mirror of
https://github.com/esphome/esphome.git
synced 2026-08-22 22:26:21 +00:00
@@ -8,7 +8,7 @@ contact_links:
|
||||
url: https://github.com/esphome/esphome-webserver/issues/new/choose
|
||||
about: Report an issue with the ESPHome web server.
|
||||
- name: Report an issue with the ESPHome Builder / Dashboard
|
||||
url: https://github.com/esphome/dashboard/issues/new/choose
|
||||
url: https://github.com/esphome/device-builder/issues/new/choose
|
||||
about: Report an issue with the ESPHome Builder / Dashboard.
|
||||
- name: Report an issue with the ESPHome API client
|
||||
url: https://github.com/esphome/aioesphomeapi/issues/new/choose
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -17,7 +17,7 @@ runs:
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
id: python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -32,7 +32,7 @@ runs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||
# downstream jobs rely on is preserved.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
|
||||
@@ -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,15 +21,15 @@ 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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
|
||||
@@ -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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -180,8 +182,8 @@ jobs:
|
||||
contents: read # actions/checkout to load the test configs
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Cap concurrency so this smoke test doesn't hog all the shared runners.
|
||||
max-parallel: 2
|
||||
# Modest cap so this smoke test leaves room on the shared runner pool.
|
||||
max-parallel: 8
|
||||
matrix:
|
||||
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
|
||||
# share a toolchain bundle, so esp32 is exercised on the base variant
|
||||
@@ -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
|
||||
|
||||
+337
-292
@@ -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,7 +49,7 @@ jobs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
||||
# that ``. venv/bin/activate`` see an identical layout.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -65,202 +65,9 @@ jobs:
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
|
||||
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-rp2-boards.py --check
|
||||
script/ci_check_duplicate_test_ids.py
|
||||
|
||||
import-time:
|
||||
name: Check import esphome.__main__ time
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.import-time == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
# reuse; dev pushes seed the shared copy instead.
|
||||
save-cache: ${{ github.event_name != 'pull_request' }}
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Install device-builder + esphome from PR
|
||||
# Install device-builder with its esphome + test extras
|
||||
# first so its pinned versions of pytest/etc. land, then
|
||||
# overlay the PR's esphome so the downstream tests run
|
||||
# against this PR's Python code. ``--system`` installs into
|
||||
# the runner's Python instead of a venv.
|
||||
run: |
|
||||
uv pip install --system -e './device-builder[esphome,test]'
|
||||
uv pip install --system -e ./esphome
|
||||
- name: Run device-builder pytest
|
||||
# ``-n auto`` runs under pytest-xdist (matches device-builder's
|
||||
# own CI). No ``--cov`` here -- this is purely a downstream
|
||||
# smoke check against this PR's esphome code. ``tests/e2e/slow``
|
||||
# is excluded: those are real multi-minute toolchain compiles
|
||||
# (LibreTiny SDK clone, native ESP-IDF install) that device-builder
|
||||
# runs in its own dedicated jobs, not this smoke check.
|
||||
working-directory: device-builder
|
||||
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow
|
||||
|
||||
pytest:
|
||||
name: Run pytest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- "3.13"
|
||||
- "3.14"
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macOS-latest
|
||||
- windows-latest
|
||||
exclude:
|
||||
# Minimize CI resource usage
|
||||
# by only running the Python version
|
||||
# version used for docker images on Windows and macOS
|
||||
- python-version: "3.13"
|
||||
os: windows-latest
|
||||
- python-version: "3.13"
|
||||
os: macOS-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@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
|
||||
@@ -291,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
|
||||
@@ -350,6 +157,169 @@ 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()
|
||||
|
||||
lint-format:
|
||||
name: Check lint and formatting
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
|
||||
with:
|
||||
# Keep in sync with requirements_test.txt.
|
||||
prek-version: "0.4.11"
|
||||
# This job only runs on pull requests, so nothing ever populates
|
||||
# the cache on dev. Every run would miss and then write a per-pull
|
||||
# request copy, which is what the old seed-cache job existed to
|
||||
# avoid. Building the hooks from scratch takes seconds, so skip it.
|
||||
cache: false
|
||||
env:
|
||||
PREK_SKIP: pylint,ci-custom
|
||||
# Pushes any fixes the hooks made back to the pull request. This step
|
||||
# must keep its default name: the GitHub App that performs the push
|
||||
# locates the workflow run by that name.
|
||||
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
||||
if: always()
|
||||
with:
|
||||
msg: apply automatic formatting fixes
|
||||
|
||||
pytest:
|
||||
name: Run pytest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.12"
|
||||
- "3.13"
|
||||
- "3.14"
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- macOS-latest
|
||||
- windows-latest
|
||||
exclude:
|
||||
# Minimize CI resource usage
|
||||
# by only running the Python version
|
||||
# version used for docker images on Windows and macOS
|
||||
- python-version: "3.13"
|
||||
os: windows-latest
|
||||
- python-version: "3.13"
|
||||
os: macOS-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
id: restore-python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Register matcher
|
||||
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
||||
- name: Run pytest
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
. ./venv/Scripts/activate.ps1
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
- name: Run pytest
|
||||
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
- name: Save Python virtual environment cache
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: venv
|
||||
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
|
||||
|
||||
codecov-empty-upload:
|
||||
name: Report no coverage to Codecov
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- determine-jobs
|
||||
# ``pytest`` is the only job that uploads coverage, and it is skipped when
|
||||
# every changed file is CI-irrelevant (see ``should_run_core_ci`` in
|
||||
# ``script/determine-jobs.py``). With no upload Codecov never reports a
|
||||
# result, so the required ``codecov/patch`` status stays pending forever and
|
||||
# the pull request can never be merged. Tell Codecov up front that this
|
||||
# commit has nothing to cover so it publishes a passing status instead.
|
||||
#
|
||||
# ``force`` skips Codecov's own check that every changed file is ignorable;
|
||||
# ``determine-jobs`` has already decided none of these files can affect
|
||||
# coverage, and Codecov would otherwise fail the status for paths it does
|
||||
# not recognise as non-testable (``docker/**``, ``.yamllint``).
|
||||
if: needs.determine-jobs.outputs.core-ci == 'false'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Report empty upload to Codecov
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
run_command: empty-upload
|
||||
force: true
|
||||
fail_ci_if_error: true
|
||||
|
||||
integration-tests:
|
||||
name: Run integration tests (${{ matrix.bucket.name }})
|
||||
runs-on: ubuntu-latest
|
||||
@@ -363,10 +333,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
|
||||
@@ -378,7 +366,7 @@ jobs:
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
@@ -408,33 +396,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
|
||||
@@ -447,7 +448,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
|
||||
@@ -465,7 +466,7 @@ jobs:
|
||||
echo "binary=$BINARY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@9f3a37ece7abc84992501a7fcd54d1704f3458fa # v4.18.4
|
||||
uses: CodSpeedHQ/action@4296e51e7041e24dadb86d1d6e8b9320d223dbe8 # v5.0.3
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -473,6 +474,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
|
||||
@@ -488,7 +516,6 @@ jobs:
|
||||
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 2
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
@@ -504,10 +531,19 @@ jobs:
|
||||
options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52
|
||||
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
|
||||
@@ -567,10 +603,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
|
||||
@@ -594,7 +641,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 +706,6 @@ jobs:
|
||||
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
@@ -674,7 +720,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
|
||||
@@ -739,12 +785,12 @@ jobs:
|
||||
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 S3
|
||||
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,
|
||||
@@ -754,11 +800,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
|
||||
@@ -825,7 +871,7 @@ jobs:
|
||||
ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }}
|
||||
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 32 || 16 }}
|
||||
matrix:
|
||||
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
|
||||
steps:
|
||||
@@ -845,7 +891,7 @@ jobs:
|
||||
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:
|
||||
@@ -990,7 +1036,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
|
||||
@@ -1016,69 +1062,62 @@ jobs:
|
||||
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
|
||||
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
|
||||
|
||||
pre-commit-seed-cache:
|
||||
name: Seed pre-commit cache
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- common
|
||||
# Saves a dev-scoped pre-commit cache that pull request runs can
|
||||
# restore, since pre-commit.ci lite itself never runs on dev pushes.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@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: Cache pre-commit environments
|
||||
id: cache-pre-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
# Must match the restore key in pre-commit-ci-lite
|
||||
# yamllint disable-line rule:line-length
|
||||
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Install pre-commit hook environments
|
||||
if: steps.cache-pre-commit.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install pre-commit
|
||||
pre-commit install-hooks
|
||||
|
||||
pre-commit-ci-lite:
|
||||
name: pre-commit.ci lite
|
||||
runs-on: ubuntu-latest
|
||||
device-builder:
|
||||
name: Test downstream esphome/device-builder
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
|
||||
if: needs.determine-jobs.outputs.device-builder == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
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 }}
|
||||
# Inlined from esphome/pre-commit-action with a restore-only cache
|
||||
# step: the pre-commit-seed-cache job owns saving this cache, so
|
||||
# pull request runs never write per-PR copies.
|
||||
- name: Restore pre-commit cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
path: esphome
|
||||
- name: Check out esphome/device-builder
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
# Must match the key pre-commit-seed-cache saves
|
||||
# yamllint disable-line rule:line-length
|
||||
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Run pre-commit
|
||||
env:
|
||||
SKIP: pylint,ci-custom
|
||||
repository: esphome/device-builder
|
||||
ref: main
|
||||
path: device-builder
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Set up uv
|
||||
# Mirrors the install shape device-builder's own CI uses
|
||||
# (esphome/device-builder#192): uv replaces pip for the
|
||||
# install step (order-of-magnitude faster on cold boots,
|
||||
# with its own wheel cache). actions/setup-python still
|
||||
# provides the interpreter.
|
||||
uses: astral-sh/setup-uv@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: |
|
||||
python -m pip install pre-commit
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
||||
if: always()
|
||||
uv pip install --system -e './device-builder[esphome,test]'
|
||||
uv pip install --system -e ./esphome
|
||||
- name: Run device-builder pytest
|
||||
# ``-n auto`` runs under pytest-xdist (matches device-builder's
|
||||
# own CI). No ``--cov`` here -- this is purely a downstream
|
||||
# smoke check against this PR's esphome code. ``tests/e2e/slow``
|
||||
# is excluded: those are real multi-minute toolchain compiles
|
||||
# (LibreTiny SDK clone, native ESP-IDF install) that device-builder
|
||||
# runs in its own dedicated jobs, not this smoke check.
|
||||
working-directory: device-builder
|
||||
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow
|
||||
|
||||
memory-impact-target-branch:
|
||||
name: Build target branch for memory impact
|
||||
@@ -1094,7 +1133,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 }}
|
||||
|
||||
@@ -1276,7 +1315,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:
|
||||
@@ -1345,7 +1384,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:
|
||||
@@ -1379,21 +1418,27 @@ jobs:
|
||||
ci-status:
|
||||
name: CI Status
|
||||
runs-on: ubuntu-24.04
|
||||
# Listed in the same order the jobs are defined above. One job is
|
||||
# deliberately left out: "benchmarks" reports through CodSpeed rather than
|
||||
# this check.
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
- ci-custom
|
||||
- pylint
|
||||
- lint-format
|
||||
- pytest
|
||||
- codecov-empty-upload
|
||||
- integration-tests
|
||||
- import-time
|
||||
- cpp-unit-tests
|
||||
- clang-tidy-single
|
||||
- clang-tidy-nosplit
|
||||
- clang-tidy-split
|
||||
- clang-tidy-esp32-variants
|
||||
- determine-jobs
|
||||
- device-builder
|
||||
- test-build-components-split
|
||||
- test-esp32-platformio
|
||||
- pre-commit-ci-lite
|
||||
- device-builder
|
||||
- memory-impact-target-branch
|
||||
- memory-impact-pr-branch
|
||||
- memory-impact-comment
|
||||
|
||||
@@ -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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
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@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Nightly Dev Release
|
||||
|
||||
# Works out the dated dev tag and starts the release workflow with it, so that
|
||||
# the release run is named after the tag it builds. A workflow run name is
|
||||
# fixed when the run starts and cannot read a file or the current date.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read # actions/checkout to read the version from esphome/const.py
|
||||
|
||||
jobs:
|
||||
trigger:
|
||||
name: Start release build
|
||||
if: github.repository == 'esphome/esphome'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # actions/checkout to read the version from esphome/const.py
|
||||
actions: write # gh workflow run starts release.yml
|
||||
steps:
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Start the release workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py)
|
||||
if [[ -z "$VERSION" ]]; then
|
||||
echo "::error::Could not read __version__ from esphome/const.py"
|
||||
exit 1
|
||||
fi
|
||||
TAG="${VERSION}$(date --utc '+%Y%m%d')"
|
||||
echo "Starting release build for ${TAG}"
|
||||
gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}"
|
||||
@@ -1,12 +1,23 @@
|
||||
---
|
||||
name: Publish Release
|
||||
|
||||
# Releases (production and beta) are named after the version they publish.
|
||||
# Dev builds are named after the dated dev tag, which is passed in by the
|
||||
# nightly workflow because a run name cannot compute it itself.
|
||||
run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }}
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: >-
|
||||
Tag to build. Only supported on dev, where the nightly workflow
|
||||
uses it. Leave empty to build the version from esphome/const.py
|
||||
with today's date appended.
|
||||
required: false
|
||||
default: ""
|
||||
release:
|
||||
types: [published]
|
||||
schedule:
|
||||
- cron: "0 2 * * *"
|
||||
|
||||
permissions:
|
||||
contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write
|
||||
@@ -20,9 +31,11 @@ jobs:
|
||||
branch_build: ${{ steps.tag.outputs.branch_build }}
|
||||
deploy_env: ${{ steps.tag.outputs.deploy_env }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Get tag
|
||||
id: tag
|
||||
env:
|
||||
INPUT_TAG: ${{ github.event.inputs.tag }}
|
||||
# yamllint disable rule:line-length
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" = "release" ]]; then
|
||||
@@ -34,12 +47,23 @@ jobs:
|
||||
ENVIRONMENT="production"
|
||||
fi
|
||||
else
|
||||
BRANCH=${GITHUB_REF#refs/heads/}
|
||||
# The nightly workflow passes the finished tag so that the run name
|
||||
# matches what is built. Without it, work it out here.
|
||||
TAG="${INPUT_TAG}"
|
||||
if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then
|
||||
echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$TAG" ]]; then
|
||||
TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
|
||||
today="$(date --utc '+%Y%m%d')"
|
||||
TAG="${TAG}${today}"
|
||||
BRANCH=${GITHUB_REF#refs/heads/}
|
||||
if [[ "$BRANCH" != "dev" ]]; then
|
||||
TAG="${TAG}-${BRANCH}"
|
||||
fi
|
||||
fi
|
||||
if [[ "$BRANCH" != "dev" ]]; then
|
||||
BRANCH_BUILD="true"
|
||||
ENVIRONMENT=""
|
||||
else
|
||||
@@ -60,9 +84,9 @@ jobs:
|
||||
contents: read # actions/checkout to build the sdist/wheel
|
||||
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
|
||||
steps:
|
||||
- uses: actions/checkout@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 +94,7 @@ jobs:
|
||||
pip3 install build
|
||||
python3 -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -92,9 +116,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 +126,12 @@ jobs:
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -168,7 +192,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 +206,13 @@ jobs:
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
+16
-31
@@ -6,33 +6,27 @@ 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
|
||||
# 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@61fd37a044cad4e9aa4303027b2a61b6a34da855 # main
|
||||
secrets:
|
||||
ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
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
|
||||
|
||||
# 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"
|
||||
# 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
|
||||
@@ -43,15 +37,6 @@ jobs:
|
||||
branch to ensure that it's up to date with the latest changes.
|
||||
|
||||
Thank you for your contribution!
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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,26 +28,26 @@ 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"
|
||||
|
||||
- name: Set up uv
|
||||
# An order of magnitude faster than pip on cold boots, with its
|
||||
# own wheel cache. ``--system`` (below) installs into the
|
||||
# setup-python interpreter so subsequent ``pre-commit`` /
|
||||
# setup-python interpreter so subsequent ``prek`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
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
|
||||
@@ -58,19 +58,19 @@ jobs:
|
||||
- name: Install Home Assistant
|
||||
run: |
|
||||
uv pip install --system -e lib/home-assistant
|
||||
uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
|
||||
uv pip install --system -r requirements.txt -r requirements_test.txt
|
||||
|
||||
- name: Sync
|
||||
run: |
|
||||
python ./script/sync-device_class.py
|
||||
|
||||
- name: Apply pre-commit auto-fixes
|
||||
- name: Apply prek auto-fixes
|
||||
# First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
|
||||
# files. pre-commit exits non-zero whenever a hook touches anything,
|
||||
# files. prek exits non-zero whenever a hook touches anything,
|
||||
# which would otherwise abort the workflow before the auto-fixes
|
||||
# can flow into the sync PR.
|
||||
#
|
||||
# SKIP:
|
||||
# PREK_SKIP:
|
||||
# - no-commit-to-branch is a local guard against committing on
|
||||
# dev/release/beta; CI runs on dev by definition, and
|
||||
# peter-evans/create-pull-request creates the branch itself.
|
||||
@@ -79,25 +79,25 @@ jobs:
|
||||
# the runtime deps (HA + requirements*.txt); main CI already
|
||||
# gates pylint on real PRs.
|
||||
env:
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files || true
|
||||
PREK_SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py prek run --all-files || true
|
||||
|
||||
- name: Verify pre-commit clean
|
||||
- name: Verify prek clean
|
||||
# Second pass: re-run all hooks against the now-fixed tree.
|
||||
# Auto-fixers exit 0 (nothing to change); any remaining failure
|
||||
# from a check-only hook (flake8 / yamllint / ci-custom) is a
|
||||
# real issue and fails the workflow loudly. Same SKIP list as
|
||||
# real issue and fails the workflow loudly. Same PREK_SKIP list as
|
||||
# above for the same reasons.
|
||||
env:
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files
|
||||
PREK_SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py prek run --all-files
|
||||
|
||||
- name: Commit changes
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
commit-message: "Synchronise Device Classes from Home Assistant"
|
||||
committer: esphomebot <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"
|
||||
|
||||
@@ -133,6 +133,8 @@ CTestTestfile.cmake
|
||||
.gcc-flags.json
|
||||
|
||||
config/
|
||||
# Test fixture config/ directories are tracked (the rule above is the dashboard dir)
|
||||
!tests/component_tests/**/config/
|
||||
tests/build/
|
||||
tests/.esphome/
|
||||
/.temp-clang-tidy.cpp
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -57,6 +57,12 @@ This document provides essential context for AI models interacting with this pro
|
||||
- Function-local constants: `lower_snake_case`
|
||||
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
|
||||
- Favor descriptive names over abbreviations
|
||||
- Enumerator names: prefix every value of an `enum class` with the enum name converted to
|
||||
`UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
|
||||
names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
|
||||
these common names (for example the Realtek SDKs used by LibreTiny define
|
||||
`#define SUCCESS 0` in `basic_types.h`), and the preprocessor replaces the enumerator
|
||||
before the compiler sees it, breaking the build and clang-tidy on those platforms.
|
||||
|
||||
* **Python Idioms:**
|
||||
* **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
|
||||
@@ -191,11 +197,14 @@ This document provides essential context for AI models interacting with this pro
|
||||
my_component_ns = cg.esphome_ns.namespace("my_component")
|
||||
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
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)
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -229,7 +238,12 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Sensor:**
|
||||
```python
|
||||
from esphome.components import sensor
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s"))
|
||||
|
||||
CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
|
||||
cv.polling_component_schema("60s")
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await sensor.new_sensor(config)
|
||||
await cg.register_component(var, config)
|
||||
@@ -238,7 +252,10 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Binary Sensor:**
|
||||
```python
|
||||
from esphome.components import binary_sensor
|
||||
|
||||
CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await binary_sensor.new_binary_sensor(config)
|
||||
```
|
||||
@@ -246,7 +263,10 @@ This document provides essential context for AI models interacting with this pro
|
||||
- **Switch:**
|
||||
```python
|
||||
from esphome.components import switch
|
||||
|
||||
CONFIG_SCHEMA = switch.switch_schema().extend({...})
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = await switch.new_switch(config)
|
||||
```
|
||||
@@ -263,10 +283,13 @@ This document provides essential context for AI models interacting with this pro
|
||||
```python
|
||||
from esphome import automation
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(MyComponent),
|
||||
cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
|
||||
}).extend(cv.COMPONENT_SCHEMA)
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
@@ -316,11 +339,14 @@ This document provides essential context for AI models interacting with this pro
|
||||
```python
|
||||
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema({
|
||||
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 +394,10 @@ This document provides essential context for AI models interacting with this pro
|
||||
```
|
||||
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
|
||||
|
||||
* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
|
||||
|
||||
* **Configuration Validation:**
|
||||
* **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
|
||||
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
|
||||
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
|
||||
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
|
||||
@@ -381,6 +410,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
.extend(i2c.i2c_device_schema(0x48))
|
||||
.extend(spi.spi_device_schema(cs_pin_required=True))
|
||||
```
|
||||
* **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check.
|
||||
|
||||
## 5. Key Files & Entrypoints
|
||||
|
||||
@@ -388,7 +418,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
* **Configuration:**
|
||||
* `pyproject.toml`: Defines the Python project metadata and dependencies.
|
||||
* `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers.
|
||||
* `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting.
|
||||
* `.pre-commit-config.yaml`: Configures the lint and format hooks, run by `prek`.
|
||||
* **CI/CD Pipeline:** Defined in `.github/workflows`.
|
||||
* **Static Analysis & Development:**
|
||||
* `esphome/core/defines.h`: A comprehensive header file containing all `#define` directives that can be added by components using `cg.add_define()` in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms.
|
||||
@@ -396,7 +426,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
## 6. Development & Testing Workflow
|
||||
|
||||
* **Local Development Environment:** Use the provided Docker container or create a Python virtual environment and install dependencies from `requirements_dev.txt`.
|
||||
* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py pre-commit run`.
|
||||
* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py prek run`.
|
||||
* **Testing:**
|
||||
* **Python:** Run unit tests with `pytest`.
|
||||
* **C++:** Use `clang-tidy` for static analysis.
|
||||
@@ -469,9 +499,9 @@ This document provides essential context for AI models interacting with this pro
|
||||
1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b <branch-name> dev` to ensure you're branching from `dev`, not the currently checked out branch).
|
||||
2. **Make Changes:** Adhere to all coding conventions and patterns.
|
||||
3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
|
||||
4. **Lint:** Run `pre-commit` to ensure code is compliant.
|
||||
4. **Lint:** Run `prek` to ensure code is compliant.
|
||||
5. **Commit:** Commit your changes. There is no strict format for commit messages.
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
|
||||
|
||||
* **Documentation Contributions:**
|
||||
* Documentation is hosted in the separate `esphome/esphome.io` repository.
|
||||
@@ -617,6 +647,7 @@ This document provides essential context for AI models interacting with this pro
|
||||
_component_state = []
|
||||
_use_feature = None
|
||||
|
||||
|
||||
def enable_feature():
|
||||
global _use_feature
|
||||
_use_feature = True
|
||||
@@ -636,20 +667,24 @@ This document provides essential context for AI models interacting with this pro
|
||||
|
||||
DOMAIN = "my_component"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyComponentData:
|
||||
feature_enabled: bool = False
|
||||
item_count: int = 0
|
||||
items: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> MyComponentData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = MyComponentData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def request_feature() -> None:
|
||||
_get_data().feature_enabled = True
|
||||
|
||||
|
||||
def add_item(item: str) -> None:
|
||||
_get_data().items.append(item)
|
||||
```
|
||||
@@ -704,10 +739,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
|
||||
|
||||
+13
@@ -69,12 +69,16 @@ esphome/components/bh1750/* @OttoWinter
|
||||
esphome/components/bh1900nux/* @B48D81EFCC
|
||||
esphome/components/binary_sensor/* @esphome/core
|
||||
esphome/components/bk72xx/* @kuba2k2
|
||||
esphome/components/bk72xx_ble/* @Bl00d-B0b
|
||||
esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b
|
||||
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
|
||||
esphome/components/bl0939/* @ziceva
|
||||
esphome/components/bl0940/* @dan-s-github @tobias-
|
||||
esphome/components/bl0942/* @dbuezas @dwmw2
|
||||
esphome/components/ble_client/* @buxtronix @clydebarrow
|
||||
esphome/components/ble_device_base/* @Bl00d-B0b
|
||||
esphome/components/ble_nus/* @tomaszduda23
|
||||
esphome/components/bluetooth_connection/* @bdraco @jesserockz
|
||||
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
|
||||
esphome/components/bm8563/* @abmantis
|
||||
esphome/components/bme280_base/* @esphome/core
|
||||
@@ -145,6 +149,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
|
||||
@@ -233,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek
|
||||
esphome/components/hm3301/* @freekode
|
||||
esphome/components/hmac_md5/* @dwmw2
|
||||
esphome/components/hmac_sha256/* @dwmw2
|
||||
esphome/components/hoermann_hcp/* @zweckj
|
||||
esphome/components/homeassistant/* @esphome/core @OttoWinter
|
||||
esphome/components/homeassistant/number/* @landonr
|
||||
esphome/components/homeassistant/switch/* @Links2004
|
||||
@@ -284,6 +290,7 @@ esphome/components/ld2412/* @Rihan9
|
||||
esphome/components/ld2420/* @descipher
|
||||
esphome/components/ld2450/* @hareeshmu
|
||||
esphome/components/ld24xx/* @kbx81
|
||||
esphome/components/ld6002b/* @hepter
|
||||
esphome/components/ledc/* @OttoWinter
|
||||
esphome/components/libretiny/* @kuba2k2
|
||||
esphome/components/libretiny_pwm/* @kuba2k2
|
||||
@@ -291,6 +298,8 @@ esphome/components/light/* @esphome/core
|
||||
esphome/components/lightwaverf/* @max246
|
||||
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
|
||||
esphome/components/lm75b/* @beormund
|
||||
esphome/components/ln882h_ble/* @Bl00d-B0b
|
||||
esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
|
||||
esphome/components/ln882x/* @lamauny
|
||||
esphome/components/lock/* @esphome/core
|
||||
esphome/components/logger/* @esphome/core
|
||||
@@ -345,6 +354,7 @@ esphome/components/mlx90393/* @functionpointer
|
||||
esphome/components/mlx90614/* @jesserockz
|
||||
esphome/components/mmc5603/* @benhoff
|
||||
esphome/components/mmc5983/* @agoode
|
||||
esphome/components/modbus_client/* @exciton
|
||||
esphome/components/modbus_controller/* @martgras
|
||||
esphome/components/modbus_controller/binary_sensor/* @martgras
|
||||
esphome/components/modbus_controller/number/* @martgras
|
||||
@@ -433,6 +443,7 @@ esphome/components/rp2/* @jesserockz
|
||||
esphome/components/rp2040_ble/* @bdraco
|
||||
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
|
||||
esphome/components/rp2040_pwm/* @jesserockz
|
||||
esphome/components/rp2_ble_tracker/* @bdraco
|
||||
esphome/components/rpi_dpi_rgb/* @clydebarrow
|
||||
esphome/components/rtl87xx/* @kuba2k2
|
||||
esphome/components/rtttl/* @glmnet @ximex
|
||||
@@ -455,6 +466,7 @@ esphome/components/sen21231/* @shreyaskarnik
|
||||
esphome/components/sen5x/* @martgras
|
||||
esphome/components/sen6x/* @martgras @mebner86 @tuct
|
||||
esphome/components/sendspin/* @kahrendt
|
||||
esphome/components/sendspin/image/* @kahrendt
|
||||
esphome/components/sendspin/media_player/* @kahrendt
|
||||
esphome/components/sendspin/media_source/* @kahrendt
|
||||
esphome/components/sendspin/sensor/* @kahrendt
|
||||
@@ -623,6 +635,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
|
||||
esphome/components/xxtea/* @clydebarrow
|
||||
esphome/components/zephyr/* @tomaszduda23
|
||||
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
|
||||
esphome/components/zephyr_pwm/* @wiomoc
|
||||
esphome/components/zhlt01/* @cfeenstra1024
|
||||
esphome/components/zigbee/* @luar123 @tomaszduda23
|
||||
esphome/components/zio_ultrasonic/* @kahrendt
|
||||
|
||||
@@ -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.4
|
||||
PROJECT_NUMBER = 2026.8.0b1
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+1
-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.9.2
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.9.5
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+150
-74
@@ -2,39 +2,33 @@
|
||||
import argparse
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from datetime import datetime
|
||||
import functools
|
||||
import getpass
|
||||
import importlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
import argcomplete
|
||||
|
||||
# Note: Do not import modules from esphome.components here, as this would
|
||||
# cause them to be loaded before external components are processed, resulting
|
||||
# in the built-in version being used instead of the external component one.
|
||||
from esphome import const
|
||||
import esphome.codegen as cg
|
||||
from esphome.config import iter_component_configs, read_config, strip_default_ids
|
||||
from esphome import const, platform_hooks
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_API,
|
||||
CONF_AUTH,
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
CONF_DEASSERT_RTS_DTR,
|
||||
CONF_DISABLED,
|
||||
CONF_DISCOVER_IP,
|
||||
CONF_ESPHOME,
|
||||
CONF_LEVEL,
|
||||
CONF_LOG,
|
||||
CONF_LOG_TOPIC,
|
||||
CONF_LOGGER,
|
||||
CONF_MDNS,
|
||||
@@ -48,10 +42,12 @@ from esphome.const import (
|
||||
CONF_PORT,
|
||||
CONF_SUBSTITUTIONS,
|
||||
CONF_TOPIC,
|
||||
CONF_USERNAME,
|
||||
CONF_VERSION,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_ESP32,
|
||||
KEY_VARIANT,
|
||||
SECRETS_FILES,
|
||||
Toolchain,
|
||||
)
|
||||
@@ -59,6 +55,7 @@ from esphome.core import CORE, EsphomeError, coroutine
|
||||
from esphome.enum import StrEnum
|
||||
from esphome.helpers import get_bool_env, indent, is_ip_address
|
||||
from esphome.log import AnsiFore, color, setup_log
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.types import ConfigType
|
||||
from esphome.upload_targets import PortType, get_port_type
|
||||
from esphome.util import (
|
||||
@@ -276,8 +273,8 @@ def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str:
|
||||
if purpose == Purpose.LOGGING and not has_api():
|
||||
return (
|
||||
"Cannot view logs over the network: no 'api:' component is "
|
||||
"configured. Network log streaming requires the native API; add "
|
||||
"an 'api:' component, enable MQTT logging, or view logs over USB."
|
||||
"configured. Add an 'api:' component, enable MQTT logging, add a "
|
||||
"'web_server:' component, or view logs over USB."
|
||||
)
|
||||
if purpose == Purpose.UPLOADING and not has_ota():
|
||||
return (
|
||||
@@ -317,9 +314,12 @@ def choose_upload_log_host(
|
||||
]
|
||||
resolved.append(choose_prompt(options, purpose=purpose))
|
||||
elif device == "OTA":
|
||||
# Logs can stream over a network transport via the native API
|
||||
# or the web_server HTTP SSE feed.
|
||||
network_logging = has_api() or has_web_server_logging()
|
||||
# ensure IP adresses are used first
|
||||
if is_ip_address(CORE.address) and (
|
||||
(purpose == Purpose.LOGGING and has_api())
|
||||
(purpose == Purpose.LOGGING and network_logging)
|
||||
or (purpose == Purpose.UPLOADING and has_ota())
|
||||
):
|
||||
resolved.extend(_resolve_with_cache(CORE.address, purpose))
|
||||
@@ -331,7 +331,11 @@ def choose_upload_log_host(
|
||||
if has_mqtt_logging():
|
||||
resolved.append("MQTT")
|
||||
|
||||
if has_api() and has_non_ip_address() and has_resolvable_address():
|
||||
if (
|
||||
network_logging
|
||||
and has_non_ip_address()
|
||||
and has_resolvable_address()
|
||||
):
|
||||
resolved.extend(_ota_hostnames_for_default(purpose))
|
||||
|
||||
elif purpose == Purpose.UPLOADING:
|
||||
@@ -393,7 +397,7 @@ def choose_upload_log_host(
|
||||
mqtt_config = CORE.config[CONF_MQTT]
|
||||
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
|
||||
|
||||
if has_api():
|
||||
if has_api() or has_web_server_logging():
|
||||
add_ota_options()
|
||||
|
||||
elif purpose == Purpose.UPLOADING and has_ota():
|
||||
@@ -486,10 +490,23 @@ def has_web_server_ota() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def has_web_server_logging() -> bool:
|
||||
"""Check if logs can be streamed over the web_server HTTP SSE endpoint.
|
||||
|
||||
The ``web_server`` component exposes a ``/events`` Server-Sent Events
|
||||
stream that carries ``event: log`` frames. This requires version 2+ (the
|
||||
v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default).
|
||||
"""
|
||||
web_conf = CORE.config.get(CONF_WEB_SERVER)
|
||||
if web_conf is None:
|
||||
return False
|
||||
if web_conf.get(CONF_VERSION, 2) == 1:
|
||||
return False
|
||||
return web_conf.get(CONF_LOG, True)
|
||||
|
||||
|
||||
def has_mqtt_ip_lookup() -> bool:
|
||||
"""Check if MQTT is available and IP lookup is supported."""
|
||||
from esphome.components.mqtt import CONF_DISCOVER_IP
|
||||
|
||||
if CONF_MQTT not in CORE.config:
|
||||
return False
|
||||
# Default Enabled
|
||||
@@ -622,6 +639,8 @@ def _resolve_network_devices(
|
||||
|
||||
|
||||
def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
from datetime import datetime
|
||||
|
||||
from aioesphomeapi import LogParser
|
||||
import serial
|
||||
|
||||
@@ -634,18 +653,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
return 1
|
||||
_LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate)
|
||||
|
||||
process_stacktrace = None
|
||||
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
process_stacktrace = module.process_stacktrace
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
CORE.target_platform,
|
||||
)
|
||||
|
||||
backtrace_state = False
|
||||
# Decoder resolution, crash isolation, and disable-after-failure
|
||||
# all live in LogLineProcessor, shared with the API log path.
|
||||
processor = LogLineProcessor(config, CORE.target_platform)
|
||||
ser = serial.Serial()
|
||||
ser.baudrate = baud_rate
|
||||
ser.port = port
|
||||
@@ -685,11 +695,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
"utf8", "backslashreplace"
|
||||
)
|
||||
safe_print(parser.parse_line(line, time_str))
|
||||
|
||||
if process_stacktrace is not None:
|
||||
backtrace_state = process_stacktrace(
|
||||
config, line, backtrace_state
|
||||
)
|
||||
processor.process_line(line)
|
||||
except serial.SerialException:
|
||||
_LOGGER.error("Serial port closed!")
|
||||
return 0
|
||||
@@ -704,6 +710,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 +747,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...")
|
||||
|
||||
@@ -931,9 +940,10 @@ def upload_using_esptool(
|
||||
|
||||
mcu = "esp8266"
|
||||
if CORE.is_esp32:
|
||||
from esphome.components.esp32 import get_esp32_variant
|
||||
|
||||
mcu = get_esp32_variant().lower()
|
||||
# Same lookup as esp32.get_esp32_variant(), read directly so the
|
||||
# serial upload path does not import the esp32 package; both the
|
||||
# validator and the warm-cache apply_to_core populate this key.
|
||||
mcu = CORE.data[KEY_ESP32][KEY_VARIANT].lower()
|
||||
|
||||
line_callbacks: list[Callable[[str], str | None]] = []
|
||||
if (
|
||||
@@ -987,6 +997,8 @@ def upload_using_esptool(
|
||||
|
||||
|
||||
def upload_using_platformio(config: ConfigType, port: str) -> int:
|
||||
import shutil
|
||||
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
|
||||
@@ -1024,6 +1036,8 @@ def upload_using_picotool(config: ConfigType) -> int:
|
||||
the mass storage copy approach that causes "disk not ejected properly"
|
||||
warnings on macOS.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
@@ -1130,6 +1144,8 @@ def check_permissions(port: str):
|
||||
"the USB cable can be used for data and is not a power-only cable."
|
||||
)
|
||||
if not (os.access(port, os.R_OK | os.W_OK)):
|
||||
import getpass
|
||||
|
||||
raise EsphomeError(
|
||||
"You do not have read or write permission on the selected serial port. "
|
||||
"To resolve this issue, you can add your user to the dialout group "
|
||||
@@ -1142,12 +1158,11 @@ def upload_program(
|
||||
config: ConfigType, args: ArgsProtocol, devices: list[str]
|
||||
) -> tuple[int, str | None]:
|
||||
host = devices[0]
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if module.upload_program(config, args, host):
|
||||
platform_upload = platform_hooks.get_platform_hook(
|
||||
CORE.target_platform, "upload_program"
|
||||
)
|
||||
if platform_upload is not None and platform_upload(config, args, host):
|
||||
return 0, host
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
port_type = get_port_type(host)
|
||||
|
||||
@@ -1298,25 +1313,23 @@ def _upload_via_native_api(
|
||||
def _upload_via_web_server(
|
||||
config: ConfigType, network_devices: list[str], binary: Path
|
||||
) -> tuple[int, str | None]:
|
||||
web_conf = config.get(CONF_WEB_SERVER)
|
||||
if not web_conf:
|
||||
raise EsphomeError(
|
||||
f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
|
||||
f"is not configured."
|
||||
)
|
||||
|
||||
remote_port = int(web_conf[CONF_PORT])
|
||||
auth = web_conf.get(CONF_AUTH) or {}
|
||||
username = auth.get(CONF_USERNAME)
|
||||
password = auth.get(CONF_PASSWORD)
|
||||
|
||||
from esphome import web_server_ota
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
remote_port, username, password = get_web_server_connection(config)
|
||||
return web_server_ota.run_ota(
|
||||
network_devices, remote_port, username, password, binary
|
||||
)
|
||||
|
||||
|
||||
def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int:
|
||||
from esphome import web_server_logs
|
||||
from esphome.web_server_helpers import get_web_server_connection
|
||||
|
||||
port, username, password = get_web_server_connection(config)
|
||||
return web_server_logs.run_logs(network_devices, port, username, password)
|
||||
|
||||
|
||||
# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
|
||||
# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
|
||||
# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
|
||||
@@ -1407,12 +1420,11 @@ def _should_subscribe_states(args: ArgsProtocol) -> bool:
|
||||
|
||||
|
||||
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if module.show_logs(config, args, devices):
|
||||
platform_show_logs = platform_hooks.get_platform_hook(
|
||||
CORE.target_platform, "show_logs"
|
||||
)
|
||||
if platform_show_logs is not None and platform_show_logs(config, args, devices):
|
||||
return 0
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
if "logger" not in config:
|
||||
raise EsphomeError("Logger is not configured!")
|
||||
@@ -1430,7 +1442,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
if has_api() and (
|
||||
network_devices := _resolve_network_devices(devices, config, args)
|
||||
):
|
||||
from esphome.components.api.client import run_logs
|
||||
from esphome.api_client import run_logs
|
||||
|
||||
return run_logs(
|
||||
config,
|
||||
@@ -1445,6 +1457,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
|
||||
config, args.topic, args.username, args.password, args.client_id
|
||||
)
|
||||
|
||||
# Fall back to the web_server HTTP SSE log stream for devices that have
|
||||
# web_server: but no api: (the logging counterpart to web_server OTA).
|
||||
if has_web_server_logging() and (
|
||||
network_devices := _resolve_network_devices(devices, config, args)
|
||||
):
|
||||
return _show_logs_via_web_server(config, network_devices)
|
||||
|
||||
raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
|
||||
|
||||
|
||||
@@ -1464,6 +1483,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)
|
||||
@@ -1713,7 +1733,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
|
||||
def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator
|
||||
from esphome.bundle import ConfigBundleCreator
|
||||
|
||||
creator = ConfigBundleCreator(config)
|
||||
|
||||
@@ -1948,7 +1968,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
new_name = args.name
|
||||
for c in new_name:
|
||||
if c not in ALLOWED_NAME_CHARS:
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{c}' is an invalid character for names. Valid characters are: "
|
||||
@@ -1961,7 +1981,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
yaml = yaml_util.load_yaml(CORE.config_path)
|
||||
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
|
||||
)
|
||||
@@ -2008,7 +2028,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
> 1
|
||||
):
|
||||
print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename"))
|
||||
safe_print(
|
||||
color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
|
||||
)
|
||||
return 1
|
||||
|
||||
new_raw = re.sub(
|
||||
@@ -2026,7 +2048,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
|
||||
# would otherwise just re-flash the same hostname).
|
||||
if new_name == old_name:
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
@@ -2036,7 +2058,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
new_path: Path = CORE.config_dir / (new_name + ".yaml")
|
||||
if new_path.resolve() == CORE.config_path.resolve():
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"'{new_name}' is already the device's name.",
|
||||
@@ -2044,7 +2066,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
return 1
|
||||
if new_path.exists():
|
||||
print(
|
||||
safe_print(
|
||||
color(
|
||||
AnsiFore.BOLD_RED,
|
||||
f"Cannot rename: {new_path} already exists. "
|
||||
@@ -2052,7 +2074,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
)
|
||||
)
|
||||
return 1
|
||||
print(
|
||||
safe_print(
|
||||
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
|
||||
)
|
||||
print()
|
||||
@@ -2061,7 +2083,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
|
||||
if rc != 0:
|
||||
print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
|
||||
new_path.unlink()
|
||||
return 1
|
||||
|
||||
@@ -2087,7 +2109,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
if CORE.config_path != new_path:
|
||||
CORE.config_path.unlink()
|
||||
|
||||
print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
|
||||
print()
|
||||
return 0
|
||||
|
||||
@@ -2498,6 +2520,11 @@ def parse_args(argv):
|
||||
# a deprecation warning).
|
||||
arguments = argv[1:]
|
||||
|
||||
# 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:
|
||||
@@ -2509,6 +2536,49 @@ def parse_args(argv):
|
||||
return parser.parse_args(arguments)
|
||||
|
||||
|
||||
def _warn_if_source_tree_mismatch() -> None:
|
||||
"""Warn when the checkout the user is standing in is not the one being run.
|
||||
|
||||
An editable install records one absolute path, so a venv shared between git
|
||||
worktrees (or reused after a checkout is copied or renamed) keeps importing
|
||||
the tree it was installed from. Every command then silently runs, and
|
||||
compiles, sources the user is not looking at. Only fires inside a checkout,
|
||||
so ordinary installs never see it.
|
||||
"""
|
||||
try:
|
||||
cwd = Path.cwd()
|
||||
except OSError:
|
||||
return # working directory is gone; a diagnostic must not break startup
|
||||
for candidate in (cwd, *cwd.parents):
|
||||
if (candidate / "esphome" / "__main__.py").is_file():
|
||||
standing_in = candidate.resolve()
|
||||
break
|
||||
else:
|
||||
return # not inside a checkout; nothing to compare against
|
||||
|
||||
running = Path(__file__).resolve().parent.parent
|
||||
# Both sides are resolved, so on a case-sensitive filesystem this matches
|
||||
# plain equality. samefile() compares device and inode, which additionally
|
||||
# covers a case-insensitive filesystem (macOS) reaching one directory by
|
||||
# differently cased paths. Falls back to equality if either path is gone.
|
||||
try:
|
||||
same = standing_in.samefile(running)
|
||||
except OSError:
|
||||
same = standing_in == running
|
||||
if same:
|
||||
return
|
||||
|
||||
_LOGGER.warning(
|
||||
"Running ESPHome from a different checkout than the one you are in:\n"
|
||||
" running from: %s\n"
|
||||
" you are in: %s\n"
|
||||
"The installed esphome resolves to the first, so its sources are used.\n"
|
||||
"Run 'python -m esphome' from the second to use that one instead.",
|
||||
running,
|
||||
standing_in,
|
||||
)
|
||||
|
||||
|
||||
def run_esphome(argv):
|
||||
from esphome.address_cache import AddressCache
|
||||
|
||||
@@ -2527,6 +2597,7 @@ def run_esphome(argv):
|
||||
args.log_level = "CRITICAL"
|
||||
|
||||
setup_log(log_level=args.log_level)
|
||||
_warn_if_source_tree_mismatch()
|
||||
|
||||
if args.command in PRE_CONFIG_ACTIONS:
|
||||
try:
|
||||
@@ -2558,10 +2629,11 @@ def run_esphome(argv):
|
||||
return 0
|
||||
|
||||
# Bundle support: if the configuration is a .esphomebundle, extract it
|
||||
# and rewrite conf_path to the extracted YAML config.
|
||||
from esphome.bundle import is_bundle_path, prepare_bundle_for_compile
|
||||
# and rewrite conf_path to the extracted YAML config. The suffix check
|
||||
# stays inline so the ordinary run never imports esphome.bundle.
|
||||
if conf_path.name.lower().endswith(BUNDLE_EXTENSION):
|
||||
from esphome.bundle import prepare_bundle_for_compile
|
||||
|
||||
if is_bundle_path(conf_path):
|
||||
_LOGGER.info("Extracting config bundle %s...", conf_path)
|
||||
conf_path = prepare_bundle_for_compile(conf_path)
|
||||
# Update the argument so downstream code sees the extracted path
|
||||
@@ -2597,9 +2669,13 @@ 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,
|
||||
# Snapshot only needed by `esphome config --no-defaults`.
|
||||
snapshot_user_config=getattr(args, "no_defaults", False),
|
||||
)
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config. Skip when the storage
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import warnings
|
||||
|
||||
# Suppress protobuf version warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore", category=UserWarning, message=".*Protobuf gencode version.*"
|
||||
)
|
||||
from aioesphomeapi import APIClient, parse_log_message
|
||||
from aioesphomeapi.log_runner import async_run
|
||||
|
||||
from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_PORT, __version__
|
||||
from esphome.core import CORE
|
||||
from esphome.stacktrace import LogLineProcessor
|
||||
from esphome.util import safe_print
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
SubscribeLogsResponse, # pylint: disable=no-name-in-module
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
) -> None:
|
||||
"""Run the logs command in the event loop."""
|
||||
from datetime import datetime
|
||||
|
||||
conf = config["api"]
|
||||
name = config["esphome"]["name"]
|
||||
port: int = int(conf[CONF_PORT])
|
||||
noise_psk: str | None = None
|
||||
if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)):
|
||||
noise_psk = key
|
||||
|
||||
_LOGGER.info(
|
||||
"Starting log output from %s using esphome API", " or ".join(addresses)
|
||||
)
|
||||
|
||||
cli = APIClient(
|
||||
addresses[0], # Primary address for compatibility
|
||||
port,
|
||||
"", # Password auth removed in 2026.1.0
|
||||
client_info=f"ESPHome Logs {__version__}",
|
||||
noise_psk=noise_psk,
|
||||
addresses=addresses, # Pass all addresses for automatic retry
|
||||
provide_time=False,
|
||||
)
|
||||
|
||||
# Decoder resolution policy lives in LogLineProcessor.
|
||||
processor = LogLineProcessor(config, CORE.target_platform)
|
||||
|
||||
def on_log(msg: SubscribeLogsResponse) -> None:
|
||||
"""Handle a new log message."""
|
||||
time_ = datetime.now().astimezone()
|
||||
message: bytes = msg.message
|
||||
text = message.decode("utf8", "backslashreplace")
|
||||
nanoseconds = time_.microsecond // 1000
|
||||
timestamp = (
|
||||
f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]"
|
||||
)
|
||||
for parsed_msg in parse_log_message(text, timestamp):
|
||||
# safe_print handles the dashboard \033 escaping and falls back
|
||||
# to backslashreplace encoding on stdouts that can't represent
|
||||
# the wifi signal-bar block characters (Windows redirected
|
||||
# cp1252 pipe).
|
||||
safe_print(parsed_msg)
|
||||
for raw_line in text.splitlines():
|
||||
processor.process_line(raw_line)
|
||||
|
||||
# Safe to fall back to plaintext here only for this diagnostics use
|
||||
# case: the stream is one-way from device to client, and this code
|
||||
# never accepts commands or acts on any message the device sends.
|
||||
# An on-path attacker could still both inject fabricated log lines
|
||||
# and passively read the device's log output (and any state data
|
||||
# delivered when subscribe_states is enabled), so this does lose
|
||||
# confidentiality as well as authentication/integrity. That tradeoff
|
||||
# is acceptable for operator-visible logs, which aioesphomeapi also
|
||||
# warns may come from an unverified device. Never mirror this opt-in
|
||||
# for any connection that sends data to the device or uses Home
|
||||
# Assistant actions.
|
||||
stop = await async_run(
|
||||
cli,
|
||||
on_log,
|
||||
name=name,
|
||||
subscribe_states=subscribe_states,
|
||||
allow_plaintext_fallback=True,
|
||||
# A top-level ``deep_sleep:`` block means the device is only awake
|
||||
# briefly; cap the reconnect backoff so a wake window is not missed.
|
||||
deep_sleep="deep_sleep" in config,
|
||||
)
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await stop()
|
||||
|
||||
|
||||
def run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
) -> None:
|
||||
"""Run the logs command."""
|
||||
with suppress(KeyboardInterrupt):
|
||||
asyncio.run(
|
||||
async_run_logs(config, addresses, subscribe_states=subscribe_states)
|
||||
)
|
||||
+110
-17
@@ -11,43 +11,136 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from itertools import count
|
||||
import logging
|
||||
import threading
|
||||
from typing import cast
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# How long the orphan watcher waits for an abandoned coroutine before giving
|
||||
# up, so a hung operation does not park a watcher thread forever.
|
||||
ORPHAN_WAIT_TIMEOUT = 300.0
|
||||
|
||||
|
||||
_runner_ids = count(1)
|
||||
|
||||
|
||||
class AsyncDispatchTimeout(TimeoutError):
|
||||
"""The caller stopped waiting; the coroutine was abandoned.
|
||||
|
||||
A subclass so callers can tell the dispatcher's own expiry apart from a
|
||||
``TimeoutError`` raised inside the coroutine, while existing
|
||||
``except TimeoutError`` handlers keep working.
|
||||
"""
|
||||
|
||||
|
||||
class AsyncThreadRunner[T](threading.Thread):
|
||||
"""Run an async coroutine in a daemon thread and expose its result.
|
||||
|
||||
The runner catches all exceptions from the coroutine and stores them in
|
||||
``exception`` so ``event`` is always set — this prevents callers waiting
|
||||
on ``event`` from hanging forever when the coroutine crashes.
|
||||
|
||||
Typical usage::
|
||||
|
||||
runner = AsyncThreadRunner(lambda: my_coro(arg))
|
||||
runner.start()
|
||||
if not runner.event.wait(timeout=5.0):
|
||||
... # timed out
|
||||
if runner.exception is not None:
|
||||
raise runner.exception
|
||||
result = runner.result
|
||||
``event`` is always set, even when the coroutine crashes, so waiters
|
||||
never hang; ``completed`` distinguishes a delivered result (even a
|
||||
legitimate ``None``) from a coroutine that never finished. Prefer
|
||||
:func:`run_async`; use this class directly only when a failure should
|
||||
degrade to a default value instead of raising.
|
||||
"""
|
||||
|
||||
def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None:
|
||||
super().__init__(daemon=True)
|
||||
super().__init__(daemon=True, name=f"async-thread-runner-{next(_runner_ids)}")
|
||||
self._coro_factory = coro_factory
|
||||
self.result: T | None = None
|
||||
self.exception: BaseException | None = None
|
||||
self.completed = False
|
||||
self.event = threading.Event()
|
||||
|
||||
async def _runner(self) -> None:
|
||||
try:
|
||||
self.result = await self._coro_factory()
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Capture all exceptions so ``event`` is always set — otherwise a
|
||||
# crash would hang the waiter forever.
|
||||
# Distinguishes a delivered result from "never ran", since None
|
||||
# is a valid result value.
|
||||
self.completed = True
|
||||
except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# Capture everything, including BaseException — otherwise a
|
||||
# cancellation or SystemExit would leave ``exception`` unset and
|
||||
# waiters would mistake the empty ``result`` for success.
|
||||
self.exception = exc
|
||||
finally:
|
||||
self.event.set()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
asyncio.run(self._runner())
|
||||
except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
# asyncio.run itself can fail before _runner executes (e.g. loop
|
||||
# creation under fd exhaustion); record it so waiters never hang.
|
||||
# A failure during loop cleanup after the coroutine completed
|
||||
# must not clobber the delivered result, hence the guard.
|
||||
if self.exception is None and not self.completed:
|
||||
self.exception = exc
|
||||
else:
|
||||
_LOGGER.debug(
|
||||
"Event loop teardown failed after outcome recorded",
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
self.event.set()
|
||||
|
||||
|
||||
def run_async[T](
|
||||
coro_factory: Callable[[], Awaitable[T]],
|
||||
timeout: float | None = None,
|
||||
on_orphan: Callable[[T], None] | None = None,
|
||||
) -> T:
|
||||
"""Run a coroutine in a daemon-thread event loop and return its result.
|
||||
|
||||
Raises :class:`AsyncDispatchTimeout` if the coroutine does not finish
|
||||
within ``timeout`` seconds; the thread is abandoned and exits with the
|
||||
interpreter. If the abandoned coroutine later produces a result,
|
||||
``on_orphan`` (if given) is called with it so resources such as a
|
||||
connected socket can be released; delivery is best effort and bounded
|
||||
by ``ORPHAN_WAIT_TIMEOUT``.
|
||||
"""
|
||||
runner: AsyncThreadRunner[T] = AsyncThreadRunner(coro_factory)
|
||||
runner.start()
|
||||
if not runner.event.wait(timeout):
|
||||
|
||||
def _cleanup() -> None:
|
||||
if not runner.event.wait(ORPHAN_WAIT_TIMEOUT):
|
||||
# The one state where a resource can genuinely leak; leave
|
||||
# a trace so a recurring hang is attributable.
|
||||
_LOGGER.info(
|
||||
"Orphan watcher gave up after %.0fs; a late result may leak",
|
||||
ORPHAN_WAIT_TIMEOUT,
|
||||
)
|
||||
return
|
||||
if not runner.completed:
|
||||
# The only place an abandoned thread's real error surfaces;
|
||||
# without it a late failure hides behind the TimeoutError.
|
||||
# INFO, not DEBUG: it fires at most once per abandoned
|
||||
# operation and the cause may not reproduce on a rerun.
|
||||
_LOGGER.info(
|
||||
"Abandoned async operation failed",
|
||||
exc_info=runner.exception,
|
||||
)
|
||||
return
|
||||
if (result := runner.result) is None:
|
||||
return
|
||||
if on_orphan is None:
|
||||
_LOGGER.debug("Discarding late result; no on_orphan handler")
|
||||
return
|
||||
try:
|
||||
on_orphan(result)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# INFO, not DEBUG: a failed release means a real leak, and
|
||||
# it fires at most once per abandoned operation.
|
||||
_LOGGER.info("Error releasing orphaned result", exc_info=True)
|
||||
|
||||
threading.Thread(
|
||||
target=_cleanup, daemon=True, name="async-orphan-cleanup"
|
||||
).start()
|
||||
raise AsyncDispatchTimeout("Timed out waiting for async operation")
|
||||
if (exc := runner.exception) is not None:
|
||||
raise exc
|
||||
if not runner.completed:
|
||||
raise RuntimeError("Async operation finished without a result or an exception")
|
||||
return cast("T", runner.result)
|
||||
|
||||
+37
-6
@@ -20,6 +20,7 @@ from typing import Any
|
||||
|
||||
from esphome import const, yaml_util
|
||||
from esphome.const import (
|
||||
BUNDLE_EXTENSION,
|
||||
CONF_ESPHOME,
|
||||
CONF_EXTERNAL_COMPONENTS,
|
||||
CONF_INCLUDES,
|
||||
@@ -29,12 +30,12 @@ from esphome.const import (
|
||||
CONF_TYPE,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.util import filter_yaml_files
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "bundle"
|
||||
|
||||
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
|
||||
MANIFEST_FILENAME = "manifest.json"
|
||||
CURRENT_MANIFEST_VERSION = 1
|
||||
MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB
|
||||
@@ -128,6 +129,9 @@ class BundleData:
|
||||
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
|
||||
|
||||
extra_files: list[Path] = field(default_factory=list)
|
||||
# Directories whose YAML files are scanned for !secret references but
|
||||
# never bundled, e.g. git package checkouts the builder re-fetches.
|
||||
secret_scan_dirs: set[Path] = field(default_factory=set)
|
||||
# Original config dir parsed from an extracted bundle's manifest.json,
|
||||
# kept in the path flavor of the machine the bundle was created on.
|
||||
# The checked flag makes the manifest lookup happen at most once per run;
|
||||
@@ -155,6 +159,30 @@ def add_bundle_file(path: Path) -> None:
|
||||
_get_data().extra_files.append(CORE.relative_config_path(path))
|
||||
|
||||
|
||||
def add_secret_scan_dir(path: Path) -> None:
|
||||
"""Register a directory to scan for ``!secret`` references when bundling.
|
||||
|
||||
The directory's files are not added to the bundle. Components call this
|
||||
for YAML the build consumes without bundling it — such as git-fetched
|
||||
packages, which the builder re-fetches — so the secrets those files
|
||||
reference are still shipped in the filtered secrets file.
|
||||
|
||||
A relative path is taken as relative to the config directory.
|
||||
"""
|
||||
if not path.is_absolute():
|
||||
path = CORE.relative_config_path(path)
|
||||
_get_data().secret_scan_dirs.add(path)
|
||||
|
||||
|
||||
def _secret_scan_yaml_files() -> list[Path]:
|
||||
"""Return the YAML files inside registered secret-scan directories."""
|
||||
return filter_yaml_files(
|
||||
f
|
||||
for scan_dir in _get_data().secret_scan_dirs
|
||||
for f in yaml_util.find_files(scan_dir, "*")
|
||||
)
|
||||
|
||||
|
||||
# Windows paths start with a drive letter or contain backslashes; POSIX
|
||||
# paths do neither in practice, so this is how the flavor of a recorded
|
||||
# path string is recognized on any host.
|
||||
@@ -310,6 +338,7 @@ class ConfigBundleCreator:
|
||||
yaml_sources = [
|
||||
bf.source for bf in files if bf.source.suffix in (".yaml", ".yml")
|
||||
]
|
||||
yaml_sources.extend(_secret_scan_yaml_files())
|
||||
used_secret_keys = _find_used_secret_keys(yaml_sources)
|
||||
filtered_secrets = self._build_filtered_secrets(used_secret_keys)
|
||||
|
||||
@@ -394,6 +423,13 @@ class ConfigBundleCreator:
|
||||
"""
|
||||
discovered = yaml_util.discover_user_yaml_files(self._config_path)
|
||||
self._secrets_paths.update(discovered.secrets)
|
||||
# A !secret inside a file this re-parse does not reach (for example
|
||||
# a git-fetched package the builder re-fetches) still resolves
|
||||
# against the config-dir secrets.yaml at build time, so always
|
||||
# consider that file; filtering no-ops when no key matches.
|
||||
default_secrets = self._config_dir / yaml_util.SECRET_YAML
|
||||
if default_secrets.is_file():
|
||||
self._secrets_paths.add(default_secrets.resolve())
|
||||
config_resolved = self._config_path.resolve()
|
||||
for fpath in discovered.files:
|
||||
if fpath == config_resolved:
|
||||
@@ -719,11 +755,6 @@ def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None:
|
||||
)
|
||||
|
||||
|
||||
def is_bundle_path(path: Path) -> bool:
|
||||
"""Check if a path looks like a bundle file."""
|
||||
return path.name.lower().endswith(BUNDLE_EXTENSION)
|
||||
|
||||
|
||||
def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
|
||||
"""Add in-memory bytes to a tar archive with deterministic metadata."""
|
||||
info = tarfile.TarInfo(name=name)
|
||||
|
||||
@@ -49,10 +49,13 @@ from esphome.cpp_helpers import ( # noqa: F401
|
||||
build_registry_entry,
|
||||
build_registry_list,
|
||||
extract_registry_entry_config,
|
||||
get_slot_count,
|
||||
gpio_pin_expression,
|
||||
past_safe_mode,
|
||||
register_component,
|
||||
register_parented,
|
||||
set_setup_priority,
|
||||
slot_counter,
|
||||
)
|
||||
from esphome.cpp_types import ( # noqa: F401
|
||||
NAN,
|
||||
|
||||
+90
-23
@@ -1,48 +1,69 @@
|
||||
"""Validated-config cache for the upload/logs fast path.
|
||||
|
||||
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
|
||||
compile dumps the validated config to <data_dir>/storage/<file>.validated.json;
|
||||
the next upload/logs for that YAML reuses it instead of running the full
|
||||
read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
|
||||
!lambda/!include/IDs/paths intact; mtime gates staleness.
|
||||
read_config pipeline. The cache is deliberately lossy: only ``!lambda``
|
||||
bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses,
|
||||
paths, UUIDs and enums store the same string form the YAML dumper
|
||||
produced for them. JSON additionally coerces non-str dict keys to
|
||||
strings; validated configs only use string keys (every schema key
|
||||
validator is ``cv.string``). mtime gates staleness.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from esphome.core import CORE
|
||||
from esphome.const import __version__ as ESPHOME_VERSION
|
||||
from esphome.core import CORE, Lambda
|
||||
from esphome.helpers import write_file
|
||||
from esphome.storage_json import StorageJSON, ext_storage_path
|
||||
from esphome.types import ConfigType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Bump when the on-disk shape changes; a mismatched version falls back
|
||||
# to read_config. The envelope also stamps the writing esphome version:
|
||||
# after an upgrade the cache holds the previous release's validation, so
|
||||
# it falls back once and the re-save self-heals.
|
||||
_CACHE_VERSION = 1
|
||||
_LAMBDA_KEY = "__esphome_lambda__"
|
||||
|
||||
|
||||
def compiled_config_path(config_filename: str) -> Path:
|
||||
"""Path to the cached validated config alongside the storage sidecar."""
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
|
||||
|
||||
|
||||
def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
|
||||
"""True iff the cache file exists and isn't older than the source."""
|
||||
try:
|
||||
return cache_path.stat().st_mtime >= source_path.stat().st_mtime
|
||||
except OSError:
|
||||
return False
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.json"
|
||||
|
||||
|
||||
def save_compiled_config(config: ConfigType) -> None:
|
||||
"""Write the validated-config cache. Always-write so mtime stays fresh.
|
||||
|
||||
Mode 0600 because show_secrets=True resolves !secret inline.
|
||||
Mode 0600 because config validation resolved !secret inline.
|
||||
Failures are non-fatal: the fast path falls back to read_config.
|
||||
"""
|
||||
from esphome import yaml_util
|
||||
|
||||
try:
|
||||
rendered = yaml_util.dump(config, show_secrets=True)
|
||||
# The legacy YAML cache holds inline-resolved secrets and nothing
|
||||
# reads it anymore; drop it even when the write below fails. A
|
||||
# failed removal leaves resolved secrets on disk, so it warns.
|
||||
try:
|
||||
_legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True)
|
||||
except OSError as err:
|
||||
_LOGGER.warning(
|
||||
"Could not remove the legacy validated-config cache: %s", err
|
||||
)
|
||||
rendered = json.dumps(
|
||||
{"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config},
|
||||
separators=(",", ":"),
|
||||
default=_json_default,
|
||||
)
|
||||
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
|
||||
except TypeError as err:
|
||||
# Structural, not transient: this config can never cache (e.g. a
|
||||
# non-basic dict key), so every upload/logs pays the slow path.
|
||||
_LOGGER.warning("Cannot cache the validated config: %s", err)
|
||||
except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
|
||||
_LOGGER.debug("Skipping compiled config cache write: %s", err)
|
||||
|
||||
@@ -51,18 +72,29 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
"""Load the cached validated config and apply storage metadata to CORE.
|
||||
|
||||
Returns None (caller falls back to read_config) when the cache is
|
||||
missing, older than the source YAML, unparseable, or the sidecar
|
||||
is incomplete.
|
||||
missing, older than the source YAML, unparseable, a different cache
|
||||
version, or the sidecar is incomplete. The loaded config carries no
|
||||
source ranges; callers must not feed it into read_config/write_cpp.
|
||||
"""
|
||||
cache_path = compiled_config_path(conf_path.name)
|
||||
if not _cache_is_fresh(cache_path, conf_path):
|
||||
return None
|
||||
|
||||
from esphome import yaml_util
|
||||
|
||||
try:
|
||||
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
|
||||
except Exception: # noqa: BLE001 # pylint: disable=broad-except
|
||||
envelope = json.loads(
|
||||
cache_path.read_text(encoding="utf-8"), object_hook=_decode_object
|
||||
)
|
||||
except (OSError, ValueError) as err:
|
||||
_LOGGER.debug("Ignoring unreadable compiled config cache: %s", err)
|
||||
return None
|
||||
|
||||
if (
|
||||
not isinstance(envelope, dict)
|
||||
or envelope.get("v") != _CACHE_VERSION
|
||||
or envelope.get("esphome") != ESPHOME_VERSION
|
||||
or not isinstance(config := envelope.get("config"), dict)
|
||||
):
|
||||
_LOGGER.debug("Ignoring compiled config cache with a foreign envelope")
|
||||
return None
|
||||
|
||||
storage = StorageJSON.load(ext_storage_path(conf_path.name))
|
||||
@@ -74,3 +106,38 @@ def load_compiled_config(conf_path: Path) -> ConfigType | None:
|
||||
return None
|
||||
storage.apply_to_core()
|
||||
return config
|
||||
|
||||
|
||||
# Remove before 2027.8: by then every maintained install has saved the
|
||||
# JSON cache at least once and dropped its legacy YAML file.
|
||||
def _legacy_compiled_config_path(config_filename: str) -> Path:
|
||||
"""Path of the pre-JSON YAML cache; only ever removed."""
|
||||
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
|
||||
|
||||
|
||||
def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
|
||||
"""True iff the cache file exists and isn't older than the source."""
|
||||
try:
|
||||
return cache_path.stat().st_mtime >= source_path.stat().st_mtime
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _json_default(value: Any) -> Any:
|
||||
"""Mirror ESPHomeDumper's representers: Lambda stays typed, the rest
|
||||
stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums).
|
||||
|
||||
IncludeFile/Extend/Remove have no JSON mirror and would stringify
|
||||
wrong, but none survive validation (config.py's packages merge and
|
||||
the substitution pass consume them) so no guard is spent on them.
|
||||
"""
|
||||
if isinstance(value, Lambda):
|
||||
return {_LAMBDA_KEY: value.value}
|
||||
return str(value)
|
||||
|
||||
|
||||
def _decode_object(obj: dict[str, Any]) -> Any:
|
||||
"""Revive the Lambda sentinel; every other mapping passes through."""
|
||||
if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str):
|
||||
return Lambda(value)
|
||||
return obj
|
||||
|
||||
@@ -231,6 +231,7 @@ def validate_adc_pin(value):
|
||||
return pins.internal_gpio_input_pin_schema(29)
|
||||
return cv.only_on([PLATFORM_ESP8266])("VCC")
|
||||
|
||||
# Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0
|
||||
if str(value).upper() == "TEMPERATURE":
|
||||
return cv.only_on_rp2("TEMPERATURE")
|
||||
|
||||
|
||||
@@ -19,6 +19,25 @@ namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc.rp2";
|
||||
|
||||
// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
|
||||
// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
|
||||
// than four.
|
||||
//
|
||||
// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That
|
||||
// derives from NUM_ADC_CHANNELS, which <pico.h> settles from a board header, and
|
||||
// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die
|
||||
// is only declared later, by the variant's pins_arduino.h, so the SDK constant
|
||||
// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file
|
||||
// is compiled, on both arduino-pico and pico-sdk builds.
|
||||
#if defined(PICO_RP2350) && !defined(PICO_RP2350A)
|
||||
#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen"
|
||||
#endif
|
||||
#if defined(PICO_RP2350) && !PICO_RP2350A
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8;
|
||||
#else
|
||||
static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4;
|
||||
#endif
|
||||
|
||||
void ADCSensor::setup() {
|
||||
static bool initialized = false;
|
||||
if (!initialized) {
|
||||
@@ -52,7 +71,7 @@ float ADCSensor::sample() {
|
||||
if (this->is_temperature_) {
|
||||
adc_set_temp_sensor_enabled(true);
|
||||
delay(1);
|
||||
adc_select_input(4);
|
||||
adc_select_input(TEMPERATURE_ADC_INPUT);
|
||||
|
||||
for (uint8_t sample = 0; sample < this->sample_count_; sample++) {
|
||||
raw = adc_read();
|
||||
|
||||
@@ -10,8 +10,8 @@ from esphome.components.esp32 import (
|
||||
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
|
||||
from esphome.components.zephyr import (
|
||||
zephyr_add_overlay,
|
||||
zephyr_add_overlay_builder,
|
||||
zephyr_add_prj_conf,
|
||||
zephyr_add_user,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
@@ -67,6 +67,13 @@ def validate_config(config):
|
||||
# Alter value here so `config` command prints the recommended change
|
||||
config[CONF_ATTENUATION] = _attenuation("12db")
|
||||
|
||||
# Remove before 2027.2.0
|
||||
if config[CONF_PIN] == "TEMPERATURE":
|
||||
_LOGGER.warning(
|
||||
"[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` "
|
||||
"sensor platform instead. Will be removed in 2027.2.0"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
@@ -113,6 +120,18 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_ADC_CHANNEL_ID = "adc_channel_id"
|
||||
|
||||
|
||||
def _overlay_io_channels():
|
||||
channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
|
||||
entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
|
||||
return f"""
|
||||
/ {{
|
||||
zephyr,user {{
|
||||
io-channels = {entries};
|
||||
}};
|
||||
}};
|
||||
"""
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
@@ -121,6 +140,7 @@ async def to_code(config):
|
||||
if config[CONF_PIN] == "VCC":
|
||||
cg.add_define("USE_ADC_SENSOR_VCC")
|
||||
elif config[CONF_PIN] == "TEMPERATURE":
|
||||
# Remove before 2027.2.0
|
||||
cg.add(var.set_is_temperature())
|
||||
elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC:
|
||||
pin = await cg.gpio_pin_expression(config[CONF_PIN])
|
||||
@@ -173,9 +193,8 @@ async def to_code(config):
|
||||
if isinstance(pin_number, int):
|
||||
GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()}
|
||||
pin_number = GPIO_TO_AIN[pin_number]
|
||||
zephyr_add_user("io-channels", f"<&adc {channel_id}>")
|
||||
zephyr_add_overlay(
|
||||
f"""
|
||||
zephyr_add_overlay_builder(_overlay_io_channels)
|
||||
zephyr_add_overlay(f"""
|
||||
&adc {{
|
||||
#address-cells = <1>;
|
||||
#size-cells = <0>;
|
||||
@@ -190,8 +209,7 @@ async def to_code(config):
|
||||
zephyr,oversampling = <8>;
|
||||
}};
|
||||
}};
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble_tracker
|
||||
from esphome.components import ble_device_base
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
|
||||
DEPENDENCIES = ["esp32_ble_tracker"]
|
||||
AUTO_LOAD = ["ble_device_base"]
|
||||
CODEOWNERS = ["@jeromelaban"]
|
||||
|
||||
airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble")
|
||||
AirthingsListener = airthings_ble_ns.class_(
|
||||
"AirthingsListener", esp32_ble_tracker.ESPBTDeviceListener
|
||||
"AirthingsListener", ble_device_base.ESPBTDeviceListener
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
ble_device_base.rename_legacy_hub_id("airthings_ble"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(AirthingsListener),
|
||||
}
|
||||
).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
|
||||
).extend(ble_device_base.BLE_DEVICE_SCHEMA),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await esp32_ble_tracker.register_ble_device(var, config)
|
||||
await ble_device_base.register_ble_device(var, config)
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
#include "esphome/core/log.h"
|
||||
#include <cinttypes>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::airthings_ble {
|
||||
|
||||
static const char *const TAG = "airthings_ble";
|
||||
|
||||
bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
|
||||
bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) {
|
||||
for (auto &it : device.get_manufacturer_datas()) {
|
||||
if (it.uuid == esp32_ble_tracker::ESPBTUUID::from_uint32(0x0334)) {
|
||||
if (it.uuid == ble_device_base::ESPBTUUID::from_uint32(0x0334)) {
|
||||
if (it.data.size() < 4)
|
||||
continue;
|
||||
|
||||
@@ -29,5 +27,3 @@ bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &devic
|
||||
}
|
||||
|
||||
} // namespace esphome::airthings_ble
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
|
||||
namespace esphome::airthings_ble {
|
||||
|
||||
class AirthingsListener final : public esp32_ble_tracker::ESPBTDeviceListener {
|
||||
class AirthingsListener final : public ble_device_base::ESPBTDeviceListener {
|
||||
public:
|
||||
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
|
||||
};
|
||||
|
||||
} // namespace esphome::airthings_ble
|
||||
|
||||
#endif
|
||||
|
||||
@@ -13,8 +13,13 @@
|
||||
import esphome.components.image as espImage
|
||||
import esphome.config_validation as cv
|
||||
|
||||
from . import image as animation_image
|
||||
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
|
||||
|
||||
# The deprecated top-level `animation:` shim gets the same batched
|
||||
# downloads as the `image:` platform form.
|
||||
PREFETCH_FILES = animation_image.PREFETCH_FILES
|
||||
|
||||
AUTO_LOAD = ["image", "file"]
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
DEPENDENCIES = ["display"]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LOOP
|
||||
from esphome.components.file import image as file_image
|
||||
from esphome.components.file.image import image_schema, write_image
|
||||
from esphome.components.image import Image_, validate_settings
|
||||
import esphome.config_validation as cv
|
||||
@@ -8,6 +9,10 @@ from esphome.const import CONF_ID, CONF_REPEAT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
|
||||
# The animation platform shares the file platform's remote file handling,
|
||||
# including its batch-download hook.
|
||||
PREFETCH_FILES = file_image.PREFETCH_FILES
|
||||
AUTO_LOAD = ["file"]
|
||||
DEPENDENCIES = ["display"]
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from esphome.const import (
|
||||
CONF_CAPTURE_RESPONSE,
|
||||
CONF_DATA,
|
||||
CONF_DATA_TEMPLATE,
|
||||
CONF_ENCRYPTION,
|
||||
CONF_EVENT,
|
||||
CONF_ID,
|
||||
CONF_KEY,
|
||||
@@ -102,7 +103,6 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
|
||||
for name, t in _SERVICE_ARG_SCALAR_TYPES.items()
|
||||
},
|
||||
}
|
||||
CONF_ENCRYPTION = "encryption"
|
||||
CONF_BATCH_DELAY = "batch_delay"
|
||||
CONF_CUSTOM_SERVICES = "custom_services"
|
||||
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
|
||||
|
||||
@@ -19,6 +19,7 @@ service APIConnection {
|
||||
rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) {
|
||||
option (needs_authentication) = false;
|
||||
}
|
||||
rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
|
||||
rpc list_entities (ListEntitiesRequest) returns (void) {}
|
||||
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
|
||||
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
|
||||
@@ -243,6 +244,12 @@ message SerialProxyInfo {
|
||||
// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas)
|
||||
// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH)
|
||||
// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA)
|
||||
//
|
||||
// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They
|
||||
// have moved to that message as of API 1.15, but are still sent here so that
|
||||
// older clients keep working. Do NOT mark them (deprecated) until the removal
|
||||
// release: in this repo (deprecated) makes the generator drop the field
|
||||
// entirely, so the device would stop sending it.
|
||||
message DeviceInfoResponse {
|
||||
option (id) = 10;
|
||||
option (source) = SOURCE_SERVER;
|
||||
@@ -280,6 +287,8 @@ message DeviceInfoResponse {
|
||||
|
||||
// Deprecated in API version 1.9
|
||||
uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
|
||||
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15.
|
||||
uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
|
||||
string manufacturer = 12 [(max_data_length) = 20, (force) = true];
|
||||
@@ -288,11 +297,14 @@ message DeviceInfoResponse {
|
||||
|
||||
// Deprecated in API version 1.10
|
||||
uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"];
|
||||
|
||||
// Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15.
|
||||
uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
|
||||
|
||||
string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"];
|
||||
|
||||
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
|
||||
// Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15.
|
||||
string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
|
||||
// Supports receiving and saving api encryption key
|
||||
@@ -305,10 +317,13 @@ message DeviceInfoResponse {
|
||||
AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"];
|
||||
|
||||
// Indicates if Z-Wave proxy support is available and features supported
|
||||
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
|
||||
uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"];
|
||||
// Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
|
||||
uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"];
|
||||
|
||||
// Serial proxy instance metadata
|
||||
// Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15.
|
||||
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
|
||||
|
||||
// Device is unprovisioned and accepts Noise handshakes with the well-known
|
||||
@@ -317,6 +332,63 @@ message DeviceInfoResponse {
|
||||
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
|
||||
}
|
||||
|
||||
// ==================== DEVICE CAPABILITIES ====================
|
||||
|
||||
// Asks the device which optional features it supports.
|
||||
//
|
||||
// This message exists so that DeviceInfoResponse does not have to keep growing
|
||||
// a flat list of feature flags. DeviceInfoResponse is served before
|
||||
// authentication, so it is limited to identity information. Capabilities are
|
||||
// only served on an authenticated connection (encrypted as well, when
|
||||
// encryption is configured).
|
||||
//
|
||||
// Clients that see api_version >= 1.15 should read these values from
|
||||
// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields.
|
||||
// Older clients keep reading DeviceInfoResponse, which still carries the same
|
||||
// values, so this is not a breaking change.
|
||||
message DeviceCapabilitiesRequest {
|
||||
option (id) = 149;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
// Empty
|
||||
}
|
||||
|
||||
// Each feature gets its own sub-message so that it can gain fields over time
|
||||
// without crowding the top-level field numbering.
|
||||
//
|
||||
// Note: a sub-message whose fields are all at their default value is not sent
|
||||
// at all, so the presence of a sub-message is not a reliable test for "this
|
||||
// feature is compiled in". Clients should test a value inside it, for example
|
||||
// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse.
|
||||
|
||||
message BluetoothProxyCapabilities {
|
||||
// Bitmask of the features this proxy supports
|
||||
uint32 feature_flags = 1;
|
||||
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
|
||||
string mac_address = 2 [(max_data_length) = 17, (force) = true];
|
||||
}
|
||||
|
||||
message VoiceAssistantCapabilities {
|
||||
// Bitmask of the features this voice assistant supports
|
||||
uint32 feature_flags = 1;
|
||||
}
|
||||
|
||||
message ZWaveProxyCapabilities {
|
||||
// Bitmask of the features this proxy supports
|
||||
uint32 feature_flags = 1;
|
||||
uint32 home_id = 2;
|
||||
}
|
||||
|
||||
message DeviceCapabilitiesResponse {
|
||||
option (id) = 150;
|
||||
option (source) = SOURCE_SERVER;
|
||||
|
||||
BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
|
||||
VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
|
||||
ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
|
||||
repeated SerialProxyInfo serial_proxies = 4
|
||||
[(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
|
||||
}
|
||||
|
||||
message ListEntitiesRequest {
|
||||
option (id) = 11;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
@@ -1688,7 +1760,7 @@ enum BluetoothDeviceRequestType {
|
||||
message BluetoothDeviceRequest {
|
||||
option (id) = 68;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
BluetoothDeviceRequestType request_type = 2;
|
||||
@@ -1699,7 +1771,7 @@ message BluetoothDeviceRequest {
|
||||
message BluetoothDeviceConnectionResponse {
|
||||
option (id) = 69;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool connected = 2;
|
||||
@@ -1710,7 +1782,7 @@ message BluetoothDeviceConnectionResponse {
|
||||
message BluetoothGATTGetServicesRequest {
|
||||
option (id) = 70;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
}
|
||||
@@ -1754,7 +1826,7 @@ message BluetoothGATTService {
|
||||
message BluetoothGATTGetServicesResponse {
|
||||
option (id) = 71;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
repeated BluetoothGATTService services = 2;
|
||||
@@ -1763,7 +1835,7 @@ message BluetoothGATTGetServicesResponse {
|
||||
message BluetoothGATTGetServicesDoneResponse {
|
||||
option (id) = 72;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
}
|
||||
@@ -1771,7 +1843,7 @@ message BluetoothGATTGetServicesDoneResponse {
|
||||
message BluetoothGATTReadRequest {
|
||||
option (id) = 73;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1780,7 +1852,7 @@ message BluetoothGATTReadRequest {
|
||||
message BluetoothGATTReadResponse {
|
||||
option (id) = 74;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1792,7 +1864,7 @@ message BluetoothGATTReadResponse {
|
||||
message BluetoothGATTWriteRequest {
|
||||
option (id) = 75;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1804,7 +1876,7 @@ message BluetoothGATTWriteRequest {
|
||||
message BluetoothGATTReadDescriptorRequest {
|
||||
option (id) = 76;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1813,7 +1885,7 @@ message BluetoothGATTReadDescriptorRequest {
|
||||
message BluetoothGATTWriteDescriptorRequest {
|
||||
option (id) = 77;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1824,7 +1896,7 @@ message BluetoothGATTWriteDescriptorRequest {
|
||||
message BluetoothGATTNotifyRequest {
|
||||
option (id) = 78;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1834,7 +1906,7 @@ message BluetoothGATTNotifyRequest {
|
||||
message BluetoothGATTNotifyDataResponse {
|
||||
option (id) = 79;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1845,13 +1917,13 @@ message BluetoothGATTNotifyDataResponse {
|
||||
message SubscribeBluetoothConnectionsFreeRequest {
|
||||
option (id) = 80;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
}
|
||||
|
||||
message BluetoothConnectionsFreeResponse {
|
||||
option (id) = 81;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint32 free = 1;
|
||||
uint32 limit = 2;
|
||||
@@ -1864,7 +1936,7 @@ message BluetoothConnectionsFreeResponse {
|
||||
message BluetoothGATTErrorResponse {
|
||||
option (id) = 82;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1874,7 +1946,7 @@ message BluetoothGATTErrorResponse {
|
||||
message BluetoothGATTWriteResponse {
|
||||
option (id) = 83;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1883,7 +1955,7 @@ message BluetoothGATTWriteResponse {
|
||||
message BluetoothGATTNotifyResponse {
|
||||
option (id) = 84;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 handle = 2;
|
||||
@@ -1892,7 +1964,7 @@ message BluetoothGATTNotifyResponse {
|
||||
message BluetoothDevicePairingResponse {
|
||||
option (id) = 85;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool paired = 2;
|
||||
@@ -1902,7 +1974,7 @@ message BluetoothDevicePairingResponse {
|
||||
message BluetoothDeviceUnpairingResponse {
|
||||
option (id) = 86;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool success = 2;
|
||||
@@ -1918,7 +1990,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest {
|
||||
message BluetoothDeviceClearCacheResponse {
|
||||
option (id) = 88;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
bool success = 2;
|
||||
@@ -2735,7 +2807,7 @@ message SerialProxyRequestResponse {
|
||||
message BluetoothSetConnectionParamsRequest {
|
||||
option (id) = 145;
|
||||
option (source) = SOURCE_CLIENT;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
uint32 min_interval = 2; // units of 1.25ms
|
||||
@@ -2747,7 +2819,7 @@ message BluetoothSetConnectionParamsRequest {
|
||||
message BluetoothSetConnectionParamsResponse {
|
||||
option (id) = 146;
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY";
|
||||
option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
|
||||
|
||||
uint64 address = 1;
|
||||
int32 error = 2;
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/version.h"
|
||||
#ifdef USE_PROVISIONING
|
||||
@@ -88,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam
|
||||
static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto");
|
||||
|
||||
static const char *const TAG = "api.connection";
|
||||
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
|
||||
void log_dropped_message(const char *tag, int line, const LogString *what) {
|
||||
esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"),
|
||||
LOG_STR_ARG(what));
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
static const int CAMERA_STOP_STREAM = 5000;
|
||||
#endif
|
||||
@@ -440,7 +448,7 @@ void APIConnection::on_disconnect_response() {
|
||||
uint16_t APIConnection::fill_and_encode_entity_state(EntityBase *entity, StateResponseProtoMessage &msg,
|
||||
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
msg.key = entity->get_object_id_hash();
|
||||
msg.key = entity->get_entity_key();
|
||||
#ifdef USE_DEVICES
|
||||
msg.device_id = entity->get_device_id();
|
||||
#endif
|
||||
@@ -451,7 +459,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
|
||||
CalculateSizeFn size_fn, MessageEncodeFn encode_fn,
|
||||
APIConnection *conn, uint32_t remaining_size) {
|
||||
// Set common fields that are shared by all entity types
|
||||
msg.key = entity->get_object_id_hash();
|
||||
msg.key = entity->get_entity_key();
|
||||
|
||||
if (entity->has_own_name()) {
|
||||
msg.name = entity->get_name();
|
||||
@@ -794,6 +802,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();
|
||||
@@ -1140,7 +1149,7 @@ void APIConnection::try_send_camera_image_() {
|
||||
bool done = this->image_reader_->available() == to_send;
|
||||
|
||||
CameraImageResponse msg;
|
||||
msg.key = camera::Camera::instance()->get_object_id_hash();
|
||||
msg.key = camera::Camera::instance()->get_entity_key();
|
||||
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
|
||||
msg.done = done;
|
||||
#ifdef USE_DEVICES
|
||||
@@ -1234,6 +1243,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request(
|
||||
void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() {
|
||||
bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this);
|
||||
}
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg);
|
||||
}
|
||||
@@ -1267,13 +1277,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() {
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
|
||||
}
|
||||
#endif
|
||||
|
||||
void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode(
|
||||
msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE);
|
||||
}
|
||||
void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
|
||||
bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
@@ -1328,7 +1340,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
|
||||
@@ -1348,22 +1361,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);
|
||||
@@ -1468,6 +1465,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);
|
||||
}
|
||||
|
||||
@@ -1545,7 +1543,13 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
|
||||
#endif
|
||||
|
||||
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); }
|
||||
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
// V: fires per decoded frame with no subscription gate, so a warning
|
||||
// would flood the congested link it reports on.
|
||||
ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
@@ -1556,8 +1560,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) {
|
||||
@@ -1566,7 +1570,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) {
|
||||
@@ -1575,7 +1579,7 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM
|
||||
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
|
||||
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) {
|
||||
@@ -1587,7 +1591,9 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
|
||||
SerialProxyGetModemPinsResponse resp{};
|
||||
resp.instance = msg.instance;
|
||||
resp.line_states = proxies[msg.instance]->get_modem_pins();
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
@@ -1619,7 +1625,9 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
|
||||
break;
|
||||
}
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -1628,7 +1636,11 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
|
||||
}
|
||||
}
|
||||
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); }
|
||||
void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
|
||||
if (!this->send_message(msg)) {
|
||||
ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_INFRARED
|
||||
@@ -1746,15 +1758,9 @@ 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;
|
||||
resp.api_version_minor = 15;
|
||||
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
|
||||
resp.server_info = ESPHOME_VERSION_REF;
|
||||
resp.name = StringRef(App.get_name());
|
||||
@@ -1765,7 +1771,9 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
// Acknowledge the hello so the client can read the server name, then request
|
||||
// disconnect with the reason. Authentication is intentionally not completed.
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Hello response");
|
||||
}
|
||||
DisconnectRequest req;
|
||||
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
|
||||
return this->send_message(req);
|
||||
@@ -1790,9 +1798,8 @@ bool APIConnection::send_device_info_response_() {
|
||||
#ifdef USE_AREAS
|
||||
resp.suggested_area = StringRef(App.get_area());
|
||||
#endif
|
||||
// Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
|
||||
char mac_address[18];
|
||||
uint8_t mac[6];
|
||||
char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
get_mac_address_raw(mac);
|
||||
format_mac_addr_upper(mac, mac_address);
|
||||
resp.mac_address = StringRef(mac_address);
|
||||
@@ -1868,8 +1875,7 @@ bool APIConnection::send_device_info_response_() {
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
|
||||
// Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
|
||||
char bluetooth_mac[18];
|
||||
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
|
||||
resp.bluetooth_mac_address = StringRef(bluetooth_mac);
|
||||
#endif
|
||||
@@ -1923,6 +1929,35 @@ bool APIConnection::send_device_info_response_() {
|
||||
|
||||
return this->send_message(resp);
|
||||
}
|
||||
bool APIConnection::send_device_capabilities_response_() {
|
||||
// These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks
|
||||
// below in sync with send_device_info_response_() until those copies are removed.
|
||||
DeviceCapabilitiesResponse resp;
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
|
||||
char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
|
||||
resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac);
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags();
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags();
|
||||
resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id();
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
size_t serial_proxy_index = 0;
|
||||
for (auto const &proxy : App.get_serial_proxies()) {
|
||||
if (serial_proxy_index >= SERIAL_PROXY_COUNT)
|
||||
break;
|
||||
auto &info = resp.serial_proxies[serial_proxy_index++];
|
||||
info.name = StringRef(proxy->get_name());
|
||||
info.port_type = proxy->get_port_type();
|
||||
}
|
||||
#endif
|
||||
return this->send_message(resp);
|
||||
}
|
||||
void APIConnection::on_hello_request(const HelloRequest &msg) {
|
||||
if (!this->send_hello_response_(msg)) {
|
||||
this->on_fatal_error();
|
||||
@@ -1944,6 +1979,11 @@ void APIConnection::on_device_info_request() {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
void APIConnection::on_device_capabilities_request() {
|
||||
if (!this->send_device_capabilities_response_()) {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_STATES
|
||||
void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) {
|
||||
@@ -2022,7 +2062,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
|
||||
resp.call_id = call_id;
|
||||
resp.success = success;
|
||||
resp.error_message = error_message;
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action response");
|
||||
}
|
||||
}
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message,
|
||||
@@ -2033,12 +2075,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
|
||||
resp.error_message = error_message;
|
||||
resp.response_data = response_data;
|
||||
resp.response_data_len = response_data_len;
|
||||
this->send_message(resp);
|
||||
if (!this->send_message(resp)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action response");
|
||||
}
|
||||
}
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return false;
|
||||
if (!this->send_message(call)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Action request");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif // USE_API_HOMEASSISTANT_SERVICES
|
||||
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
void APIConnection::send_time_request() {
|
||||
GetTimeRequest req;
|
||||
if (!this->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Time request");
|
||||
}
|
||||
}
|
||||
#endif // USE_HOMEASSISTANT_TIME
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) {
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
|
||||
@@ -2113,7 +2177,10 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
|
||||
if (this->helper_->can_write_without_blocking())
|
||||
return true;
|
||||
if (log_out_of_space) {
|
||||
ESP_LOGV(TAG, "Cannot send message because of TCP buffer space");
|
||||
// VV: refusals are either reported by the sending call site (naming what
|
||||
// was lost) or retried without loss (the deferred batch), so this generic
|
||||
// line only duplicates them.
|
||||
ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#include "esphome/components/esp8266/crash_handler.h"
|
||||
#endif
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
#include <functional>
|
||||
@@ -40,6 +41,16 @@ namespace esphome::api {
|
||||
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
|
||||
class APIServer;
|
||||
|
||||
// One shared flash string for every refused-frame warning: send_message()
|
||||
// fails as soon as the TCP buffer is full, and each caller only pays for its
|
||||
// short name. The guard drops the helper and its arguments below WARN.
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
|
||||
void log_dropped_message(const char *tag, int line, const LogString *what);
|
||||
#define API_LOG_MSG_DROPPED(tag, what) esphome::api::log_dropped_message(tag, __LINE__, LOG_STR(what))
|
||||
#else
|
||||
#define API_LOG_MSG_DROPPED(tag, what)
|
||||
#endif
|
||||
|
||||
// Keepalive timeout in milliseconds
|
||||
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
|
||||
// Maximum number of entities to process in a single batch during initial state/info sending
|
||||
@@ -169,12 +180,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Returns whether this client has subscribed to Home Assistant actions; the message
|
||||
// is only handed to the send path when subscribed. A true return does not guarantee
|
||||
// delivery - it lets the caller warn when no connected client has the subscription.
|
||||
bool send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return false;
|
||||
this->send_message(call);
|
||||
return true;
|
||||
}
|
||||
bool send_homeassistant_action(const HomeassistantActionRequest &call);
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
|
||||
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
@@ -183,6 +189,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg);
|
||||
void on_unsubscribe_bluetooth_le_advertisements_request();
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_device_request(const BluetoothDeviceRequest &msg);
|
||||
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg);
|
||||
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg);
|
||||
@@ -191,15 +198,13 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg);
|
||||
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg);
|
||||
void on_subscribe_bluetooth_connections_free_request();
|
||||
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg);
|
||||
#endif
|
||||
void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
|
||||
|
||||
#endif
|
||||
#ifdef USE_HOMEASSISTANT_TIME
|
||||
void send_time_request() {
|
||||
GetTimeRequest req;
|
||||
this->send_message(req);
|
||||
}
|
||||
void send_time_request();
|
||||
#endif
|
||||
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
@@ -266,6 +271,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_disconnect_request(const DisconnectRequest &msg);
|
||||
void on_ping_request();
|
||||
void on_device_info_request();
|
||||
void on_device_capabilities_request();
|
||||
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
|
||||
void on_subscribe_states_request() {
|
||||
this->flags_.state_subscription = true;
|
||||
@@ -334,7 +340,9 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Function pointer type for type-erased size calculation
|
||||
using CalculateSizeFn = uint32_t (*)(const void *);
|
||||
|
||||
template<typename T> bool send_message(const T &msg) {
|
||||
/// Returns false as soon as the TCP buffer is full. Marked nodiscard so we
|
||||
/// have no silent failures: every caller must handle (or log) a refusal.
|
||||
template<typename T> [[nodiscard]] bool send_message(const T &msg) {
|
||||
if constexpr (T::ESTIMATED_SIZE == 0) {
|
||||
return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg);
|
||||
} else {
|
||||
@@ -385,10 +393,11 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
bool send_disconnect_response_();
|
||||
bool send_ping_response_();
|
||||
bool send_device_info_response_();
|
||||
bool send_device_capabilities_response_();
|
||||
#ifdef USE_API_NOISE
|
||||
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool send_subscribe_bluetooth_connections_free_response_();
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
|
||||
@@ -149,7 +149,7 @@ class APIFrameHelper {
|
||||
// holding data too long waiting for Nagle's timer causes buffer exhaustion
|
||||
// and dropped messages.
|
||||
//
|
||||
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
|
||||
// ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle
|
||||
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
|
||||
//
|
||||
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
|
||||
@@ -312,7 +312,7 @@ class APIFrameHelper {
|
||||
// Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
|
||||
// After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0.
|
||||
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
|
||||
// ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
|
||||
// ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more.
|
||||
#ifdef USE_ESP8266
|
||||
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
|
||||
#else
|
||||
|
||||
@@ -241,6 +241,82 @@ uint32_t DeviceInfoResponse::calculate_size() const {
|
||||
#endif
|
||||
return size;
|
||||
}
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
|
||||
ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address);
|
||||
return pos;
|
||||
}
|
||||
uint32_t BluetoothProxyCapabilities::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_uint32(1, this->feature_flags);
|
||||
size += 2 + this->mac_address.size();
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
|
||||
return pos;
|
||||
}
|
||||
uint32_t VoiceAssistantCapabilities::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_uint32(1, this->feature_flags);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id);
|
||||
return pos;
|
||||
}
|
||||
uint32_t ZWaveProxyCapabilities::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_uint32(1, this->feature_flags);
|
||||
size += ProtoSize::calc_uint32(1, this->home_id);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy);
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant);
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy);
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
for (const auto &it : this->serial_proxies) {
|
||||
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it);
|
||||
}
|
||||
#endif
|
||||
return pos;
|
||||
}
|
||||
uint32_t DeviceCapabilitiesResponse::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size());
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size());
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size());
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
for (const auto &it : this->serial_proxies) {
|
||||
size += ProtoSize::calc_message_force(1, it.calculate_size());
|
||||
}
|
||||
#endif
|
||||
return size;
|
||||
}
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
uint8_t *ListEntitiesBinarySensorResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
@@ -2406,6 +2482,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const {
|
||||
}
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
@@ -2782,6 +2860,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const {
|
||||
size += ProtoSize::calc_int32(1, this->error);
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->state));
|
||||
@@ -4145,7 +4225,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
|
||||
return size;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
|
||||
@@ -225,7 +225,7 @@ enum MediaPlayerFormatPurpose : uint32_t {
|
||||
MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1,
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
enum BluetoothDeviceRequestType : uint32_t {
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0,
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1,
|
||||
@@ -235,6 +235,8 @@ enum BluetoothDeviceRequestType : uint32_t {
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5,
|
||||
BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6,
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
enum BluetoothScannerState : uint32_t {
|
||||
BLUETOOTH_SCANNER_STATE_IDLE = 0,
|
||||
BLUETOOTH_SCANNER_STATE_STARTING = 1,
|
||||
@@ -600,6 +602,74 @@ class DeviceInfoResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
class BluetoothProxyCapabilities final : public ProtoMessage {
|
||||
public:
|
||||
uint32_t feature_flags{0};
|
||||
StringRef mac_address{};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
class VoiceAssistantCapabilities final : public ProtoMessage {
|
||||
public:
|
||||
uint32_t feature_flags{0};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
class ZWaveProxyCapabilities final : public ProtoMessage {
|
||||
public:
|
||||
uint32_t feature_flags{0};
|
||||
uint32_t home_id{0};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
class DeviceCapabilitiesResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 150;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 102;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); }
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
BluetoothProxyCapabilities bluetooth_proxy{};
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
VoiceAssistantCapabilities voice_assistant{};
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
ZWaveProxyCapabilities zwave_proxy{};
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
|
||||
#endif
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
};
|
||||
class ListEntitiesDoneResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 19;
|
||||
@@ -1931,6 +2001,8 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
class BluetoothDeviceRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 68;
|
||||
@@ -2316,6 +2388,8 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
class BluetoothScannerStateResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 126;
|
||||
@@ -3290,7 +3364,7 @@ class SerialProxyRequestResponse final : public ProtoMessage {
|
||||
protected:
|
||||
};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 145;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS)
|
||||
#ifndef USE_API_VARINT64
|
||||
#define USE_API_VARINT64
|
||||
#endif
|
||||
|
||||
@@ -584,7 +584,7 @@ template<> const char *proto_enum_to_string<enums::MediaPlayerFormatPurpose>(enu
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
template<>
|
||||
const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::BluetoothDeviceRequestType value) {
|
||||
switch (value) {
|
||||
@@ -606,6 +606,8 @@ const char *proto_enum_to_string<enums::BluetoothDeviceRequestType>(enums::Bluet
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
template<> const char *proto_enum_to_string<enums::BluetoothScannerState>(enums::BluetoothScannerState value) {
|
||||
switch (value) {
|
||||
case enums::BLUETOOTH_SCANNER_STATE_IDLE:
|
||||
@@ -988,6 +990,55 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities"));
|
||||
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
|
||||
dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities"));
|
||||
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities"));
|
||||
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
|
||||
dump_field(out, ESPHOME_PSTR("home_id"), this->home_id);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": ");
|
||||
this->bluetooth_proxy.dump_to(out);
|
||||
out.append("\n");
|
||||
#endif
|
||||
#ifdef USE_VOICE_ASSISTANT
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": ");
|
||||
this->voice_assistant.dump_to(out);
|
||||
out.append("\n");
|
||||
#endif
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": ");
|
||||
this->zwave_proxy.dump_to(out);
|
||||
out.append("\n");
|
||||
#endif
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
for (const auto &it : this->serial_proxies) {
|
||||
out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": ");
|
||||
it.dump_to(out);
|
||||
out.append("\n");
|
||||
}
|
||||
#endif
|
||||
return out.c_str();
|
||||
}
|
||||
const char *ListEntitiesDoneResponse::dump_to(DumpBuffer &out) const {
|
||||
out.append_p(ESPHOME_PSTR("ListEntitiesDoneResponse {}"));
|
||||
return out.c_str();
|
||||
@@ -1953,6 +2004,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const
|
||||
}
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("address"), this->address);
|
||||
@@ -2124,6 +2177,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const {
|
||||
dump_field(out, ESPHOME_PSTR("error"), this->error);
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse"));
|
||||
dump_field(out, ESPHOME_PSTR("state"), static_cast<enums::BluetoothScannerState>(this->state));
|
||||
@@ -2715,7 +2770,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("address"), this->address);
|
||||
|
||||
@@ -302,7 +302,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothDeviceRequest::MESSAGE_TYPE: {
|
||||
BluetoothDeviceRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -313,7 +313,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTGetServicesRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTGetServicesRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -324,7 +324,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTReadRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTReadRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -335,7 +335,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTWriteRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTWriteRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -346,7 +346,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTReadDescriptorRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTReadDescriptorRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -357,7 +357,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTWriteDescriptorRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTWriteDescriptorRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -368,7 +368,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothGATTNotifyRequest::MESSAGE_TYPE: {
|
||||
BluetoothGATTNotifyRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -379,7 +379,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case 80 /* SubscribeBluetoothConnectionsFreeRequest is empty */: {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_subscribe_bluetooth_connections_free_request"));
|
||||
@@ -694,7 +694,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
case BluetoothSetConnectionParamsRequest::MESSAGE_TYPE: {
|
||||
BluetoothSetConnectionParamsRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
@@ -705,6 +705,13 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
case 149 /* DeviceCapabilitiesRequest is empty */: {
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_device_capabilities_request"));
|
||||
#endif
|
||||
this->on_device_capabilities_request();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ class APIServerConnectionBase {
|
||||
void on_ping_response(){};
|
||||
void on_device_info_request(){};
|
||||
|
||||
void on_device_capabilities_request(){};
|
||||
|
||||
void on_list_entities_request(){};
|
||||
|
||||
void on_subscribe_states_request(){};
|
||||
@@ -113,32 +115,32 @@ class APIServerConnectionBase {
|
||||
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_device_request(const BluetoothDeviceRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &value){};
|
||||
#endif
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_subscribe_bluetooth_connections_free_request(){};
|
||||
#endif
|
||||
|
||||
@@ -233,7 +235,7 @@ class APIServerConnectionBase {
|
||||
void on_serial_proxy_request(const SerialProxyRequest &value){};
|
||||
#endif
|
||||
|
||||
#ifdef USE_BLUETOOTH_PROXY
|
||||
#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
|
||||
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &value){};
|
||||
#endif
|
||||
};
|
||||
|
||||
@@ -123,7 +123,9 @@ void APIServer::setup() {
|
||||
// Best-effort: if the send buffer is full the reason is dropped, but the
|
||||
// client still learns the window is closed when it reconnects (rejected at
|
||||
// hello) or via the socket close.
|
||||
c->send_message(req);
|
||||
if (!c->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -394,8 +396,11 @@ void APIServer::on_update(update::UpdateEntity *obj) {
|
||||
void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
// We could add code to manage a second subscription type, but, since this message type is
|
||||
// very infrequent and small, we simply send it to all clients
|
||||
for (auto &c : this->active_clients())
|
||||
c->send_message(msg);
|
||||
for (auto &c : this->active_clients()) {
|
||||
if (!c->send_message(msg)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Home ID notification");
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -576,7 +581,9 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
|
||||
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
|
||||
for (auto &c : this->active_clients()) {
|
||||
DisconnectRequest req;
|
||||
c->send_message(req);
|
||||
if (!c->send_message(req)) {
|
||||
API_LOG_MSG_DROPPED(TAG, "Disconnect request");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,174 +1,10 @@
|
||||
from __future__ import annotations
|
||||
"""Backward-compatibility shim; the log client lives in esphome.api_client.
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import importlib
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
import warnings
|
||||
|
||||
# Suppress protobuf version warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings(
|
||||
"ignore", category=UserWarning, message=".*Protobuf gencode version.*"
|
||||
)
|
||||
from aioesphomeapi import APIClient, parse_log_message
|
||||
from aioesphomeapi.log_runner import async_run
|
||||
|
||||
import contextlib
|
||||
|
||||
from esphome.const import CONF_KEY, CONF_PORT, __version__
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.util import safe_print
|
||||
|
||||
from . import CONF_ENCRYPTION
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aioesphomeapi.api_pb2 import (
|
||||
SubscribeLogsResponse, # pylint: disable=no-name-in-module
|
||||
)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _LogLineProcessor:
|
||||
"""Feeds incoming log lines to the stack-trace decoder.
|
||||
|
||||
Two responsibilities beyond just calling the decoder:
|
||||
1. Catch EsphomeError. on_log runs inside an asyncio protocol
|
||||
callback; if an exception escapes, the loop tears the transport
|
||||
down with "Fatal error: protocol.data_received() call failed."
|
||||
and ReconnectLogic immediately reconnects, the device replays
|
||||
the same crash trace, and we loop forever.
|
||||
2. Disable decoding after the first failure. _decode_pc shells out
|
||||
to PlatformIO via _run_idedata, which is expensive; a single
|
||||
crash dump can contain many PC/BT lines and we don't want to
|
||||
retry the failing subprocess for each one.
|
||||
Importing this module executes the whole api component package, which pulls
|
||||
in the validation stack. CLI code paths should import esphome.api_client
|
||||
directly so the logs fast path stays light.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None:
|
||||
self._config = config
|
||||
self._platform_handler = platform_handler
|
||||
self._decode_enabled = True
|
||||
self.backtrace_state = False
|
||||
from esphome.api_client import async_run_logs, run_logs
|
||||
|
||||
def process_line(self, raw_line: str) -> None:
|
||||
if not self._decode_enabled:
|
||||
return
|
||||
try:
|
||||
if self._platform_handler is not None:
|
||||
self.backtrace_state = self._platform_handler(
|
||||
self._config, raw_line, self.backtrace_state
|
||||
)
|
||||
except EsphomeError as exc:
|
||||
self._decode_enabled = False
|
||||
self.backtrace_state = False
|
||||
# _run_idedata raises EsphomeError with no message; fall back
|
||||
# to a generic explanation when str(exc) is empty.
|
||||
detail = str(exc) or "build artifacts not found locally"
|
||||
_LOGGER.warning(
|
||||
"Crash trace decoding unavailable: %s. "
|
||||
"Run 'esphome compile' for this device to enable PC decoding.",
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
async def async_run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
) -> None:
|
||||
"""Run the logs command in the event loop."""
|
||||
conf = config["api"]
|
||||
name = config["esphome"]["name"]
|
||||
port: int = int(conf[CONF_PORT])
|
||||
noise_psk: str | None = None
|
||||
if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)):
|
||||
noise_psk = key
|
||||
|
||||
if len(addresses) == 1:
|
||||
_LOGGER.info("Starting log output from %s using esphome API", addresses[0])
|
||||
else:
|
||||
_LOGGER.info(
|
||||
"Starting log output from %s using esphome API", " or ".join(addresses)
|
||||
)
|
||||
|
||||
cli = APIClient(
|
||||
addresses[0], # Primary address for compatibility
|
||||
port,
|
||||
"", # Password auth removed in 2026.1.0
|
||||
client_info=f"ESPHome Logs {__version__}",
|
||||
noise_psk=noise_psk,
|
||||
addresses=addresses, # Pass all addresses for automatic retry
|
||||
provide_time=False,
|
||||
)
|
||||
|
||||
# Try platform-specific stacktrace handler first, fall back to generic
|
||||
platform_process_stacktrace = None
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
platform_process_stacktrace = module.process_stacktrace
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
CORE.target_platform,
|
||||
)
|
||||
|
||||
processor = _LogLineProcessor(config, platform_process_stacktrace)
|
||||
|
||||
def on_log(msg: SubscribeLogsResponse) -> None:
|
||||
"""Handle a new log message."""
|
||||
time_ = datetime.now().astimezone()
|
||||
message: bytes = msg.message
|
||||
text = message.decode("utf8", "backslashreplace")
|
||||
nanoseconds = time_.microsecond // 1000
|
||||
timestamp = (
|
||||
f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]"
|
||||
)
|
||||
for parsed_msg in parse_log_message(text, timestamp):
|
||||
# safe_print handles the dashboard \033 escaping and falls back
|
||||
# to backslashreplace encoding on stdouts that can't represent
|
||||
# the wifi signal-bar block characters (Windows redirected
|
||||
# cp1252 pipe).
|
||||
safe_print(parsed_msg)
|
||||
for raw_line in text.splitlines():
|
||||
processor.process_line(raw_line)
|
||||
|
||||
# Safe to fall back to plaintext here only for this diagnostics use
|
||||
# case: the stream is one-way from device to client, and this code
|
||||
# never accepts commands or acts on any message the device sends.
|
||||
# An on-path attacker could still both inject fabricated log lines
|
||||
# and passively read the device's log output (and any state data
|
||||
# delivered when subscribe_states is enabled), so this does lose
|
||||
# confidentiality as well as authentication/integrity. That tradeoff
|
||||
# is acceptable for operator-visible logs, which aioesphomeapi also
|
||||
# warns may come from an unverified device. Never mirror this opt-in
|
||||
# for any connection that sends data to the device or uses Home
|
||||
# Assistant actions.
|
||||
stop = await async_run(
|
||||
cli,
|
||||
on_log,
|
||||
name=name,
|
||||
subscribe_states=subscribe_states,
|
||||
allow_plaintext_fallback=True,
|
||||
# A top-level ``deep_sleep:`` block means the device is only awake
|
||||
# briefly; cap the reconnect backoff so a wake window is not missed.
|
||||
deep_sleep="deep_sleep" in config,
|
||||
)
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
await stop()
|
||||
|
||||
|
||||
def run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
subscribe_states: bool = True,
|
||||
) -> None:
|
||||
"""Run the logs command."""
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
asyncio.run(
|
||||
async_run_logs(config, addresses, subscribe_states=subscribe_states)
|
||||
)
|
||||
__all__ = ["async_run_logs", "run_logs"]
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#include "atc_mithermometer.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::atc_mithermometer {
|
||||
|
||||
static const char *const TAG = "atc_mithermometer";
|
||||
@@ -15,7 +13,7 @@ void ATCMiThermometer::dump_config() {
|
||||
LOG_SENSOR(" ", "Battery Voltage", this->battery_voltage_);
|
||||
}
|
||||
|
||||
bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
|
||||
bool ATCMiThermometer::parse_device(const ble_device_base::ESPBTDevice &device) {
|
||||
if (device.address_uint64() != this->address_) {
|
||||
ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
|
||||
return false;
|
||||
@@ -52,7 +50,7 @@ bool ATCMiThermometer::parse_device(const esp32_ble_tracker::ESPBTDevice &device
|
||||
return success;
|
||||
}
|
||||
|
||||
optional<ParseResult> ATCMiThermometer::parse_header_(const esp32_ble_tracker::ServiceData &service_data) {
|
||||
optional<ParseResult> ATCMiThermometer::parse_header_(const ble_device_base::ServiceData &service_data) {
|
||||
ParseResult result;
|
||||
if (!service_data.uuid.contains(0x1A, 0x18)) {
|
||||
ESP_LOGVV(TAG, "parse_header(): no service data UUID magic bytes.");
|
||||
@@ -132,5 +130,3 @@ bool ATCMiThermometer::report_results_(const optional<ParseResult> &result, cons
|
||||
}
|
||||
|
||||
} // namespace esphome::atc_mithermometer
|
||||
|
||||
#endif
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::atc_mithermometer {
|
||||
|
||||
struct ParseResult {
|
||||
@@ -18,11 +16,11 @@ struct ParseResult {
|
||||
int raw_offset;
|
||||
};
|
||||
|
||||
class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
|
||||
class ATCMiThermometer final : public Component, public ble_device_base::ESPBTDeviceListener {
|
||||
public:
|
||||
void set_address(uint64_t address) { address_ = address; };
|
||||
|
||||
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
|
||||
void dump_config() override;
|
||||
void set_temperature(sensor::Sensor *temperature) { temperature_ = temperature; }
|
||||
void set_humidity(sensor::Sensor *humidity) { humidity_ = humidity; }
|
||||
@@ -40,11 +38,9 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT
|
||||
|
||||
uint8_t last_frame_count_{0};
|
||||
|
||||
optional<ParseResult> parse_header_(const esp32_ble_tracker::ServiceData &service_data);
|
||||
optional<ParseResult> parse_header_(const ble_device_base::ServiceData &service_data);
|
||||
bool parse_message_(const std::vector<uint8_t> &message, ParseResult &result);
|
||||
bool report_results_(const optional<ParseResult> &result, const char *address);
|
||||
};
|
||||
|
||||
} // namespace esphome::atc_mithermometer
|
||||
|
||||
#endif
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble_tracker, sensor
|
||||
from esphome.components import ble_device_base, sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BATTERY_LEVEL,
|
||||
@@ -24,14 +24,15 @@ from esphome.const import (
|
||||
|
||||
CODEOWNERS = ["@ahpohl"]
|
||||
|
||||
DEPENDENCIES = ["esp32_ble_tracker"]
|
||||
AUTO_LOAD = ["ble_device_base"]
|
||||
|
||||
atc_mithermometer_ns = cg.esphome_ns.namespace("atc_mithermometer")
|
||||
ATCMiThermometer = atc_mithermometer_ns.class_(
|
||||
"ATCMiThermometer", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
|
||||
"ATCMiThermometer", ble_device_base.ESPBTDeviceListener, cg.Component
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
ble_device_base.rename_legacy_hub_id("atc_mithermometer"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(ATCMiThermometer),
|
||||
@@ -71,15 +72,15 @@ CONFIG_SCHEMA = (
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await esp32_ble_tracker.register_ble_device(var, config)
|
||||
await ble_device_base.register_ble_device(var, config)
|
||||
|
||||
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
|
||||
|
||||
|
||||
@@ -371,7 +371,7 @@ async def to_code(config):
|
||||
data.wav_support = True
|
||||
|
||||
if data.micro_decoder_support:
|
||||
add_idf_component(name="esphome/micro-decoder", ref="0.2.0")
|
||||
add_idf_component(name="esphome/micro-decoder", ref="0.4.0")
|
||||
|
||||
# All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash
|
||||
if not data.flac_support:
|
||||
@@ -380,6 +380,8 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
|
||||
if not data.opus_support:
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
|
||||
# Vorbis is unsupported in ESPHome, so always disable it
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_VORBIS", False)
|
||||
if not data.wav_support:
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False)
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
#include "b_parasite.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
namespace esphome::b_parasite {
|
||||
|
||||
static const char *const TAG = "b_parasite";
|
||||
@@ -16,7 +14,7 @@ void BParasite::dump_config() {
|
||||
LOG_SENSOR(" ", "Illuminance", this->illuminance_);
|
||||
}
|
||||
|
||||
bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
|
||||
bool BParasite::parse_device(const ble_device_base::ESPBTDevice &device) {
|
||||
if (device.address_uint64() != address_) {
|
||||
ESP_LOGVV(TAG, "parse_device(): unknown MAC address.");
|
||||
return false;
|
||||
@@ -113,5 +111,3 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
|
||||
}
|
||||
|
||||
} // namespace esphome::b_parasite
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
@@ -2,18 +2,16 @@
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/components/sensor/sensor.h"
|
||||
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
|
||||
|
||||
#ifdef USE_ESP32
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
|
||||
namespace esphome::b_parasite {
|
||||
|
||||
class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
|
||||
class BParasite final : public Component, public ble_device_base::ESPBTDeviceListener {
|
||||
public:
|
||||
void set_address(uint64_t address) { address_ = address; };
|
||||
void set_bindkey(const std::string &bindkey);
|
||||
|
||||
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
|
||||
bool parse_device(const ble_device_base::ESPBTDevice &device) override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_battery_voltage(sensor::Sensor *battery_voltage) { battery_voltage_ = battery_voltage; }
|
||||
@@ -35,5 +33,3 @@ class BParasite final : public Component, public esp32_ble_tracker::ESPBTDeviceL
|
||||
};
|
||||
|
||||
} // namespace esphome::b_parasite
|
||||
|
||||
#endif // USE_ESP32
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32_ble_tracker, sensor
|
||||
from esphome.components import ble_device_base, sensor
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BATTERY_VOLTAGE,
|
||||
@@ -23,14 +23,15 @@ from esphome.const import (
|
||||
|
||||
CODEOWNERS = ["@rbaron"]
|
||||
|
||||
DEPENDENCIES = ["esp32_ble_tracker"]
|
||||
AUTO_LOAD = ["ble_device_base"]
|
||||
|
||||
b_parasite_ns = cg.esphome_ns.namespace("b_parasite")
|
||||
BParasite = b_parasite_ns.class_(
|
||||
"BParasite", esp32_ble_tracker.ESPBTDeviceListener, cg.Component
|
||||
"BParasite", ble_device_base.ESPBTDeviceListener, cg.Component
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
ble_device_base.rename_legacy_hub_id("b_parasite"),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BParasite),
|
||||
@@ -68,15 +69,15 @@ CONFIG_SCHEMA = (
|
||||
),
|
||||
}
|
||||
)
|
||||
.extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(ble_device_base.BLE_DEVICE_SCHEMA),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
await esp32_ble_tracker.register_ble_device(var, config)
|
||||
await ble_device_base.register_ble_device(var, config)
|
||||
|
||||
cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex))
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -51,7 +51,11 @@ def _set_core_data(config):
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = libretiny.BASE_SCHEMA
|
||||
# extend({}) makes this platform's own schema instance: BASE_SCHEMA is shared
|
||||
# by every LibreTiny platform, and prepending this platform's _set_core_data
|
||||
# onto the shared object would run it for every platform's validation once two
|
||||
# platform modules are imported in one process (device-builder, tests).
|
||||
CONFIG_SCHEMA = libretiny.BASE_SCHEMA.extend({})
|
||||
|
||||
PIN_SCHEMA = libretiny.gpio.BASE_PIN_SCHEMA
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""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)
|
||||
|
||||
|
||||
# Once per registered scan listener; sizes the controller's StaticVector
|
||||
# listener storage.
|
||||
request_scan_listener_slot = cg.slot_counter("BK72XX_BLE_SCAN_LISTENER_COUNT")
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
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,119 @@
|
||||
// Every SDK call the scan reconciler makes. The BDK's own start hardcodes
|
||||
// passive (the active bit is commented out in both stacks), so
|
||||
// bdk_scan_start() packs the GAPM_ACTIVITY_START_CMD itself, field-for-field
|
||||
// the SDK's app_ble_start_scaning() except that prop takes the mode, armed
|
||||
// through the SDK's own operation bookkeeping. The component pins
|
||||
// beken-bdk 3.0.78; the static asserts catch a layout change on a bump.
|
||||
|
||||
#include "bdk_scan.h"
|
||||
|
||||
#ifdef USE_BK72XX_BLE
|
||||
|
||||
// Same SDK gate as bk72xx_ble.cpp (which carries the explanatory #error).
|
||||
#if !defined(CLANG_TIDY) && __has_include("ble_api.h")
|
||||
|
||||
extern "C" {
|
||||
#include "app_ble.h" // app_ble_env, app_ble_run, app_ble_reset, actv_state_t,
|
||||
// app_ble_actv_state_get, app_ble_env_state_get,
|
||||
// app_ble_get_idle_actv_idx_handle, UNKNOW_ACT_IDX,
|
||||
// bk_ble_* (via ble_api_5_x.h)
|
||||
#include "kernel_msg.h" // KERNEL_MSG_ALLOC, kernel_msg_send
|
||||
#if __has_include("gapm_msg.h")
|
||||
#include "gapm_msg.h" // BLE 5.2 (BK7238/BK7252N): gapm_activity_start_cmd, GAPM_SCAN_*
|
||||
#else
|
||||
#include "gapm_task.h" // BLE 5.1 (BK7231N/BK7236): same declarations, older header name
|
||||
#endif
|
||||
}
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bk72xx_ble {
|
||||
|
||||
static const char *const TAG = "bk72xx_ble";
|
||||
|
||||
// Pin the SDK surface this file depends on: a beken-bdk bump that moves these
|
||||
// must fail the build, not corrupt the kernel message.
|
||||
static_assert(GAPM_SCAN_PROP_PHY_1M_BIT == (1 << 0) && GAPM_SCAN_PROP_ACTIVE_1M_BIT == (1 << 2) &&
|
||||
sizeof(struct gapm_scan_param) == 16 && sizeof(struct gapm_scan_wd_op_param) == 4,
|
||||
"beken-bdk GAPM scan layout changed; revalidate bdk_scan_start() "
|
||||
"against the SDK's app_ble_start_scaning()");
|
||||
static_assert(INVALID_ACTIVITY_IDX == UNKNOW_ACT_IDX,
|
||||
"beken-bdk activity sentinel changed; revalidate the scan reconciler");
|
||||
static_assert(GAPM_REPORT_TYPE_SCAN_RSP_EXT == 2 && GAPM_REPORT_TYPE_SCAN_RSP_LEG == 3 &&
|
||||
GAPM_REPORT_INFO_SCAN_ADV_BIT == (1 << 5),
|
||||
"beken-bdk GAPM report info changed; revalidate the tracker's demux constants");
|
||||
|
||||
bool bdk_scan_ready() { return app_ble_env_state_get() == APP_BLE_READY; }
|
||||
|
||||
BdkActivityState bdk_scan_state(uint8_t activity_idx) {
|
||||
if (activity_idx == INVALID_ACTIVITY_IDX)
|
||||
return BdkActivityState::IDLE;
|
||||
switch (app_ble_actv_state_get(activity_idx)) {
|
||||
case ACTV_IDLE:
|
||||
return BdkActivityState::IDLE;
|
||||
case ACTV_SCAN_CREATED:
|
||||
return BdkActivityState::CREATED;
|
||||
case ACTV_SCAN_STARTED:
|
||||
return BdkActivityState::STARTED;
|
||||
default:
|
||||
return BdkActivityState::OTHER;
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t bdk_scan_acquire_activity() {
|
||||
uint8_t idx = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
|
||||
if (idx == INVALID_ACTIVITY_IDX)
|
||||
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
|
||||
return idx;
|
||||
}
|
||||
|
||||
BdkOpResult bdk_scan_create(uint8_t activity_idx) {
|
||||
ble_err_t ret = bk_ble_create_scaning(activity_idx, nullptr);
|
||||
if (ret == ERR_SUCCESS)
|
||||
return BdkOpResult::OK;
|
||||
if (ret == ERR_BLE_STATUS)
|
||||
return BdkOpResult::BUSY;
|
||||
ESP_LOGE(TAG, "Scan activity create failed (err %d)", static_cast<int>(ret));
|
||||
return BdkOpResult::FAILED;
|
||||
}
|
||||
|
||||
BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active) {
|
||||
app_ble_run(activity_idx, BLE_START_SCAN, 1 << BLE_OP_START_SCAN_POS, nullptr);
|
||||
struct gapm_activity_start_cmd *cmd =
|
||||
KERNEL_MSG_ALLOC(GAPM_ACTIVITY_START_CMD, TASK_BLE_GAPM, TASK_BLE_APP, gapm_activity_start_cmd);
|
||||
if (cmd == nullptr) {
|
||||
app_ble_reset(); // the SDK's own failure path for an unsent operation
|
||||
ESP_LOGE(TAG, "Scan start failed: kernel message allocation");
|
||||
return BdkOpResult::FAILED;
|
||||
}
|
||||
cmd->operation = GAPM_START_ACTIVITY;
|
||||
cmd->actv_idx = app_ble_env.actvs[activity_idx].gap_advt_idx;
|
||||
cmd->u_param.scan_param.type = GAPM_SCAN_TYPE_OBSERVER;
|
||||
cmd->u_param.scan_param.prop = GAPM_SCAN_PROP_PHY_1M_BIT | (active ? GAPM_SCAN_PROP_ACTIVE_1M_BIT : 0);
|
||||
cmd->u_param.scan_param.scan_param_1m.scan_intv = interval;
|
||||
cmd->u_param.scan_param.scan_param_1m.scan_wd = window;
|
||||
cmd->u_param.scan_param.scan_param_coded.scan_intv = 0;
|
||||
cmd->u_param.scan_param.scan_param_coded.scan_wd = 0;
|
||||
cmd->u_param.scan_param.dup_filt_pol = 0;
|
||||
cmd->u_param.scan_param.rsvd = 0;
|
||||
cmd->u_param.scan_param.duration = 0; // scan until stopped
|
||||
cmd->u_param.scan_param.period = 10; // matches the SDK's passive start
|
||||
kernel_msg_send(cmd);
|
||||
return BdkOpResult::OK;
|
||||
}
|
||||
|
||||
BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out) {
|
||||
ble_err_t ret = created ? bk_ble_delete_scaning(activity_idx, nullptr) : bk_ble_scan_stop(activity_idx, nullptr);
|
||||
*err_out = static_cast<int>(ret);
|
||||
if (ret == ERR_SUCCESS)
|
||||
return BdkOpResult::OK;
|
||||
// DEBUG on purpose: the reconciler WARNs once per streak and the stuck
|
||||
// ERROR carries this code — a per-retry ERROR would be unbounded.
|
||||
ESP_LOGD(TAG, "Scan release %s (err %d)", ret == ERR_BLE_STATUS ? "rejected" : "failed", static_cast<int>(ret));
|
||||
return ret == ERR_BLE_STATUS ? BdkOpResult::BUSY : BdkOpResult::FAILED;
|
||||
}
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
#endif // !CLANG_TIDY && ble_api.h
|
||||
#endif // USE_BK72XX_BLE
|
||||
@@ -0,0 +1,51 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BK72XX_BLE
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::bk72xx_ble {
|
||||
|
||||
/// Activity index value marking "no scan activity", the BDK's own convention
|
||||
/// (asserted against its symbol in bdk_scan.cpp).
|
||||
inline constexpr uint8_t INVALID_ACTIVITY_IDX = 0xFF;
|
||||
|
||||
/// Scan-relevant controller activity states, read live from the SDK.
|
||||
enum class BdkActivityState : uint8_t {
|
||||
IDLE, ///< No activity (or one whose create failed).
|
||||
CREATED, ///< Created but not started.
|
||||
STARTED, ///< Scanning.
|
||||
OTHER, ///< A non-scan or transitional state; settles on a later read.
|
||||
};
|
||||
|
||||
/// Outcome of a BDK scan operation request.
|
||||
enum class BdkOpResult : uint8_t {
|
||||
OK, ///< Accepted; completion is asynchronous.
|
||||
BUSY, ///< Another controller operation is in flight; retry later.
|
||||
FAILED, ///< Rejected.
|
||||
};
|
||||
|
||||
/// True when no controller operation is in flight (APP_BLE_READY).
|
||||
bool bdk_scan_ready();
|
||||
/// Live state of the given activity; INVALID_ACTIVITY_IDX reads as IDLE.
|
||||
BdkActivityState bdk_scan_state(uint8_t activity_idx);
|
||||
/// Claim an idle activity slot; INVALID_ACTIVITY_IDX when none is free.
|
||||
uint8_t bdk_scan_acquire_activity();
|
||||
/// Create the scan activity (asynchronous); started once CREATED is observed.
|
||||
BdkOpResult bdk_scan_create(uint8_t activity_idx);
|
||||
/// Start a created activity: the packed GAPM start, taking the scan mode the
|
||||
/// BDK's own start path hardcodes away. Fire-and-forget; FAILED when the
|
||||
/// kernel message could not be allocated (the armed SDK operation is rolled
|
||||
/// back).
|
||||
BdkOpResult bdk_scan_start(uint8_t activity_idx, uint16_t interval, uint16_t window, bool active);
|
||||
/// Release the activity: delete when never started (a stop would be
|
||||
/// rejected), stop otherwise. BUSY on a transient rejection (retry), FAILED
|
||||
/// on any other error; err_out receives the SDK code (0 on success).
|
||||
/// Teardown is asynchronous — observe IDLE to confirm.
|
||||
BdkOpResult bdk_scan_release(uint8_t activity_idx, bool created, int *err_out);
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
#endif // USE_BK72XX_BLE
|
||||
@@ -0,0 +1,515 @@
|
||||
// bk72xx_ble.cpp
|
||||
//
|
||||
// BLE controller support for the BK72xx BLE-5.x chips (LibreTiny beken-72xx
|
||||
// family) — the platform analog of esp32_ble / rp2040_ble. Owns everything that
|
||||
// talks to the Beken BDK BLE stack:
|
||||
// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()),
|
||||
// - the controller BLE address,
|
||||
// - the scan reconciler (request, pacing, bring-up budget) over the
|
||||
// bdk_scan surface,
|
||||
// - the scan-report ring: the BDK notice callback (BLE task) takes a report
|
||||
// from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains,
|
||||
// dispatches on the main task and returns reports to the pool — the same
|
||||
// EventPool + LockFreeQueue handoff esp32_ble uses, zero allocation at
|
||||
// steady state.
|
||||
// Consumers contain no SDK calls of their own.
|
||||
//
|
||||
// NOTE: the Beken BDK BLE 5.x stack is compiled and linked by the LibreTiny
|
||||
// beken-72xx builder itself (prebuilt libble_<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
|
||||
|
||||
#include "bdk_scan.h" // the raw BDK scan surface (state reads, starts, release)
|
||||
|
||||
#ifdef USE_BK72XX_BLE
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h" // get_mac_address_raw()
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK-capability gate (not a chip allowlist).
|
||||
// This component drives the Beken BLE *5.x* controller via its public API,
|
||||
// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the
|
||||
// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the
|
||||
// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header
|
||||
// itself so any BLE-5.x Beken chip — present or future — is supported without a
|
||||
// hard-coded list, and a non-5.x build fails here with a clear message instead
|
||||
// of a cryptic "ble_api.h: No such file or directory".
|
||||
// ---------------------------------------------------------------------------
|
||||
#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" // ble_set_notice_cb, recv_adv_t, ble_notice_t,
|
||||
// BLE_5_REPORT_ADV (scan primitives live in bdk_scan.cpp)
|
||||
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
|
||||
#include "common_bt_defines.h" // struct bd_addr
|
||||
// The controller's public BLE address, populated by the BDK during ble_entry().
|
||||
// Present on BK7231N; the other BLE-5.x chips' stacks have no such symbol — there the
|
||||
// address is derived from the WiFi MAC instead (matching the BDK's own fallback).
|
||||
extern struct bd_addr common_default_bdaddr;
|
||||
#endif
|
||||
// ble_entry() brings up the BDK BLE stack; it is not declared in ble_api.h, so
|
||||
// declare it here.
|
||||
void ble_entry(void);
|
||||
}
|
||||
|
||||
namespace esphome::bk72xx_ble {
|
||||
|
||||
static const char *const TAG = "bk72xx_ble";
|
||||
|
||||
static constexpr uint32_t RECONCILE_RETRY_MS = 10; // pump floor for fast loops
|
||||
static constexpr uint32_t RECONCILE_REJECTED_RETRY_MS = 500; // retry gate after a rejected release
|
||||
static constexpr uint32_t RECONCILE_PENDING_TIMEOUT_MS = 2000; // bring-up budget before FAILED
|
||||
static constexpr uint32_t SCAN_LIVENESS_CHECK_MS = 1000; // settled-scan re-check cadence
|
||||
static constexpr uint32_t TEARDOWN_STUCK_ERROR_MS = 30000; // stuck-teardown ERROR (stop also goes FAILED)
|
||||
|
||||
// The BDK notice callback is a plain C function pointer with no user argument,
|
||||
// so it reaches the (single) component instance through a file-static pointer.
|
||||
static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BLE notice callback — runs in the BDK BLE task context.
|
||||
// The BK controller reports every advertisement as a BLE_5_REPORT_ADV notice
|
||||
// carrying a recv_adv_t. Copy it into the queue and return; all dispatch
|
||||
// happens in loop() on the main task.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void ble_notice_callback(ble_notice_t notice, void *param) {
|
||||
if (s_ble == nullptr || param == nullptr)
|
||||
return;
|
||||
if (notice != BLE_5_REPORT_ADV)
|
||||
return;
|
||||
|
||||
const recv_adv_t *info = reinterpret_cast<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,
|
||||
static_cast<uint8_t>(info->evt_type), info->data, info->data_len);
|
||||
}
|
||||
|
||||
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type,
|
||||
const uint8_t *data, uint16_t data_len) {
|
||||
BLEScanReport *report = this->report_pool_.allocate();
|
||||
if (report == nullptr) {
|
||||
// Pool exhausted — the queue is full; count and drop.
|
||||
this->report_queue_.increment_dropped_count();
|
||||
return;
|
||||
}
|
||||
memcpy(report->mac, mac, MAC_ADDRESS_SIZE);
|
||||
report->rssi = rssi;
|
||||
report->addr_type = addr_type;
|
||||
report->evt_type = evt_type;
|
||||
report->data_len =
|
||||
(data_len <= sizeof(report->data)) ? static_cast<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;
|
||||
// The report pool grows lazily on purpose: the BDK notice callback runs in
|
||||
// task context (malloc-safe, unlike rp2040's IRQ path), and typical traffic
|
||||
// stays far below the pool cap, so not warming contains RAM.
|
||||
// Resolve the MAC early so get_mac_lsb_first() is valid for consumers before
|
||||
// the stack is up (it is re-read once ble_entry() has run).
|
||||
this->resolve_mac_();
|
||||
if (this->enable_on_boot_) {
|
||||
this->enable();
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the BDK
|
||||
// is first touched only once WiFi is up (single-core WiFi/BLE bring-up order).
|
||||
float BK72xxBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; }
|
||||
|
||||
void BK72xxBLE::enable() {
|
||||
if (this->state_ != BLEComponentState::STATE_OFF)
|
||||
return;
|
||||
this->state_ = BLEComponentState::ENABLING;
|
||||
|
||||
// One-time BLE stack init: register the notice callback, then bring up the
|
||||
// BDK BLE stack. The BDK has no teardown path — init happens at most once.
|
||||
ble_set_notice_cb(ble_notice_callback);
|
||||
ble_entry();
|
||||
|
||||
delay(100); // NOLINT — one-time BLE stack init; the SDK needs this settle time
|
||||
|
||||
// Re-read the BLE MAC now that the controller is up (common_default_bdaddr is
|
||||
// populated by ble_entry()); resolve_mac_() may have fallen back earlier.
|
||||
this->resolve_mac_();
|
||||
|
||||
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
|
||||
// Liveness heuristic (BK7231N): a healthy ble_entry() populates
|
||||
// common_default_bdaddr during init, so all-zero after the settle delay
|
||||
// suggests the stack did not come up. The BDK entry point returns void — no
|
||||
// return code exists — so warn rather than fail: scan starts against a dead
|
||||
// stack already fail cleanly downstream (no idle activity handle).
|
||||
bool bdaddr_live = false;
|
||||
for (uint8_t b : common_default_bdaddr.addr) {
|
||||
if (b != 0) {
|
||||
bdaddr_live = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bdaddr_live)
|
||||
ESP_LOGW(TAG, "Controller address still unset after init; BLE stack may not have started");
|
||||
#endif
|
||||
|
||||
this->state_ = BLEComponentState::ACTIVE;
|
||||
ESP_LOGD(TAG, "BLE stack initialised");
|
||||
}
|
||||
|
||||
void BK72xxBLE::loop() {
|
||||
// Keep reconciling toward the requested scan state (e.g. complete a stop
|
||||
// that arrived while a controller operation was in flight), and re-check a
|
||||
// settled scan at low frequency: a controller-side drop re-enters the
|
||||
// bring-up, and the budget's FAILED feeds the tracker's recovery.
|
||||
// Keep driving until settled: any PENDING, plus a terminal stop whose slot
|
||||
// must still be freed. A FAILED scan request is the one combination not
|
||||
// re-driven here — that belongs to the tracker's backoff.
|
||||
const uint32_t pump_now = App.get_loop_component_start_time();
|
||||
if (this->last_result_ == ScanOpResult::PENDING ||
|
||||
(!this->scan_wanted_ && this->last_result_ == ScanOpResult::FAILED)) {
|
||||
const uint32_t gate = (this->release_warned_ || this->last_result_ == ScanOpResult::FAILED)
|
||||
? RECONCILE_REJECTED_RETRY_MS
|
||||
: RECONCILE_RETRY_MS;
|
||||
if (pump_now - this->last_advance_ms_ >= gate)
|
||||
this->advance_();
|
||||
} else if (this->scan_wanted_ && this->last_result_ == ScanOpResult::SETTLED &&
|
||||
pump_now - this->last_advance_ms_ >= SCAN_LIVENESS_CHECK_MS) {
|
||||
// Re-check a settled scan; scan_start() refills the bring-up budget.
|
||||
// WARN: the only report of a drop that recovers inside its budget.
|
||||
if (this->scan_start(this->requested_.interval, this->requested_.window, this->requested_.active) !=
|
||||
ScanOpResult::SETTLED)
|
||||
ESP_LOGW(TAG, "Controller dropped the scan; restarting");
|
||||
}
|
||||
|
||||
// Drain the lock-free ring filled by the BLE task; all per-report work runs
|
||||
// here on the main task, then the report returns to the pool.
|
||||
BLEScanReport *report = this->report_queue_.pop();
|
||||
if (report == nullptr)
|
||||
return;
|
||||
do {
|
||||
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
|
||||
for (auto *listener : this->scan_listeners_)
|
||||
listener->on_scan_report(*report);
|
||||
#endif
|
||||
this->report_pool_.release(report);
|
||||
} while ((report = this->report_queue_.pop()) != nullptr);
|
||||
|
||||
// Log dropped reports — only reachable when reports were processed; drops can
|
||||
// only occur while the queue is full, and only this loop drains it.
|
||||
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
|
||||
if (dropped > 0)
|
||||
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped);
|
||||
}
|
||||
|
||||
void BK72xxBLE::get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const {
|
||||
for (int i = 0; i < 6; i++)
|
||||
out[i] = this->ble_mac_[i];
|
||||
}
|
||||
|
||||
void BK72xxBLE::dump_config() {
|
||||
// ble_mac_ is stored LSB-first (BLE convention); print [5..0] for the
|
||||
// MSB-first order Home Assistant shows.
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BK72xx BLE:\n"
|
||||
" MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n"
|
||||
" Active: %s",
|
||||
this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1],
|
||||
this->ble_mac_[0], YESNO(this->is_active()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MAC resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void BK72xxBLE::resolve_mac_() {
|
||||
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
|
||||
// BK7231N: the BDK populates common_default_bdaddr (LSB-first, BLE convention)
|
||||
// during ble_entry(). It may still be zero before the stack is up; if so, fall
|
||||
// through to the WiFi-derived MAC below.
|
||||
bool nonzero = false;
|
||||
for (uint8_t b : common_default_bdaddr.addr) {
|
||||
if (b != 0) {
|
||||
nonzero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nonzero) {
|
||||
memcpy(this->ble_mac_, common_default_bdaddr.addr, MAC_ADDRESS_SIZE);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// Chips whose BLE stack does not export common_default_bdaddr (BK7238 and the other
|
||||
// BLE-5.x SoCs), or BK7231N before the stack is up: derive the BLE MAC exactly as the
|
||||
// Beken BDK does in bdaddr_env_init() — the WiFi STA MAC with only its last byte
|
||||
// incremented (sta_mac[5] += 1, a plain byte increment with no carry into the next
|
||||
// byte), OUI unchanged. This reproduces the address the controller advertises with
|
||||
// (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it
|
||||
// matches on every device, including the last-byte == 0xFF edge that a 24-bit increment
|
||||
// would carry differently.
|
||||
uint8_t wifi_mac[MAC_ADDRESS_SIZE];
|
||||
get_mac_address_raw(wifi_mac); // MSB-first
|
||||
const uint8_t ble[MAC_ADDRESS_SIZE] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
|
||||
wifi_mac[3], wifi_mac[4], static_cast<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];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan reconciler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Episode boundary: fresh teardown deadline and error bookkeeping.
|
||||
void BK72xxBLE::reset_teardown_episode_() {
|
||||
this->teardown_since_ms_ = 0;
|
||||
this->restarting_ = false;
|
||||
this->last_release_err_ = 0;
|
||||
}
|
||||
|
||||
ScanOpResult BK72xxBLE::scan_start(uint16_t interval, uint16_t window, bool active) {
|
||||
if (!this->is_active())
|
||||
this->enable();
|
||||
|
||||
const ScanParams params{active, interval, window};
|
||||
// A new episode refills the budget and gets a fresh teardown deadline; a
|
||||
// re-call observing an in-flight bring-up (last result PENDING) must not.
|
||||
if (this->last_result_ != ScanOpResult::PENDING || !this->scan_wanted_ || params != this->requested_) {
|
||||
this->pending_since_ms_ = App.get_loop_component_start_time();
|
||||
this->reset_teardown_episode_();
|
||||
}
|
||||
this->scan_wanted_ = true;
|
||||
this->requested_ = params;
|
||||
return this->advance_();
|
||||
}
|
||||
|
||||
void BK72xxBLE::scan_stop() {
|
||||
if (this->scan_wanted_) {
|
||||
// A stamp inherited from a stuck restart would fail the stop on its
|
||||
// first advance.
|
||||
this->reset_teardown_episode_();
|
||||
}
|
||||
this->scan_wanted_ = false;
|
||||
this->advance_();
|
||||
}
|
||||
|
||||
bool BK72xxBLE::flush_pending_stop(uint32_t timeout_ms) {
|
||||
// millis() on both sides: the loop clock is frozen while this blocks.
|
||||
const uint32_t start = millis();
|
||||
while (!this->scan_wanted_ && this->last_result_ == ScanOpResult::PENDING) {
|
||||
if (millis() - start >= timeout_ms)
|
||||
return false;
|
||||
delay(RECONCILE_RETRY_MS);
|
||||
this->advance_();
|
||||
}
|
||||
return this->last_result_ == ScanOpResult::SETTLED;
|
||||
}
|
||||
|
||||
// Teardown is asynchronous: the handle is kept until an IDLE observation
|
||||
// confirms the radio is idle. A rejection WARNs once per failure streak and
|
||||
// widens the pump gate; the epilogue owns the stuck-teardown deadline.
|
||||
void BK72xxBLE::release_activity_(BdkActivityState state) {
|
||||
const BdkOpResult result =
|
||||
bdk_scan_release(this->scan_activity_idx_, state == BdkActivityState::CREATED, &this->last_release_err_);
|
||||
if (result == BdkOpResult::OK) {
|
||||
this->release_warned_ = false;
|
||||
return;
|
||||
}
|
||||
if (!this->release_warned_) {
|
||||
// A hard error carries its code immediately; the 30 s stuck ERROR follows
|
||||
// if it persists.
|
||||
if (result == BdkOpResult::FAILED) {
|
||||
ESP_LOGW(TAG, "Scan activity release failed (err %d); retrying", this->last_release_err_);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Scan activity release rejected; retrying");
|
||||
}
|
||||
this->release_warned_ = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Stamp/track the teardown episode; once past the deadline, ERROR (re-logged
|
||||
// each interval) and report stuck.
|
||||
bool BK72xxBLE::teardown_stuck_(uint32_t now) {
|
||||
if (this->teardown_since_ms_ == 0) {
|
||||
this->teardown_since_ms_ = now;
|
||||
this->teardown_stuck_log_ms_ = now; // first ERROR fires at the deadline
|
||||
return false;
|
||||
}
|
||||
if (now - this->teardown_since_ms_ < TEARDOWN_STUCK_ERROR_MS)
|
||||
return false;
|
||||
if (now - this->teardown_stuck_log_ms_ >= TEARDOWN_STUCK_ERROR_MS) {
|
||||
if (this->last_release_err_ != 0) {
|
||||
ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (release err %d)", this->last_release_err_);
|
||||
} else {
|
||||
// No rejected release this episode: stuck waiting on the controller.
|
||||
ESP_LOGE(TAG, "Scan teardown cannot proceed; scanner is stuck (controller busy)");
|
||||
}
|
||||
this->teardown_stuck_log_ms_ = now;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// One SDK operation per call toward the latched request; controller state is
|
||||
// read live each time (it changes on the BLE task, so nothing is mirrored).
|
||||
// The epilogue owns all deadlines and episode bookkeeping.
|
||||
ScanOpResult BK72xxBLE::advance_() {
|
||||
if (!this->scan_wanted_ && this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) {
|
||||
// Nothing to do; also keeps SDK reads off the pre-enable() path.
|
||||
this->last_result_ = ScanOpResult::SETTLED;
|
||||
return ScanOpResult::SETTLED;
|
||||
}
|
||||
const BdkActivityState state = bdk_scan_state(this->scan_activity_idx_);
|
||||
const bool ready = bdk_scan_ready();
|
||||
ScanOpResult result = this->scan_wanted_ ? this->advance_start_(state, ready) : this->advance_stop_(state, ready);
|
||||
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
this->last_advance_ms_ = now;
|
||||
if (result == ScanOpResult::SETTLED || (state == BdkActivityState::IDLE && ready)) {
|
||||
// Any teardown episode is over (IDLE observed with the controller
|
||||
// settled, or e.g. a mode flip that settled back without ever reaching
|
||||
// IDLE). An IDLE read while an operation is in flight proves nothing —
|
||||
// a stop deferred there must keep its episode running.
|
||||
this->reset_teardown_episode_();
|
||||
this->release_warned_ = false;
|
||||
}
|
||||
if (this->restarting_ && (state == BdkActivityState::IDLE || state == BdkActivityState::CREATED)) {
|
||||
// The mode-change release is observed complete; the rest is a normal
|
||||
// bring-up on a fresh budget.
|
||||
this->restarting_ = false;
|
||||
this->pending_since_ms_ = now;
|
||||
}
|
||||
// Not chained to the clear above: a bring-up waiting at IDLE (create still
|
||||
// in flight) must keep spending its budget.
|
||||
if (result == ScanOpResult::PENDING) {
|
||||
if (this->scan_wanted_ && state != BdkActivityState::STARTED && !this->restarting_) {
|
||||
// A downed radio spends the bring-up budget; exhausting it hands
|
||||
// recovery to the tracker's backoff.
|
||||
if (now - this->pending_since_ms_ >= RECONCILE_PENDING_TIMEOUT_MS) {
|
||||
ESP_LOGE(TAG, "Scan bring-up did not settle; giving up until the next start");
|
||||
result = ScanOpResult::FAILED;
|
||||
}
|
||||
} else {
|
||||
// A teardown is pending: a stop, or a mode-change release still in
|
||||
// flight (restarting_); either way the bring-up budget waits.
|
||||
if (this->scan_wanted_)
|
||||
this->pending_since_ms_ = now;
|
||||
if (this->teardown_stuck_(now)) {
|
||||
// Terminal for stop AND restart: the tracker's backoff owns recovery
|
||||
// (a stop's release keeps re-driving from loop(); a restart is
|
||||
// re-requested through scan_start() with a fresh deadline).
|
||||
result = ScanOpResult::FAILED;
|
||||
}
|
||||
}
|
||||
}
|
||||
this->last_result_ = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
ScanOpResult BK72xxBLE::advance_stop_(BdkActivityState state, bool ready) {
|
||||
if (state == BdkActivityState::IDLE && ready) {
|
||||
// Fully torn down (or never created): the radio is idle. IDLE is trusted
|
||||
// only when the controller is settled — mid-create the slot still reads
|
||||
// IDLE, and dropping the handle then would leak the activity once the
|
||||
// create lands.
|
||||
this->scan_activity_idx_ = INVALID_ACTIVITY_IDX;
|
||||
return ScanOpResult::SETTLED;
|
||||
}
|
||||
if (!ready) {
|
||||
// Acting mid-operation could delete an activity whose start lands
|
||||
// afterwards, leaking the slot with the radio on; wait.
|
||||
if (this->last_result_ == ScanOpResult::SETTLED)
|
||||
ESP_LOGD(TAG, "Scan stop deferred (controller busy)");
|
||||
return ScanOpResult::PENDING;
|
||||
}
|
||||
// Settled, so CREATED unambiguously means "never started".
|
||||
this->release_activity_(state);
|
||||
return ScanOpResult::PENDING; // confirmed once IDLE is observed
|
||||
}
|
||||
|
||||
ScanOpResult BK72xxBLE::advance_start_(BdkActivityState state, bool ready) {
|
||||
if (state == BdkActivityState::STARTED) {
|
||||
if (this->applied_ == this->requested_)
|
||||
return ScanOpResult::SETTLED;
|
||||
// Running with different mode or parameters: tear down (the SDK stop
|
||||
// chain also deletes the activity) and recreate on a later advance.
|
||||
if (ready) {
|
||||
this->release_activity_(state);
|
||||
// Invalidate so a flip back to the old params cannot SETTLE against the
|
||||
// activity being deleted (interval 0 never matches a real request).
|
||||
this->applied_.interval = 0;
|
||||
this->restarting_ = true;
|
||||
}
|
||||
return ScanOpResult::PENDING;
|
||||
}
|
||||
if (!ready) {
|
||||
if (this->last_result_ == ScanOpResult::SETTLED)
|
||||
ESP_LOGD(TAG, "Scan start deferred (controller busy)");
|
||||
return ScanOpResult::PENDING;
|
||||
}
|
||||
if (state == BdkActivityState::CREATED) {
|
||||
// Fire-and-forget: SETTLED only once a later advance observes the scan
|
||||
// running, so a rejected start is retried rather than silently dead. On
|
||||
// failure the created activity is intact; keep the handle.
|
||||
if (bdk_scan_start(this->scan_activity_idx_, this->requested_.interval, this->requested_.window,
|
||||
this->requested_.active) != BdkOpResult::OK)
|
||||
return ScanOpResult::FAILED;
|
||||
this->applied_ = this->requested_;
|
||||
return ScanOpResult::PENDING;
|
||||
}
|
||||
if (state == BdkActivityState::OTHER)
|
||||
return ScanOpResult::PENDING; // transitional; settles on a later read
|
||||
|
||||
// IDLE and ready: acquire a slot and create. A kept index is deliberately
|
||||
// reused: SDK delete returns the slot to idle and create requires an idle
|
||||
// slot, so it equals a fresh acquire — while clearing here would orphan a
|
||||
// create still in flight (the BUSY race below).
|
||||
if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX) {
|
||||
this->scan_activity_idx_ = bdk_scan_acquire_activity();
|
||||
if (this->scan_activity_idx_ == INVALID_ACTIVITY_IDX)
|
||||
return ScanOpResult::FAILED;
|
||||
}
|
||||
switch (bdk_scan_create(this->scan_activity_idx_)) {
|
||||
case BdkOpResult::BUSY: // raced the BLE task; keep the index, the retry resumes this slot
|
||||
case BdkOpResult::OK:
|
||||
return ScanOpResult::PENDING;
|
||||
case BdkOpResult::FAILED:
|
||||
break;
|
||||
}
|
||||
// Safe to clear (unlike BUSY): acquire is a pure search, so a rejected
|
||||
// create leaves the slot IDLE for re-acquire.
|
||||
this->scan_activity_idx_ = INVALID_ACTIVITY_IDX;
|
||||
return ScanOpResult::FAILED;
|
||||
}
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
#endif // BK72XX_BLE_NO_SDK
|
||||
#endif // USE_BK72XX_BLE
|
||||
@@ -0,0 +1,158 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BK72XX_BLE
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/event_pool.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#include "bdk_scan.h"
|
||||
|
||||
namespace esphome::bk72xx_ble {
|
||||
|
||||
enum class BLEComponentState : uint8_t {
|
||||
STATE_OFF = 0,
|
||||
ENABLING,
|
||||
ACTIVE,
|
||||
};
|
||||
|
||||
/// Outcome of one reconciliation step.
|
||||
enum class ScanOpResult : uint8_t {
|
||||
SETTLED, ///< The request is reached: scan observed running, or stopped
|
||||
///< with the activity fully released.
|
||||
PENDING, ///< A step is in flight; loop() keeps advancing — call
|
||||
///< scan_start() again to learn the outcome.
|
||||
FAILED, ///< The controller rejected a step; retry later.
|
||||
};
|
||||
|
||||
/// One scan request: mode plus timing, in BLE units (0.625 ms).
|
||||
struct ScanParams {
|
||||
bool active;
|
||||
uint16_t interval;
|
||||
uint16_t window;
|
||||
bool operator==(const ScanParams &) const = default;
|
||||
};
|
||||
|
||||
/// One advertisement report from the controller.
|
||||
struct BLEScanReport {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE]; // LSB-first, as the controller delivers it
|
||||
int8_t rssi; // signed dBm
|
||||
uint8_t addr_type;
|
||||
// GAPM report info byte (recv_adv_t.evt_type): bits 0-2 report type
|
||||
// (1 = legacy adv, 3 = legacy scan response), bit 5 scannable — lets the
|
||||
// tracker's merger tell the two frames apart.
|
||||
uint8_t evt_type;
|
||||
uint8_t data_len; // bytes valid in data[]
|
||||
uint8_t data[62]; // legacy advertisement (31) + scan response (31)
|
||||
|
||||
// EventPool contract: nothing is heap-allocated inside a report.
|
||||
void release() {}
|
||||
};
|
||||
|
||||
/// Consumer interface for controller scan reports. on_scan_report() always runs
|
||||
/// on the ESPHome main task: reports are queued from the BDK BLE task and
|
||||
/// drained by the controller's loop(), so consumers never deal with cross-task
|
||||
/// state (the esp32_ble event-queue pattern).
|
||||
class BLEScanListener {
|
||||
public:
|
||||
virtual void on_scan_report(const BLEScanReport &report) = 0;
|
||||
|
||||
protected:
|
||||
~BLEScanListener() = default; // deletion via this interface is not part of the contract
|
||||
};
|
||||
|
||||
// Maximum reports buffered between the BLE task and loop().
|
||||
static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64;
|
||||
|
||||
class BK72xxBLE final : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override;
|
||||
|
||||
/// Bring up the BDK BLE stack (one-time; the BDK has no teardown path).
|
||||
void enable();
|
||||
bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; }
|
||||
|
||||
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
|
||||
|
||||
/// Controller BLE address, least-significant octet first (BLE convention).
|
||||
void get_mac_lsb_first(uint8_t out[MAC_ADDRESS_SIZE]) const;
|
||||
|
||||
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
|
||||
/// Register a consumer for scan reports (delivered on the main task via loop()).
|
||||
/// Storage is codegen-sized: the consumer's codegen requests a slot via
|
||||
/// request_scan_listener_slot(), which emits BK72XX_BLE_SCAN_LISTENER_COUNT.
|
||||
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
|
||||
#endif
|
||||
|
||||
/// Request a scan (interval/window in 0.625 ms BLE units); enables the
|
||||
/// stack first if needed. PENDING until the scan is observed running —
|
||||
/// loop() keeps advancing, call again to learn the outcome.
|
||||
ScanOpResult scan_start(uint16_t interval, uint16_t window, bool active);
|
||||
/// Request the scanner stopped and the activity released; steps that
|
||||
/// cannot run yet are completed from loop().
|
||||
void scan_stop();
|
||||
/// Drive a requested stop until the radio is observed idle, bounded by
|
||||
/// timeout_ms (for OTA). Returns false if it still has not settled.
|
||||
bool flush_pending_stop(uint32_t timeout_ms);
|
||||
/// Last reconciliation outcome; on FAILED the consumer's retry policy owns
|
||||
/// recovery.
|
||||
ScanOpResult last_scan_result() const { return this->last_result_; }
|
||||
|
||||
/// Internal: buffer one controller report (BDK notice callback, BLE task
|
||||
/// context — bounded copy under the scheduler lock, nothing else).
|
||||
void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, uint8_t evt_type, const uint8_t *data,
|
||||
uint16_t data_len);
|
||||
|
||||
protected:
|
||||
void resolve_mac_();
|
||||
ScanOpResult advance_();
|
||||
ScanOpResult advance_stop_(BdkActivityState state, bool ready);
|
||||
ScanOpResult advance_start_(BdkActivityState state, bool ready);
|
||||
bool teardown_stuck_(uint32_t now);
|
||||
void reset_teardown_episode_();
|
||||
void release_activity_(BdkActivityState state);
|
||||
|
||||
#ifdef BK72XX_BLE_SCAN_LISTENER_COUNT
|
||||
// Codegen-sized: no heap allocation, no std::vector template instantiation —
|
||||
// the same StaticVector pattern as the tracker's ble_device_base listeners.
|
||||
StaticVector<BLEScanListener *, BK72XX_BLE_SCAN_LISTENER_COUNT> scan_listeners_;
|
||||
#endif
|
||||
// Report ring: the BDK notice callback (BLE task) allocates a report from the
|
||||
// pool, fills it and pushes the pointer; loop() pops, dispatches and releases.
|
||||
// Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern.
|
||||
esphome::LockFreeQueue<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_;
|
||||
// Largest-to-smallest: padding only at the tail, absorbed by future byte fields.
|
||||
uint32_t last_advance_ms_{0};
|
||||
uint32_t pending_since_ms_{0}; // bring-up budget anchor; refilled on request change
|
||||
uint32_t teardown_since_ms_{0}; // unfinished teardown episode start; 0 = none
|
||||
uint32_t teardown_stuck_log_ms_{0}; // last stuck-teardown ERROR; re-logged each TEARDOWN_STUCK_ERROR_MS
|
||||
int last_release_err_{0}; // SDK code of the episode's last failed release; 0 = none
|
||||
ScanParams requested_{}; // latched by scan_start()
|
||||
ScanParams applied_{}; // last params we commanded; mismatch with requested_ restarts
|
||||
uint8_t ble_mac_[MAC_ADDRESS_SIZE]{0}; // LSB-first (BLE convention)
|
||||
uint8_t scan_activity_idx_{INVALID_ACTIVITY_IDX};
|
||||
bool scan_wanted_{false}; // the latched request is to scan (vs stopped)
|
||||
bool release_warned_{false}; // gates the release WARN; widens the pump gate
|
||||
bool restarting_{false}; // mode-change release in flight; teardown deadline governs until released
|
||||
bool enable_on_boot_{false};
|
||||
// PENDING means advance_() has more to do; loop() drives it, paced and
|
||||
// (for a bring-up) bounded.
|
||||
ScanOpResult last_result_{ScanOpResult::SETTLED};
|
||||
BLEComponentState state_{BLEComponentState::STATE_OFF};
|
||||
};
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
#endif // USE_BK72XX_BLE
|
||||
@@ -0,0 +1,190 @@
|
||||
"""BK72xx BLE Tracker — ESPHome BLE 5.x scanner for the BLE-5.x-capable
|
||||
LibreTiny Beken chips (beken-72xx family).
|
||||
|
||||
Builds on the bk72xx_ble controller component (stack bring-up, BLE address,
|
||||
scan primitives) and implements the platform-neutral ble_device_base BLEHub
|
||||
contract: the shared BLE sensors (ble_presence, ble_rssi, ble_scanner,
|
||||
bthome_mithermometer, xiaomi_*, …) bind to this tracker through
|
||||
cv.use_id(BLEHub) with no BK-specific code.
|
||||
|
||||
Scan modes:
|
||||
continuous: true — scan runs forever; never stops automatically.
|
||||
Use this when the radio is dedicated to BLE.
|
||||
continuous: false — a started scan runs for `duration` ms, then stops. The
|
||||
FIRST start is external too: nothing in this component
|
||||
starts a non-continuous scan on boot — the radio stays
|
||||
idle until bk72xx_ble_tracker.start_scan fires (e.g.
|
||||
from an api client-connected automation), so the
|
||||
single-core radio can service WiFi in between scans.
|
||||
"""
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import bk72xx_ble, ble_device_base, ota
|
||||
from esphome.components.ble_device_base import automation as ble_automation
|
||||
from esphome.components.const import CONF_ON_SCAN_END, CONF_SCAN_PARAMETERS, CONF_WINDOW
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACTIVE,
|
||||
CONF_CONTINUOUS,
|
||||
CONF_DURATION,
|
||||
CONF_ID,
|
||||
CONF_INTERVAL,
|
||||
CONF_MANUFACTURER_ID,
|
||||
CONF_ON_BLE_ADVERTISE,
|
||||
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
|
||||
CONF_ON_BLE_SERVICE_DATA_ADVERTISE,
|
||||
CONF_SERVICE_UUID,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONF_BK72XX_BLE_ID = "bk72xx_ble_id"
|
||||
|
||||
DEPENDENCIES = ["bk72xx"]
|
||||
AUTO_LOAD = ["ble_device_base", "bk72xx_ble"]
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
|
||||
ble_device_base.register_hub_provider("bk72xx_ble_tracker")
|
||||
|
||||
bk72xx_ble_tracker_ns = cg.esphome_ns.namespace("bk72xx_ble_tracker")
|
||||
BK72xxBLETracker = bk72xx_ble_tracker_ns.class_(
|
||||
"BK72xxBLETracker", ble_device_base.BLEHub, cg.Component
|
||||
)
|
||||
|
||||
StartScanAction = bk72xx_ble_tracker_ns.class_("StartScanAction", automation.Action)
|
||||
StopScanAction = bk72xx_ble_tracker_ns.class_("StopScanAction", automation.Action)
|
||||
|
||||
ESPBTAdvertiseTrigger = ble_automation.ESPBTAdvertiseTrigger
|
||||
BLEServiceDataAdvertiseTrigger = ble_automation.BLEServiceDataAdvertiseTrigger
|
||||
BLEManufacturerDataAdvertiseTrigger = ble_automation.BLEManufacturerDataAdvertiseTrigger
|
||||
BLEEndOfScanTrigger = ble_automation.BLEEndOfScanTrigger
|
||||
|
||||
|
||||
# interval defaults to the BK reference scan rate — 100 ms with the shared 30 ms
|
||||
# window, a 30 % duty cycle. Converted to the controller's 0.625 ms BLE units in
|
||||
# to_code(). (LN882H's SDK recommends a different 100 / 50 ms = 50 %.)
|
||||
SCAN_PARAMETERS_SCHEMA = ble_device_base.scan_parameters_schema("100ms")
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BK72xxBLETracker),
|
||||
cv.GenerateID(CONF_BK72XX_BLE_ID): cv.use_id(bk72xx_ble.BK72xxBLE),
|
||||
cv.Optional(CONF_SCAN_PARAMETERS, default={}): SCAN_PARAMETERS_SCHEMA,
|
||||
cv.Optional(CONF_ON_BLE_ADVERTISE): ble_automation.advertise_trigger_schema(
|
||||
ESPBTAdvertiseTrigger
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_BLE_SERVICE_DATA_ADVERTISE
|
||||
): ble_automation.uuid_trigger_schema(
|
||||
BLEServiceDataAdvertiseTrigger,
|
||||
{cv.Required(CONF_SERVICE_UUID): ble_device_base.bt_uuid},
|
||||
),
|
||||
cv.Optional(
|
||||
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE
|
||||
): ble_automation.uuid_trigger_schema(
|
||||
BLEManufacturerDataAdvertiseTrigger,
|
||||
{cv.Required(CONF_MANUFACTURER_ID): ble_device_base.bt_uuid},
|
||||
),
|
||||
cv.Optional(CONF_ON_SCAN_END): ble_automation.scan_end_trigger_schema(
|
||||
BLEEndOfScanTrigger
|
||||
),
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"bk72xx_ble_tracker.start_scan",
|
||||
StartScanAction,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BK72xxBLETracker),
|
||||
# Optional with no default, unlike esp32_ble_tracker: omitting it
|
||||
# keeps whatever scan_parameters.continuous configured, instead of
|
||||
# silently forcing one-shot.
|
||||
cv.Optional(CONF_CONTINUOUS): cv.templatable(cv.boolean),
|
||||
}
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def start_scan_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: list,
|
||||
) -> cg.MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
if (continuous := config.get(CONF_CONTINUOUS)) is not None:
|
||||
template_ = await cg.templatable(continuous, args, cg.bool_)
|
||||
cg.add(var.set_continuous(template_))
|
||||
return var
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"bk72xx_ble_tracker.stop_scan",
|
||||
StopScanAction,
|
||||
automation.maybe_simple_id(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(BK72xxBLETracker),
|
||||
}
|
||||
)
|
||||
),
|
||||
synchronous=True,
|
||||
)
|
||||
async def stop_scan_action_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: list,
|
||||
) -> cg.MockObj:
|
||||
var = cg.new_Pvariable(action_id, template_arg)
|
||||
await cg.register_parented(var, config[CONF_ID])
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
# Selects the BLEHub alias arm in ble_device_base/ble_hub_impl.h.
|
||||
cg.add_define("USE_BK72XX_BLE_TRACKER")
|
||||
# Compiles the shared adv + scan-response merge (the BDK delivers the pair
|
||||
# as separate reports).
|
||||
cg.add_define("USE_BLE_SCAN_RESPONSE_MERGER")
|
||||
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
parent = await cg.get_variable(config[CONF_BK72XX_BLE_ID])
|
||||
cg.add(var.set_parent(parent))
|
||||
# The tracker registers itself as a controller scan listener in setup();
|
||||
# request the codegen-sized StaticVector slot for it.
|
||||
bk72xx_ble.request_scan_listener_slot()
|
||||
|
||||
# Get notified when an OTA update starts, to pause scanning (esp32_ble_tracker parity)
|
||||
ota.request_ota_state_listeners()
|
||||
|
||||
scan = config[CONF_SCAN_PARAMETERS]
|
||||
cg.add(var.set_scan_interval(ble_device_base.to_ble_units(scan[CONF_INTERVAL])))
|
||||
cg.add(var.set_scan_window(ble_device_base.to_ble_units(scan[CONF_WINDOW])))
|
||||
cg.add(var.set_scan_duration(scan[CONF_DURATION].total_milliseconds))
|
||||
cg.add(var.set_configured_continuous(scan[CONF_CONTINUOUS]))
|
||||
cg.add(var.set_scan_active(scan[CONF_ACTIVE]))
|
||||
|
||||
for conf in config.get(CONF_ON_BLE_ADVERTISE, []):
|
||||
await ble_automation.advertise_trigger_to_code(conf, var)
|
||||
|
||||
for trigger_key, uuid_key, setter_prefix in (
|
||||
(CONF_ON_BLE_SERVICE_DATA_ADVERTISE, CONF_SERVICE_UUID, "set_service_uuid"),
|
||||
(
|
||||
CONF_ON_BLE_MANUFACTURER_DATA_ADVERTISE,
|
||||
CONF_MANUFACTURER_ID,
|
||||
"set_manufacturer_uuid",
|
||||
),
|
||||
):
|
||||
for conf in config.get(trigger_key, []):
|
||||
await ble_automation.uuid_trigger_to_code(
|
||||
conf, var, uuid_key, setter_prefix
|
||||
)
|
||||
|
||||
for conf in config.get(CONF_ON_SCAN_END, []):
|
||||
await ble_automation.scan_end_trigger_to_code(conf, var)
|
||||
@@ -0,0 +1,48 @@
|
||||
// Automation triggers and actions for bk72xx_ble_tracker: triggers are the
|
||||
// neutral ble_device_base classes; only the scan-control actions are
|
||||
// platform-specific.
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "bk72xx_ble_tracker.h"
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::bk72xx_ble_tracker {
|
||||
|
||||
template<typename... Ts> class StartScanAction final : public Action<Ts...>, public Parented<BK72xxBLETracker> {
|
||||
public:
|
||||
TEMPLATABLE_VALUE(bool, continuous)
|
||||
void play(const Ts &...x) override {
|
||||
// With continuous: set, the action wins. Without it, the configured value
|
||||
// is used - stop_scan() clears the runtime flag permanently, so a bare
|
||||
// stop_scan/start_scan pair would otherwise never resume continuous mode.
|
||||
const bool want =
|
||||
this->continuous_.has_value() ? this->continuous_.value(x...) : this->parent_->configured_continuous();
|
||||
if (this->parent_->scan_running()) {
|
||||
// Same mode on a running scan is a no-op (esp32 parity): re-anchoring
|
||||
// the duration window here would let a repeated action keep a one-shot
|
||||
// scan alive forever. A real mode switch re-anchors so a change to
|
||||
// one-shot runs a full duration from now.
|
||||
if (want != this->parent_->scan_continuous()) {
|
||||
this->parent_->set_scan_continuous(want);
|
||||
this->parent_->restart_scan_duration();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this->parent_->set_scan_continuous(want);
|
||||
this->parent_->start_scan();
|
||||
}
|
||||
};
|
||||
|
||||
template<typename... Ts> class StopScanAction final : public Action<Ts...>, public Parented<BK72xxBLETracker> {
|
||||
public:
|
||||
void play(const Ts &...x) override { this->parent_->stop_scan(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::bk72xx_ble_tracker
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -0,0 +1,378 @@
|
||||
// bk72xx_ble_tracker.cpp
|
||||
//
|
||||
// BLE scan policy for the BK72xx BLE-5.x chips: parameters, duration/period
|
||||
// timers and the rate-limited start retry. All controller access (stack
|
||||
// bring-up, scan primitives, the BLE-task → main-task report queue) goes
|
||||
// through the bk72xx_ble component — no SDK calls and no cross-task state here.
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "bk72xx_ble_tracker.h"
|
||||
|
||||
#include <cinttypes>
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::bk72xx_ble_tracker {
|
||||
|
||||
static const char *const TAG = "bk72xx_ble_tracker";
|
||||
|
||||
// Minimum interval between scan (re)start attempts, so a failing controller start
|
||||
// cannot be retried every main-loop iteration (single-core CPU starvation). The
|
||||
// interval doubles with consecutive failed starts (1 s up to 64 s) so a controller
|
||||
// that never comes up — the controller logs each failure at ERROR — settles into a
|
||||
// slow, quiet poll instead of an error line every second for the rest of uptime;
|
||||
// a single WARN is emitted when the retry interval first saturates.
|
||||
static constexpr uint32_t SCAN_START_RETRY_MS = 1000;
|
||||
static constexpr uint8_t SCAN_START_RETRY_MAX_DOUBLINGS = 6; // 1 s << 6 = 64 s
|
||||
// Stable-run time before the failure streak clears; reset-on-start would keep
|
||||
// a flapping controller at the 1 s gate.
|
||||
static constexpr uint32_t SCAN_STABLE_RESET_MS = 30000;
|
||||
|
||||
// Radio-idle deadline for the bounded stop drain at OTA start.
|
||||
static constexpr uint32_t OTA_STOP_FLUSH_MS = 100;
|
||||
|
||||
// 0.625 ms BLE units; integer math avoids soft-float on this FPU-less part.
|
||||
constexpr uint32_t ble_units_to_ms(uint32_t units) { return units * 5 / 8; }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void BK72xxBLETracker::setup() {
|
||||
// Receive the controller's scan reports; the controller queues them from the
|
||||
// BLE task and delivers here on the main task.
|
||||
this->parent_->register_scan_listener(this);
|
||||
// Merged (and unmerged) frames go to the shared dispatcher; unclaimed
|
||||
// devices are logged only on one-shot scans (continuous would spam).
|
||||
this->merger_.bind(&this->dispatcher_, &this->scan_continuous_, TAG);
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
// Pause scanning while an OTA update is in flight — on the single-core BK72xx the
|
||||
// BLE scan competes with the OTA flash writes. Mirrors esp32_ble_tracker.
|
||||
ota::get_global_ota_callback()->add_global_state_listener(this);
|
||||
#endif
|
||||
// scan_requested_ check: an on_boot start_scan latched before this setup()
|
||||
// must keep the retry loop running (rp2/ln882h parity).
|
||||
if (!this->scan_continuous_ && !this->scan_requested_) {
|
||||
// Nothing to time until an explicit start_scan(); it re-enables the loop.
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
void BK72xxBLETracker::on_ota_global_state(ota::OTAState state, float progress, uint8_t error,
|
||||
ota::OTAComponent *comp) {
|
||||
if (state == ota::OTA_STARTED) {
|
||||
this->scan_continuous_before_ota_ = this->scan_continuous_;
|
||||
this->scan_requested_before_ota_ = this->scan_requested_;
|
||||
this->stop_scan();
|
||||
// The transfer starves the loop; a deferred stop would leave the radio
|
||||
// scanning for the whole update, so drain it here, bounded.
|
||||
if (!this->parent_->flush_pending_stop(OTA_STOP_FLUSH_MS))
|
||||
ESP_LOGE(TAG, "Scan still stopping at OTA start; the radio may contend with the update");
|
||||
} else if (state == ota::OTA_ERROR || state == ota::OTA_ABORT) {
|
||||
// On success the device reboots, so restore only on a failed/aborted update;
|
||||
// loop() restarts the scan on its next iteration (continuous idle branch).
|
||||
if (this->scan_continuous_before_ota_) {
|
||||
this->scan_continuous_before_ota_ = false;
|
||||
this->scan_continuous_ = true;
|
||||
this->enable_loop(); // stop_scan() parked it
|
||||
}
|
||||
// A one-shot request that was still pending (latched, retrying) when the
|
||||
// OTA paused scanning is re-latched, not dropped — loop() resumes the retry.
|
||||
if (this->scan_requested_before_ota_) {
|
||||
this->scan_requested_before_ota_ = false;
|
||||
this->scan_requested_ = true;
|
||||
this->enable_loop();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif // USE_OTA_STATE_LISTENER
|
||||
|
||||
void BK72xxBLETracker::loop() {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
|
||||
// Deliver held scannable advertisements whose scan response never arrived —
|
||||
// unmerged after the merger's timeout.
|
||||
if (!this->merger_.empty())
|
||||
this->merger_.sweep(now);
|
||||
|
||||
// Before the drop branch: a drop after a stable run starts a fresh streak.
|
||||
if (this->scan_running_ && this->failed_start_count_ != 0 && now - this->scan_start_time_ >= SCAN_STABLE_RESET_MS)
|
||||
this->failed_start_count_ = 0;
|
||||
|
||||
// A terminal failure while we report running recovers via the normal retry
|
||||
// path; the drop charges the backoff so a flapping controller escalates.
|
||||
if (this->scan_running_ && this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::FAILED) {
|
||||
ESP_LOGW(TAG, "Controller scan lost; retrying");
|
||||
this->scan_requested_ = true;
|
||||
this->count_failed_start_();
|
||||
this->mark_scan_ended_(now);
|
||||
}
|
||||
|
||||
if (this->scan_continuous_) {
|
||||
if (!this->scan_running_) {
|
||||
// One-iteration deferral; all stamps share this iteration's cached
|
||||
// timestamp, so the period check below cannot underflow.
|
||||
if (this->try_start_with_backoff_(now))
|
||||
return;
|
||||
}
|
||||
// Period timer: fire on_scan_end() once per scan_duration_ window, mirroring
|
||||
// esp32_ble_tracker::cleanup_scan_state_(). Gated on scan_started_once_ so a scan
|
||||
// that never came up (start kept failing) does not fire spurious on_scan_end events.
|
||||
if (this->scan_started_once_ && now - this->scan_period_start_ >= this->scan_duration_) {
|
||||
this->fire_scan_end_();
|
||||
this->scan_period_start_ = now;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-continuous mode: run for scan_duration_ ms, then stop and fire on_scan_end.
|
||||
// Restart is driven externally (e.g. api: on_client_connected:).
|
||||
//
|
||||
// A requested start that failed (same controller failures the continuous branch
|
||||
// absorbs) is retried with the same backoff — otherwise a failed one-shot start
|
||||
// would be silent: the scan never runs, stop_scan_() is never reached and
|
||||
// on_scan_end() never fires, leaving period-keyed consumers waiting forever.
|
||||
if (this->scan_requested_ && !this->scan_running_) {
|
||||
// Same one-iteration deferral as the continuous branch.
|
||||
if (this->try_start_with_backoff_(now))
|
||||
return;
|
||||
}
|
||||
if (this->scan_running_ && now - this->scan_start_time_ >= this->scan_duration_) {
|
||||
// A full-duration run proves the controller healthy even when duration is
|
||||
// shorter than SCAN_STABLE_RESET_MS.
|
||||
this->failed_start_count_ = 0;
|
||||
this->stop_scan_();
|
||||
}
|
||||
}
|
||||
|
||||
bool BK72xxBLETracker::try_start_with_backoff_(uint32_t now, bool force) {
|
||||
// Rate-limit (re)start attempts. The controller start can fail (no idle activity
|
||||
// handle, WiFi/BLE coexistence) and leave scan_running_ false; retrying every
|
||||
// main-loop iteration would spin the single-core CPU and starve WiFi (device
|
||||
// becomes unresponsive). The interval backs off with consecutive failures so a
|
||||
// controller that never comes up polls slowly and quietly.
|
||||
//
|
||||
// force bypasses the gate for an explicit user start (start_scan()) — but
|
||||
// only while the failure streak is clean. Once the controller is failing,
|
||||
// even user-initiated attempts respect the backoff, so a start_scan() action
|
||||
// on a short cadence cannot hammer a failing controller; the attempt stays
|
||||
// inside the failure accounting below either way.
|
||||
// Mid bring-up, observe instead of re-issuing (the hub self-advances). A
|
||||
// SETTLED outcome completes immediately; only fresh attempts after FAILED
|
||||
// are rate-limited.
|
||||
const auto hub = this->parent_->last_scan_result();
|
||||
if (hub == bk72xx_ble::ScanOpResult::PENDING)
|
||||
return false;
|
||||
if (hub == bk72xx_ble::ScanOpResult::FAILED) {
|
||||
if (this->start_attempt_open_) {
|
||||
// Our bring-up gave up asynchronously; charge it to the backoff.
|
||||
this->start_attempt_open_ = false;
|
||||
this->count_failed_start_();
|
||||
}
|
||||
if ((!force || this->failed_start_count_ != 0) &&
|
||||
now - this->last_scan_start_attempt_ < (SCAN_START_RETRY_MS << this->failed_start_count_))
|
||||
return false;
|
||||
}
|
||||
this->start_scan_();
|
||||
if (!this->scan_running_) {
|
||||
if (this->parent_->last_scan_result() == bk72xx_ble::ScanOpResult::PENDING) {
|
||||
this->start_attempt_open_ = true;
|
||||
return false; // the controller is still bringing the scan up; not a failure
|
||||
}
|
||||
this->count_failed_start_();
|
||||
}
|
||||
return this->scan_running_;
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::count_failed_start_() {
|
||||
if (this->failed_start_count_ < SCAN_START_RETRY_MAX_DOUBLINGS) {
|
||||
++this->failed_start_count_;
|
||||
if (this->failed_start_count_ == SCAN_START_RETRY_MAX_DOUBLINGS) {
|
||||
ESP_LOGW(TAG, "Scan start keeps failing; retrying every %" PRIu32 " s",
|
||||
(SCAN_START_RETRY_MS << SCAN_START_RETRY_MAX_DOUBLINGS) / 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BK72xx BLE Tracker:\n"
|
||||
" Scan Duration: %" PRIu32 " s\n"
|
||||
" Scan Interval: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
|
||||
" Scan Window: %" PRIu32 " ms (%" PRIu32 " BLE units)\n"
|
||||
" Scan Type: %s (configured %s)\n"
|
||||
" Continuous Scanning: %s",
|
||||
this->scan_duration_ / 1000, ble_units_to_ms(this->scan_interval_), this->scan_interval_,
|
||||
ble_units_to_ms(this->scan_window_), this->scan_window_, this->scan_active_ ? "ACTIVE" : "PASSIVE",
|
||||
this->scan_active_configured_ ? "ACTIVE" : "PASSIVE", YESNO(this->scan_continuous_));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scan report — delivered by the controller's loop() on the ESPHome main task
|
||||
// (the controller queues reports from the BLE task), so publish_state() and
|
||||
// listener dispatch run in main-loop context with no cross-task handling here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// GAPM report info byte (BLEScanReport::evt_type): bits 0-2 report type,
|
||||
// bit 5 scannable advertisement. Verified against both BDK stacks (5.1 and
|
||||
// 5.2 fill it from gapm_ext_adv_report_ind.info).
|
||||
static constexpr uint8_t GAPM_REPORT_TYPE_MASK = 0x07;
|
||||
static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_EXT = 2;
|
||||
static constexpr uint8_t GAPM_REPORT_TYPE_SCAN_RSP_LEG = 3;
|
||||
static constexpr uint8_t GAPM_REPORT_INFO_SCAN_ADV_BIT = 1 << 5;
|
||||
|
||||
// Demux advertisements vs scan responses into the shared merger: the BDK
|
||||
// delivers the pair as separate reports; a scannable advertisement is held
|
||||
// until its scan response arrives and delivered as one merged frame.
|
||||
void BK72xxBLETracker::on_scan_report(const bk72xx_ble::BLEScanReport &report) {
|
||||
const uint8_t rtype = report.evt_type & GAPM_REPORT_TYPE_MASK;
|
||||
if (rtype == GAPM_REPORT_TYPE_SCAN_RSP_LEG || rtype == GAPM_REPORT_TYPE_SCAN_RSP_EXT) {
|
||||
this->merger_.submit_scan_rsp(report.mac, report.rssi, report.addr_type, report.data, report.data_len);
|
||||
return;
|
||||
}
|
||||
// Stash only while an active scan runs: a passive scan never gets a
|
||||
// response, and after a stop nothing would sweep the merger, so a late
|
||||
// report would surface minutes later as a fresh advertisement.
|
||||
if (this->scan_running_ && this->scan_active_ && (report.evt_type & GAPM_REPORT_INFO_SCAN_ADV_BIT)) {
|
||||
this->merger_.stash_adv(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
|
||||
App.get_loop_component_start_time());
|
||||
return;
|
||||
}
|
||||
this->dispatcher_.dispatch(report.mac, report.rssi, report.addr_type, report.data, report.data_len,
|
||||
/*raw_only=*/false, this->scan_continuous_ ? nullptr : TAG);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public scan control
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void BK72xxBLETracker::start_scan() {
|
||||
// Mirrors esp32_ble_tracker::start_scan(): caller sets scan_continuous_ via
|
||||
// set_scan_continuous() first, then calls start_scan() to begin scanning.
|
||||
//
|
||||
// Nothing to do while a scan is already running: latching here would leave
|
||||
// scan_requested_ set after that scan ends and silently restart a one-shot
|
||||
// scan nobody asked for.
|
||||
if (this->scan_running_)
|
||||
return;
|
||||
|
||||
// The request is latched: if this immediate attempt fails (controller busy,
|
||||
// WiFi/BLE coexistence), loop() keeps retrying it with backoff even in
|
||||
// non-continuous mode, so a one-shot start cannot fail silently.
|
||||
//
|
||||
// Routed through the backoff helper (forced: the user asked for an immediate
|
||||
// attempt) so a failure here still counts toward the backoff escalation and
|
||||
// its WARN. The force bypass only applies while the failure streak is clean —
|
||||
// against a failing controller, repeated start_scan() calls are rate-limited
|
||||
// like any other attempt.
|
||||
this->scan_requested_ = true;
|
||||
this->enable_loop(); // an idle one-shot tracker parked it in stop_scan_()
|
||||
this->try_start_with_backoff_(App.get_loop_component_start_time(), /* force= */ true);
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::restart_scan_duration() {
|
||||
if (!this->scan_running_)
|
||||
return;
|
||||
// Re-anchor only the one-shot duration clock. scan_period_start_ (the
|
||||
// continuous-mode on_scan_end period) is deliberately left alone: a
|
||||
// start_scan action fired more often than scan_duration_ would otherwise
|
||||
// suppress on_scan_end indefinitely — and absence detection (ble_rssi's NAN
|
||||
// publish) rides on that period.
|
||||
this->scan_start_time_ = App.get_loop_component_start_time();
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::stop_scan() {
|
||||
this->scan_continuous_ = false;
|
||||
this->scan_requested_ = false; // also cancels a pending (not yet successful) start
|
||||
this->stop_scan_();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal scan start / stop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bk72xx_ble::ScanOpResult BK72xxBLETracker::controller_scan_start_() {
|
||||
this->last_scan_start_attempt_ = App.get_loop_component_start_time();
|
||||
return this->parent_->scan_start(static_cast<uint16_t>(this->scan_interval_),
|
||||
static_cast<uint16_t>(this->scan_window_), this->scan_active_);
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::start_scan_() {
|
||||
if (this->scan_running_)
|
||||
return;
|
||||
|
||||
if (this->controller_scan_start_() != bk72xx_ble::ScanOpResult::SETTLED)
|
||||
return;
|
||||
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
this->scan_running_ = true;
|
||||
this->scan_requested_ = false; // the latched one-shot request is satisfied
|
||||
this->start_attempt_open_ = false;
|
||||
// failed_start_count_ deliberately not reset here; only a stable run clears it (loop()).
|
||||
this->scan_start_time_ = now;
|
||||
// Log every explicit start at DEBUG — stop_scan_() logs every stop at DEBUG, and
|
||||
// in non-continuous mode each period is an explicit start, so asymmetric logging
|
||||
// would read as the scanner failing to come back up.
|
||||
ESP_LOGD(TAG, "Scan started (%s, window=%" PRIu32 "ms, interval=%" PRIu32 "ms)",
|
||||
this->scan_active_ ? "active" : "passive", ble_units_to_ms(this->scan_window_),
|
||||
ble_units_to_ms(this->scan_interval_));
|
||||
// Re-anchor the on_scan_end period to every successful start — first start (so the
|
||||
// period counts from the scan, not from boot) and every restart after a stop (so
|
||||
// resuming after longer than scan_duration, e.g. a failed OTA restoring continuous
|
||||
// mode 10 minutes later, does not fire on_scan_end before an advertisement can
|
||||
// arrive). scan_started_once_ purely gates the period timer.
|
||||
this->scan_period_start_ = now;
|
||||
this->scan_started_once_ = true;
|
||||
}
|
||||
|
||||
// Deliberate logical/physical split: on_scan_end() reports the tracker's
|
||||
// intent while the hub winds the radio down asynchronously; OTA is the one
|
||||
// path that must wait, and it flushes explicitly.
|
||||
void BK72xxBLETracker::stop_scan_() {
|
||||
this->start_attempt_open_ = false; // an abandoned bring-up is not charged
|
||||
this->parent_->scan_stop(); // idempotent: releases whatever the hub holds
|
||||
if (this->scan_running_) {
|
||||
ESP_LOGD(TAG, "Scan stopped");
|
||||
this->mark_scan_ended_(App.get_loop_component_start_time());
|
||||
}
|
||||
// Park when idle (the hub drives its own teardown); re-check because an
|
||||
// on_scan_end automation may have restarted the scan.
|
||||
if (!this->scan_continuous_ && !this->scan_running_ && !this->scan_requested_)
|
||||
this->disable_loop();
|
||||
}
|
||||
|
||||
// The period re-anchor keeps on_scan_end from double-firing in one iteration.
|
||||
void BK72xxBLETracker::mark_scan_ended_(uint32_t now) {
|
||||
this->scan_running_ = false;
|
||||
this->fire_scan_end_();
|
||||
this->scan_period_start_ = now;
|
||||
}
|
||||
|
||||
void BK72xxBLETracker::fire_scan_end_() {
|
||||
// Deliver held advertisements whose scan response never came (unmerged)
|
||||
// BEFORE on_scan_end fires.
|
||||
this->merger_.flush();
|
||||
this->dispatcher_.on_scan_end();
|
||||
}
|
||||
|
||||
// true = request latched, not applied: the reconciler applies it
|
||||
// asynchronously and loop() recovers a failed re-arm (ln882h parity).
|
||||
bool BK72xxBLETracker::request_scan_mode(bool active) {
|
||||
if (this->scan_active_ == active)
|
||||
return true;
|
||||
this->scan_active_ = active;
|
||||
// V: the proxy's "Setting scanner mode" line already narrates this at D.
|
||||
ESP_LOGV(TAG, "Scan mode %s", active ? "active" : "passive");
|
||||
// The controller reconciler restarts a running scan itself; the scan stays
|
||||
// logically running. An idle scanner picks the mode up on its next start.
|
||||
if (this->scan_running_)
|
||||
this->controller_scan_start_();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace esphome::bk72xx_ble_tracker
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -0,0 +1,182 @@
|
||||
// bk72xx_ble_tracker.h
|
||||
//
|
||||
// ESPHome BLE scanner for the BK72xx BLE-5.x chips (LibreTiny beken-72xx family).
|
||||
// Implements the platform-neutral ble_device_base::BLEHub contract on top of the
|
||||
// bk72xx_ble controller component: parsed ESPBTDevice objects go to registered
|
||||
// listeners (bthome_mithermometer, ble_presence, …) and every raw frame to the
|
||||
// hub's raw-advertisement callback.
|
||||
//
|
||||
// This component contains no Beken SDK calls and no cross-task state: the
|
||||
// controller (stack bring-up, BLE address, scan primitives, and the BLE-task →
|
||||
// main-task report queue) is owned by bk72xx_ble, which delivers every scan
|
||||
// report on the ESPHome main task. The tracker owns scan policy — parameters,
|
||||
// duration/period timers and the rate-limited start retry.
|
||||
//
|
||||
// YAML config (values shown are the defaults; interval/window are a 30 % duty
|
||||
// cycle, the BK reference scan rate):
|
||||
//
|
||||
// bk72xx_ble_tracker:
|
||||
// scan_parameters:
|
||||
// interval: 100ms
|
||||
// window: 30ms
|
||||
// duration: 5min
|
||||
// continuous: true
|
||||
// active: true
|
||||
|
||||
#pragma once
|
||||
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "esphome/components/bk72xx_ble/bk72xx_ble.h"
|
||||
#include "esphome/components/ble_device_base/ble_device.h"
|
||||
#include "esphome/components/ble_device_base/ble_hub.h"
|
||||
#include "esphome/components/ble_device_base/scan_response_merger.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <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 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; }
|
||||
/// Set from YAML (scan_parameters.active); runtime mode requests change
|
||||
/// only the resolved mode.
|
||||
void set_scan_active(bool scan_active) {
|
||||
this->scan_active_ = scan_active;
|
||||
this->scan_active_configured_ = scan_active;
|
||||
}
|
||||
/// Set from YAML (scan_parameters.continuous); also the value
|
||||
/// configured_continuous() reports and a bare start_scan action restores.
|
||||
void set_configured_continuous(bool scan_continuous) {
|
||||
this->scan_continuous_ = scan_continuous;
|
||||
this->scan_continuous_configured_ = scan_continuous;
|
||||
}
|
||||
/// Runtime control (esp32_ble_tracker lambda parity): does not change the
|
||||
/// configured value, so configured_continuous() still reports what YAML
|
||||
/// asked for.
|
||||
void set_scan_continuous(bool scan_continuous) { this->scan_continuous_ = scan_continuous; }
|
||||
bool scan_continuous() const { return this->scan_continuous_; }
|
||||
bool configured_continuous() const { return this->scan_continuous_configured_; }
|
||||
/// Re-anchor the one-shot duration clock of a running scan to now — used
|
||||
/// when an action changes the scan mode without stopping the radio. The
|
||||
/// continuous-mode on_scan_end period is deliberately not touched.
|
||||
void restart_scan_duration();
|
||||
|
||||
// ---- Public scan control ----
|
||||
// Mirrors esp32_ble_tracker: set_scan_continuous() + start_scan() / stop_scan().
|
||||
void start_scan();
|
||||
void stop_scan();
|
||||
|
||||
// ---- ble_device_base::BLEHub contract ----
|
||||
void register_listener(ble_device_base::ESPBTDeviceListener *listener) {
|
||||
this->dispatcher_.register_listener(listener);
|
||||
}
|
||||
void set_raw_advertisement_callback(ble_device_base::RawAdvertisementCallback callback) {
|
||||
this->dispatcher_.set_raw_advertisement_callback(callback);
|
||||
}
|
||||
static constexpr ble_device_base::HubCapabilities get_capabilities() {
|
||||
// Active scanning is driven through bk72xx_ble's reconciler because the BDK
|
||||
// API itself is passive-only. The controller delivers scan responses as
|
||||
// separate reports; this tracker merges the pair before delivery (shared
|
||||
// ScanResponseMerger, Bluedroid semantics). No GATT client.
|
||||
return {.active_scan = true, .merges_scan_response = true, .gatt = false, .scan_mode_switch = true};
|
||||
}
|
||||
bool request_scan_mode(bool active);
|
||||
// The controller stores the address LSB-first (BLE convention); the contract
|
||||
// wants printable (MSB-first) order.
|
||||
void get_adapter_mac(uint8_t out[MAC_ADDRESS_SIZE]) {
|
||||
uint8_t mac[MAC_ADDRESS_SIZE];
|
||||
this->parent_->get_mac_lsb_first(mac);
|
||||
for (int i = 0; i < 6; i++)
|
||||
out[i] = mac[5 - i];
|
||||
}
|
||||
bool scan_running() { return this->scan_running_; }
|
||||
bool scan_active() { return this->scan_active_; }
|
||||
|
||||
// ---- bk72xx_ble::BLEScanListener ----
|
||||
// Delivered by the controller's loop() on the ESPHome main task — the
|
||||
// BLE-task → main-task handoff already happened in the controller's queue.
|
||||
void on_scan_report(const bk72xx_ble::BLEScanReport &report) override;
|
||||
|
||||
protected:
|
||||
void start_scan_();
|
||||
void stop_scan_();
|
||||
void fire_scan_end_();
|
||||
void mark_scan_ended_(uint32_t now);
|
||||
/// Stamp-and-start for every controller scan attempt, so the retry rate
|
||||
/// limit covers all callers.
|
||||
bk72xx_ble::ScanOpResult controller_scan_start_();
|
||||
/// Rate-limited (re)start; true when the scan is running (the caller must
|
||||
/// not reuse a `now` older than the stamps this refreshed). Force and
|
||||
/// backoff rules are documented at the definition.
|
||||
bool try_start_with_backoff_(uint32_t now, bool force = false);
|
||||
void count_failed_start_();
|
||||
|
||||
bool scan_running_{false};
|
||||
bool scan_requested_{false}; // latched start_scan() request not yet running; loop() retries with backoff
|
||||
bool start_attempt_open_{false}; // charge a later FAILED observation to the backoff exactly once
|
||||
// Defaults: the BK reference — 30 % duty cycle
|
||||
// (interval 100 ms / window 30 ms), in 0.625 ms BLE units.
|
||||
uint32_t scan_interval_{160}; // 160 × 0.625 ms = 100 ms
|
||||
uint32_t scan_window_{48}; // 48 × 0.625 ms = 30 ms (30/100 = 30 %)
|
||||
uint32_t scan_duration_{300000};
|
||||
bool scan_continuous_{true};
|
||||
bool scan_continuous_configured_{true}; // YAML value; stop_scan() must not lose it
|
||||
bool scan_active_{true}; // resolved mode; see scan_parameters.active
|
||||
bool scan_active_configured_{true}; // YAML value; runtime requests must not lose it
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
bool scan_continuous_before_ota_{false}; // continuous mode saved at OTA start, restored on OTA failure
|
||||
bool scan_requested_before_ota_{false}; // pending one-shot latch saved at OTA start, re-latched on OTA failure
|
||||
#endif
|
||||
uint32_t scan_start_time_{0};
|
||||
|
||||
uint32_t last_scan_start_attempt_{0}; // last controller start attempt, any caller; rate-limits retries
|
||||
uint8_t failed_start_count_{0}; // failed starts AND drops; backoff shift, cleared after a stable run (loop())
|
||||
uint32_t scan_period_start_{0}; // loop-clock start of the scan period; rate-limits on_scan_end()
|
||||
bool scan_started_once_{false}; // true after first successful scan start; gates the period timer
|
||||
|
||||
// Shared adv + scan-response merge and frame dispatch (ble_device_base).
|
||||
// All calls run on the main task (the controller queue already crossed
|
||||
// tasks). Merger clock: stash_adv() reads the PARENT's cached loop time
|
||||
// (on_scan_report runs inside bk72xx_ble's queue drain), sweep() this
|
||||
// component's — same App.loop() pass, so the delta stays non-negative and
|
||||
// the 300 ms timeout holds.
|
||||
ble_device_base::ScanResponseMerger merger_;
|
||||
ble_device_base::AdvDispatcher dispatcher_;
|
||||
};
|
||||
|
||||
} // namespace esphome::bk72xx_ble_tracker
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -9,6 +9,7 @@ from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_MAC_ADDRESS,
|
||||
CONF_NAME,
|
||||
CONF_NOTIFY,
|
||||
CONF_ON_CONNECT,
|
||||
CONF_ON_DISCONNECT,
|
||||
CONF_SERVICE_UUID,
|
||||
@@ -16,11 +17,46 @@ from esphome.const import (
|
||||
CONF_VALUE,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
AUTO_LOAD = ["esp32_ble_client"]
|
||||
CODEOWNERS = ["@buxtronix", "@clydebarrow"]
|
||||
DEPENDENCIES = ["esp32_ble_tracker"]
|
||||
|
||||
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
|
||||
CONF_ON_NOTIFY = "on_notify"
|
||||
|
||||
|
||||
def validate_descriptor_not_notify(config: ConfigType) -> ConfigType:
|
||||
"""Reject descriptor_uuid combined with notify or on_notify.
|
||||
|
||||
BLE descriptors cannot send notifications; only characteristics can, and
|
||||
ESP-IDF has no descriptor variant of esp_ble_gattc_register_for_notify.
|
||||
"""
|
||||
if CONF_DESCRIPTOR_UUID in config and (
|
||||
config.get(CONF_NOTIFY) or CONF_ON_NOTIFY in config
|
||||
):
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_DESCRIPTOR_UUID}' cannot be used with '{CONF_NOTIFY}' or "
|
||||
f"'{CONF_ON_NOTIFY}': BLE descriptors cannot send notifications; remove "
|
||||
f"'{CONF_DESCRIPTOR_UUID}' to receive characteristic notifications, or "
|
||||
f"remove '{CONF_NOTIFY}' and '{CONF_ON_NOTIFY}' to poll the descriptor"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def notify_from_on_notify(config: ConfigType) -> ConfigType:
|
||||
"""Enable notifications when an on_notify automation is configured.
|
||||
|
||||
The triggers have no registration path of their own; without notify the
|
||||
automation would validate but never fire.
|
||||
"""
|
||||
if CONF_ON_NOTIFY in config and not config[CONF_NOTIFY]:
|
||||
config = config.copy()
|
||||
config[CONF_NOTIFY] = True
|
||||
return config
|
||||
|
||||
|
||||
ble_client_ns = cg.esphome_ns.namespace("ble_client")
|
||||
BLEClient = ble_client_ns.class_("BLEClient", esp32_ble_client.BLEClientBase)
|
||||
BLEClientNode = ble_client_ns.class_("BLEClientNode")
|
||||
|
||||
@@ -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_; }
|
||||
|
||||
@@ -14,13 +14,16 @@ from esphome.const import (
|
||||
UNIT_DECIBEL_MILLIWATT,
|
||||
)
|
||||
|
||||
from .. import ble_client_ns
|
||||
from .. import (
|
||||
CONF_DESCRIPTOR_UUID,
|
||||
CONF_ON_NOTIFY,
|
||||
ble_client_ns,
|
||||
notify_from_on_notify,
|
||||
validate_descriptor_not_notify,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
|
||||
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
|
||||
|
||||
CONF_ON_NOTIFY = "on_notify"
|
||||
TYPE_CHARACTERISTIC = "characteristic"
|
||||
TYPE_RSSI = "rssi"
|
||||
|
||||
@@ -85,6 +88,8 @@ CONFIG_SCHEMA = cv.All(
|
||||
},
|
||||
lower=True,
|
||||
),
|
||||
validate_descriptor_not_notify,
|
||||
notify_from_on_notify,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
break;
|
||||
}
|
||||
this->handle = chr->handle;
|
||||
if (this->descr_uuid_.get_uuid().len > 0) {
|
||||
if (this->descr_uuid_.is_set()) {
|
||||
auto *descr = chr->get_descriptor(this->descr_uuid_);
|
||||
if (descr == nullptr) {
|
||||
this->status_set_warning();
|
||||
@@ -77,8 +77,7 @@ void BLESensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t ga
|
||||
this->handle = descr->handle;
|
||||
}
|
||||
if (this->notify_) {
|
||||
auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(),
|
||||
this->parent()->get_remote_bda(), chr->handle);
|
||||
auto status = this->parent()->register_for_notify(chr->handle);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status);
|
||||
}
|
||||
|
||||
@@ -9,13 +9,16 @@ from esphome.const import (
|
||||
CONF_TRIGGER_ID,
|
||||
)
|
||||
|
||||
from .. import ble_client_ns
|
||||
from .. import (
|
||||
CONF_DESCRIPTOR_UUID,
|
||||
CONF_ON_NOTIFY,
|
||||
ble_client_ns,
|
||||
notify_from_on_notify,
|
||||
validate_descriptor_not_notify,
|
||||
)
|
||||
|
||||
DEPENDENCIES = ["ble_client"]
|
||||
|
||||
CONF_DESCRIPTOR_UUID = "descriptor_uuid"
|
||||
|
||||
CONF_ON_NOTIFY = "on_notify"
|
||||
|
||||
adv_data_t = cg.std_vector.template(cg.uint8)
|
||||
adv_data_t_const_ref = adv_data_t.operator("ref").operator("const")
|
||||
@@ -48,7 +51,9 @@ CONFIG_SCHEMA = cv.All(
|
||||
}
|
||||
)
|
||||
.extend(cv.polling_component_schema("60s"))
|
||||
.extend(ble_client.BLE_CLIENT_SCHEMA)
|
||||
.extend(ble_client.BLE_CLIENT_SCHEMA),
|
||||
validate_descriptor_not_notify,
|
||||
notify_from_on_notify,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
break;
|
||||
}
|
||||
this->handle = chr->handle;
|
||||
if (this->descr_uuid_.get_uuid().len > 0) {
|
||||
if (this->descr_uuid_.is_set()) {
|
||||
auto *descr = chr->get_descriptor(this->descr_uuid_);
|
||||
if (descr == nullptr) {
|
||||
this->status_set_warning();
|
||||
@@ -77,8 +77,7 @@ void BLETextSensor::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
|
||||
this->handle = descr->handle;
|
||||
}
|
||||
if (this->notify_) {
|
||||
auto status = esp_ble_gattc_register_for_notify(this->parent()->get_gattc_if(),
|
||||
this->parent()->get_remote_bda(), chr->handle);
|
||||
auto status = this->parent()->register_for_notify(chr->handle);
|
||||
if (status) {
|
||||
ESP_LOGW(TAG, "esp_ble_gattc_register_for_notify failed, status=%d", status);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"""
|
||||
ble_device_base — the platform-neutral BLE layer.
|
||||
|
||||
Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData /
|
||||
ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract
|
||||
(BLEHub, in ble_hub.h; C++-side a per-platform alias bound in ble_hub_impl.h)
|
||||
on every platform.
|
||||
|
||||
BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the
|
||||
configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared
|
||||
subclass, so there is no Python platform table here and no dependency in
|
||||
either direction (C++-side, the compile-time alias header ble_hub_impl.h and the
|
||||
defines.h mirror are the deliberate exceptions). A sensor extends
|
||||
BLE_DEVICE_SCHEMA in its CONFIG_SCHEMA (so an explicit ble_hub_id: is a
|
||||
declared key even on strict schemas) and calls register_ble_device() in
|
||||
to_code; a tracker component declares BLEHub as its codegen-class parent and
|
||||
MUST call register_hub_provider() at import time — without it _require_hub
|
||||
rejects configs that bind through the generated id (an explicit ble_hub_id:
|
||||
bypasses the registry). Adding a new BLE chip requires a new in-tree tracker
|
||||
component plus its alias arm and define (see above); out-of-tree BLE hubs
|
||||
are not supported.
|
||||
|
||||
AES-CCM decryption for encrypted advertisements is provided portably in
|
||||
ble_aes_ccm.h.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
import re
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_WINDOW
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_ACTIVE,
|
||||
CONF_CONTINUOUS,
|
||||
CONF_DURATION,
|
||||
CONF_INTERVAL,
|
||||
KEY_TARGET_PLATFORM,
|
||||
)
|
||||
from esphome.core import CORE, ID, KEY_CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
|
||||
CONF_BLE_HUB_ID = "ble_hub_id"
|
||||
|
||||
# Number of parsed-advertisement listeners registered in this build; read via
|
||||
# cg.get_slot_count() by esp32_ble_tracker's feature coupling.
|
||||
LISTENER_COUNT_DEFINE = "ESPHOME_BLE_DEVICE_BASE_LISTENER_COUNT"
|
||||
|
||||
|
||||
ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base")
|
||||
|
||||
# The neutral tracker contract. Every tracker's codegen class declares this as
|
||||
# a parent, which is what lets cv.use_id(BLEHub) resolve any of them. Python
|
||||
# only: C++-side the name is a per-platform alias (ble_hub_impl.h).
|
||||
BLEHub = ble_device_base_ns.class_("BLEHub")
|
||||
|
||||
# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener).
|
||||
ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener")
|
||||
|
||||
|
||||
# Config keys that provide a BLEHub, registered by each tracker component at
|
||||
# import time (a tracker's module is imported iff it can end up in the build).
|
||||
# Used only to phrase an actionable error when a BLE consumer is configured
|
||||
# without any tracker — the binding itself resolves any BLEHub subclass and
|
||||
# needs no platform table. Out-of-tree BLE hubs are not supported; the
|
||||
# registry and the messages below deal in in-tree trackers only.
|
||||
_HUB_PROVIDERS: set[str] = set()
|
||||
|
||||
# The in-tree trackers per target platform, so the missing-tracker error names
|
||||
# them even in a fresh process where no tracker module has been imported yet (a
|
||||
# consumer imports only ble_device_base, so the registry is empty exactly in
|
||||
# the most common failure: the tracker was simply forgotten). Filtered by the
|
||||
# current platform so an esp32 config is not told to add a Beken tracker; an
|
||||
# unknown/absent platform falls back to every in-tree name.
|
||||
_IN_TREE_HUB_PROVIDERS: dict[str, str] = {
|
||||
"esp32": "esp32_ble_tracker",
|
||||
"bk72xx": "bk72xx_ble_tracker",
|
||||
"rp2": "rp2_ble_tracker",
|
||||
"ln882x": "ln882h_ble_tracker",
|
||||
}
|
||||
|
||||
|
||||
def register_hub_provider(component: str) -> None:
|
||||
"""Called at import time by every component whose config key declares a BLEHub."""
|
||||
_HUB_PROVIDERS.add(component)
|
||||
|
||||
|
||||
def _require_hub(value: ID) -> ID:
|
||||
# Without this check a missing tracker surfaces at ID resolution as
|
||||
# "Couldn't find any component that can be used for 'ble_device_base::BLEHub'"
|
||||
# — a C++ class name the user never types. Component final validation cannot
|
||||
# phrase it better: the ID pass runs first and its error skips all later
|
||||
# steps. All explicitly configured components are loaded before any schema
|
||||
# validates, so a registered provider in loaded_integrations is exact here.
|
||||
if value.id is not None:
|
||||
# Explicit ble_hub_id: — the user is pointing at a specific hub (the
|
||||
# multi-hub disambiguation case). Let the ID pass judge it; its error
|
||||
# names the missing id, which is accurate.
|
||||
return value
|
||||
if not _HUB_PROVIDERS & CORE.loaded_integrations:
|
||||
# Defensive lookup rather than CORE.target_platform: the property
|
||||
# raises when no platform is registered, and this message must never
|
||||
# be the thing that crashes. In a real run the platform is always set
|
||||
# (LoadTargetPlatformValidationStep runs before any other domain), so
|
||||
# the unfiltered all-platforms fallback is reachable only from tests.
|
||||
platform = CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM)
|
||||
if platform is not None and platform not in _IN_TREE_HUB_PROVIDERS:
|
||||
# Known platform with no in-tree hub (esp8266, host, rtl87xx, …):
|
||||
# listing the other platforms' trackers would misdirect, and
|
||||
# out-of-tree BLE hubs are not supported.
|
||||
raise cv.Invalid(
|
||||
f"No BLE tracker exists for {platform}; BLE components are "
|
||||
"not supported on this platform"
|
||||
)
|
||||
in_tree = (
|
||||
{tracker}
|
||||
if (tracker := _IN_TREE_HUB_PROVIDERS.get(platform))
|
||||
else set(_IN_TREE_HUB_PROVIDERS.values())
|
||||
)
|
||||
# in_tree only: _HUB_PROVIDERS is import-time state that outlives
|
||||
# CORE.reset() in a long-lived process (dashboard), so a tracker from
|
||||
# an earlier build of another platform must not leak into the message.
|
||||
# The gate above is immune — loaded_integrations resets per run.
|
||||
names = ", ".join(sorted(in_tree))
|
||||
raise cv.Invalid(f"No BLE tracker configured — add one of: {names}")
|
||||
return value
|
||||
|
||||
|
||||
# Schema fragment binding a consumer to the configured BLE tracker: extend a
|
||||
# consumer's CONFIG_SCHEMA with this so ble_hub_id: is a declared key — a
|
||||
# trailing validator after a PREVENT_EXTRA schema would reject the explicit
|
||||
# form before ever running. An omitted id resolves to the single declared
|
||||
# tracker on any platform; multiple trackers are disambiguated with an
|
||||
# explicit ble_hub_id.
|
||||
BLE_DEVICE_SCHEMA = cv.Schema(
|
||||
{cv.GenerateID(CONF_BLE_HUB_ID): cv.All(cv.use_id(BLEHub), _require_hub)}
|
||||
)
|
||||
|
||||
|
||||
def rename_legacy_hub_id(component: str) -> Callable[[ConfigType], ConfigType]:
|
||||
"""Transitional alias for the pre-migration binding key: esp32_ble_id ->
|
||||
ble_hub_id. Warns and auto-migrates until removal; every migrated platform
|
||||
prepends this to its CONFIG_SCHEMA so existing configs keep validating."""
|
||||
return cv.rename_key(
|
||||
"esp32_ble_id", CONF_BLE_HUB_ID, removed_in="2027.2.0", component=component
|
||||
)
|
||||
|
||||
|
||||
def request_irk_support() -> None:
|
||||
"""Compile in resolve_irk()'s software-AES path. Called by sensors with an
|
||||
irk: option so builds without IRK do not carry the resolution code."""
|
||||
cg.add_define("USE_BLE_DEVICE_IRK")
|
||||
|
||||
|
||||
# Number of GATT client connection slots in this build; sizes the platform
|
||||
# backend's connection storage.
|
||||
GATT_CLIENT_COUNT_DEFINE = "ESPHOME_BLE_GATT_CLIENT_COUNT"
|
||||
|
||||
_request_gatt_connection_slot = cg.slot_counter(GATT_CLIENT_COUNT_DEFINE)
|
||||
|
||||
|
||||
def request_gatt_client() -> None:
|
||||
"""Compile in the neutral GATT client contract (ble_gatt_client.h) and
|
||||
claim one compiled-in client slot (sizes ESPHOME_BLE_GATT_CLIENT_COUNT;
|
||||
distinct from the proxy's validated connection budget). Called by
|
||||
bluetooth_connection.new_gatt_backend() once per backend instance."""
|
||||
cg.add_define("USE_BLE_GATT_CLIENT")
|
||||
_request_gatt_connection_slot()
|
||||
|
||||
|
||||
_request_listener_slot = cg.slot_counter(LISTENER_COUNT_DEFINE)
|
||||
|
||||
|
||||
async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
|
||||
"""Register `var` as a parsed-advertisement listener on the configured hub."""
|
||||
hub = await cg.get_variable(config[CONF_BLE_HUB_ID])
|
||||
cg.add(hub.register_listener(var))
|
||||
_request_listener_slot()
|
||||
return var
|
||||
|
||||
|
||||
# ---- shared validation / codegen helpers (platform-neutral) ----
|
||||
|
||||
|
||||
def to_ble_units(value: cv.TimePeriod) -> int:
|
||||
"""Convert a scan time to the controller's 0.625 ms units.
|
||||
|
||||
Used by both validation and codegen so what is validated is exactly what is
|
||||
programmed — the truncation here is what makes the duty-cycle check below
|
||||
meaningful.
|
||||
"""
|
||||
return value.total_microseconds // 625
|
||||
|
||||
|
||||
def validate_scan_parameters(config: ConfigType) -> ConfigType:
|
||||
"""Reject impossible window/interval/duration combinations at config time.
|
||||
|
||||
The controller cannot scan for longer than the interval, and a too-short
|
||||
duration would end the scan period almost immediately. Catching it here
|
||||
gives a clear error instead of a runtime controller failure and a retry
|
||||
loop.
|
||||
"""
|
||||
duration = config[CONF_DURATION]
|
||||
interval = config[CONF_INTERVAL]
|
||||
window = config[CONF_WINDOW]
|
||||
|
||||
if window > interval:
|
||||
raise cv.Invalid(
|
||||
f"Scan window ({window}) needs to be smaller than scan interval ({interval})"
|
||||
)
|
||||
|
||||
# BLE scan interval/window are programmed in 0.625 ms units as a 16-bit value; the
|
||||
# controller only accepts 2.5 ms .. 10240 ms (0x0004 .. 0x4000). Reject out-of-range
|
||||
# values here instead of letting the unit conversion silently overflow.
|
||||
for name, value in (("interval", interval), ("window", window)):
|
||||
if value.total_microseconds < 2500 or value.total_microseconds > 10_240_000:
|
||||
raise cv.Invalid(
|
||||
f"Scan {name} ({value}) must be between 2.5 ms and 10240 ms"
|
||||
)
|
||||
|
||||
# Validate what actually reaches the controller: both values are truncated to
|
||||
# whole 0.625 ms units, so a window/interval pair that differs by less than one
|
||||
# unit collapses to the same value — silently programming a 100 % duty cycle
|
||||
# (radio permanently on) from a config that asked for less.
|
||||
interval_units = to_ble_units(interval)
|
||||
window_units = to_ble_units(window)
|
||||
if window_units == interval_units and window < interval:
|
||||
raise cv.Invalid(
|
||||
f"Scan window ({window}) and interval ({interval}) both truncate to "
|
||||
f"{interval_units} x 0.625 ms, which the controller scans at a 100 % duty "
|
||||
f"cycle. Separate them by at least 0.625 ms."
|
||||
)
|
||||
|
||||
if interval.total_microseconds * 3 > duration.total_microseconds:
|
||||
raise cv.Invalid(
|
||||
f"Scan duration ({duration}) must cover at least three scan intervals "
|
||||
f"({interval}): the scanner listens on one of the three BLE advertising "
|
||||
f"channels per interval, so a shorter duration can miss devices entirely."
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def scan_parameters_schema(
|
||||
interval_default: str,
|
||||
*,
|
||||
window_default: str = "30ms",
|
||||
) -> cv.All:
|
||||
"""Build the scan_parameters value schema shared by all BLE trackers.
|
||||
|
||||
interval_default and window_default are per chip (e.g. esp32 320/30 ms,
|
||||
bk72xx/rp2 100/30 ms — the reference scan rates of the respective stacks;
|
||||
LN882H's SDK recommends 100/50 ms). The `active` option (default on) is
|
||||
unconditional: active scanning is part of the tracker contract — every
|
||||
current proxy client assumes it, so a passive-only tracker must not share
|
||||
this schema.
|
||||
"""
|
||||
schema = {
|
||||
cv.Optional(CONF_DURATION, default="5min"): cv.positive_time_period_seconds,
|
||||
cv.Optional(CONF_INTERVAL, default=interval_default): cv.positive_time_period,
|
||||
cv.Optional(CONF_WINDOW, default=window_default): cv.positive_time_period,
|
||||
cv.Optional(CONF_CONTINUOUS, default=True): cv.boolean,
|
||||
cv.Optional(CONF_ACTIVE, default=True): cv.boolean,
|
||||
}
|
||||
return cv.All(cv.Schema(schema), validate_scan_parameters)
|
||||
|
||||
|
||||
BT_UUID16_FORMAT = "XXXX"
|
||||
BT_UUID32_FORMAT = "XXXXXXXX"
|
||||
BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
|
||||
_BT_UUID16_RE = re.compile("^[A-F0-9]{4,}$")
|
||||
_BT_UUID32_RE = re.compile("^[A-F0-9]{8,}$")
|
||||
_BT_UUID128_RE = re.compile(
|
||||
"^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$"
|
||||
)
|
||||
|
||||
|
||||
# Validator table keyed by input length: (compiled pattern, label used in errors).
|
||||
_BT_UUID_FORMATS = {
|
||||
len(BT_UUID16_FORMAT): (_BT_UUID16_RE, "16 bit"),
|
||||
len(BT_UUID32_FORMAT): (_BT_UUID32_RE, "32 bit"),
|
||||
len(BT_UUID128_FORMAT): (_BT_UUID128_RE, "128"),
|
||||
}
|
||||
|
||||
|
||||
def bt_uuid(value: str) -> str:
|
||||
in_value = cv.string_strict(value)
|
||||
value = in_value.upper()
|
||||
|
||||
fmt = _BT_UUID_FORMATS.get(len(value))
|
||||
if fmt is None:
|
||||
raise cv.Invalid(
|
||||
f"Bluetooth UUID must be in 16 bit '{BT_UUID16_FORMAT}', 32 bit '{BT_UUID32_FORMAT}', or 128 bit '{BT_UUID128_FORMAT}' format"
|
||||
)
|
||||
pattern, label = fmt
|
||||
if not pattern.match(value):
|
||||
raise cv.Invalid(
|
||||
f"Invalid hexadecimal value for {label} UUID format: '{in_value}'"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def as_hex(value: str) -> cg.RawExpression:
|
||||
return cg.RawExpression(f"0x{value}ULL")
|
||||
|
||||
|
||||
def _hex_array_expression(value: str, reverse: bool) -> cg.RawExpression:
|
||||
value = value.replace("-", "")
|
||||
cpp_array = [
|
||||
f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)]
|
||||
]
|
||||
if reverse:
|
||||
cpp_array.reverse()
|
||||
return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}")
|
||||
|
||||
|
||||
def as_hex_array(value: str) -> cg.RawExpression:
|
||||
return _hex_array_expression(value, reverse=False)
|
||||
|
||||
|
||||
def as_reversed_hex_array(value: str) -> cg.RawExpression:
|
||||
return _hex_array_expression(value, reverse=True)
|
||||
|
||||
|
||||
def add_service_uuid(var: cg.MockObj, service_uuid: str) -> None:
|
||||
"""Emit the width-matched service-UUID setter for a consumer.
|
||||
|
||||
16-/32-bit UUIDs go out as plain hex literals, 128-bit as a reversed byte
|
||||
array (BLE wire order). Shared here so every sensor platform dispatches the
|
||||
same way instead of carrying its own if/elif copy.
|
||||
"""
|
||||
if len(service_uuid) == len(BT_UUID16_FORMAT):
|
||||
cg.add(var.set_service_uuid16(as_hex(service_uuid)))
|
||||
elif len(service_uuid) == len(BT_UUID32_FORMAT):
|
||||
cg.add(var.set_service_uuid32(as_hex(service_uuid)))
|
||||
elif len(service_uuid) == len(BT_UUID128_FORMAT):
|
||||
cg.add(var.set_service_uuid128(as_reversed_hex_array(service_uuid)))
|
||||
else:
|
||||
# bt_uuid restricts lengths to exactly these three formats; if that
|
||||
# ever loosens, fail the build instead of emitting no setter (a
|
||||
# sensor whose match_by_ is unset silently never matches). ValueError,
|
||||
# not cv.Invalid: this runs from to_code, after validation, where
|
||||
# voluptuous errors surface as raw tracebacks.
|
||||
raise ValueError(f"Unsupported UUID format: {service_uuid}")
|
||||
@@ -0,0 +1,118 @@
|
||||
// Platform-neutral BLE advertisement triggers: ESPBTDeviceListener subclasses
|
||||
// registered on a BLEHub, exposed by each tracker under its own automation
|
||||
// names. parse_device()'s return feeds the "Found device" suppression.
|
||||
// Constructors are templated on the hub type so this header also builds with
|
||||
// no tracker present (host unit tests).
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "ble_device.h"
|
||||
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <initializer_list>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
// on_ble_advertise: fires on every BLE advertisement, optionally filtered to one or more MACs.
|
||||
class ESPBTAdvertiseTrigger final : public Trigger<const ESPBTDevice &>, public ESPBTDeviceListener {
|
||||
public:
|
||||
template<typename Hub> explicit ESPBTAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
|
||||
|
||||
void set_addresses(std::initializer_list<uint64_t> addresses) { this->addresses_ = addresses; }
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
if (!this->addresses_.empty() && std::find(this->addresses_.begin(), this->addresses_.end(),
|
||||
device.address_uint64()) == this->addresses_.end()) {
|
||||
return false;
|
||||
}
|
||||
this->trigger(device);
|
||||
return true;
|
||||
}
|
||||
|
||||
protected:
|
||||
FixedVector<uint64_t> addresses_;
|
||||
};
|
||||
|
||||
// on_ble_service_data_advertise: fires when an advertisement contains service
|
||||
// data for the given UUID. Optional single-MAC filter.
|
||||
class BLEServiceDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
|
||||
public:
|
||||
template<typename Hub> explicit BLEServiceDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
|
||||
|
||||
void set_service_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
|
||||
void set_service_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); }
|
||||
void set_service_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_address(uint64_t address) {
|
||||
this->address_ = address;
|
||||
this->has_address_ = true;
|
||||
}
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
if (this->has_address_ && device.address_uint64() != this->address_) {
|
||||
return false;
|
||||
}
|
||||
for (const auto &sd : device.get_service_datas()) {
|
||||
if (sd.uuid == this->uuid_) {
|
||||
this->trigger(sd.data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected:
|
||||
ESPBTUUID uuid_{};
|
||||
uint64_t address_{0};
|
||||
bool has_address_{false};
|
||||
};
|
||||
|
||||
// on_ble_manufacturer_data_advertise: fires when an advertisement contains
|
||||
// manufacturer data for the given ID. Optional single-MAC filter.
|
||||
class BLEManufacturerDataAdvertiseTrigger final : public Trigger<const adv_data_t &>, public ESPBTDeviceListener {
|
||||
public:
|
||||
template<typename Hub> explicit BLEManufacturerDataAdvertiseTrigger(Hub *parent) { parent->register_listener(this); }
|
||||
|
||||
void set_manufacturer_uuid16(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint16(static_cast<uint16_t>(uuid)); }
|
||||
void set_manufacturer_uuid32(uint64_t uuid) { this->uuid_ = ESPBTUUID::from_uint32(static_cast<uint32_t>(uuid)); }
|
||||
void set_manufacturer_uuid128(const uint8_t *uuid) { this->uuid_ = ESPBTUUID::from_raw(uuid); }
|
||||
|
||||
void set_address(uint64_t address) {
|
||||
this->address_ = address;
|
||||
this->has_address_ = true;
|
||||
}
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override {
|
||||
if (this->has_address_ && device.address_uint64() != this->address_) {
|
||||
return false;
|
||||
}
|
||||
for (const auto &md : device.get_manufacturer_datas()) {
|
||||
if (md.uuid == this->uuid_) {
|
||||
this->trigger(md.data);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected:
|
||||
ESPBTUUID uuid_{};
|
||||
uint64_t address_{0};
|
||||
bool has_address_{false};
|
||||
};
|
||||
|
||||
// on_scan_end: fires whenever a scan period ends (duration elapsed or stop
|
||||
// requested). A listener whose on_scan_end() hook fires the trigger — never
|
||||
// claims devices (parse_device always returns false).
|
||||
class BLEEndOfScanTrigger final : public Trigger<>, public ESPBTDeviceListener {
|
||||
public:
|
||||
template<typename Hub> explicit BLEEndOfScanTrigger(Hub *parent) { parent->register_listener(this); }
|
||||
|
||||
bool parse_device(const ESPBTDevice &device) override { return false; }
|
||||
void on_scan_end() override { this->trigger(); }
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Shared codegen for the neutral BLE advertisement triggers (automation.h)."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_MAC_ADDRESS, CONF_TRIGGER_ID
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from . import (
|
||||
BT_UUID16_FORMAT,
|
||||
BT_UUID32_FORMAT,
|
||||
BT_UUID128_FORMAT,
|
||||
LISTENER_COUNT_DEFINE,
|
||||
as_hex,
|
||||
as_reversed_hex_array,
|
||||
ble_device_base_ns,
|
||||
)
|
||||
|
||||
adv_data_t = cg.std_vector.template(cg.uint8)
|
||||
adv_data_t_const_ref = adv_data_t.operator("ref").operator("const")
|
||||
ESPBTDeviceConstRef = (
|
||||
ble_device_base_ns.class_("ESPBTDevice").operator("ref").operator("const")
|
||||
)
|
||||
|
||||
ESPBTAdvertiseTrigger = ble_device_base_ns.class_(
|
||||
"ESPBTAdvertiseTrigger", automation.Trigger.template(ESPBTDeviceConstRef)
|
||||
)
|
||||
BLEServiceDataAdvertiseTrigger = ble_device_base_ns.class_(
|
||||
"BLEServiceDataAdvertiseTrigger", automation.Trigger.template(adv_data_t_const_ref)
|
||||
)
|
||||
BLEManufacturerDataAdvertiseTrigger = ble_device_base_ns.class_(
|
||||
"BLEManufacturerDataAdvertiseTrigger",
|
||||
automation.Trigger.template(adv_data_t_const_ref),
|
||||
)
|
||||
BLEEndOfScanTrigger = ble_device_base_ns.class_(
|
||||
"BLEEndOfScanTrigger", automation.Trigger.template()
|
||||
)
|
||||
|
||||
# UUID string length -> setter width. 16/32-bit go out as plain hex literals,
|
||||
# 128-bit as a reversed byte array (BLE wire order). Keyed exhaustively so an
|
||||
# impossible length fails as a KeyError instead of silently picking a width
|
||||
# (bt_uuid validation upstream only ever produces these three).
|
||||
_UUID_WIDTHS = {
|
||||
len(BT_UUID16_FORMAT): "16",
|
||||
len(BT_UUID32_FORMAT): "32",
|
||||
len(BT_UUID128_FORMAT): "128",
|
||||
}
|
||||
|
||||
|
||||
def uuid_trigger_schema(
|
||||
trigger_class: MockObjClass, extra: dict[Any, Any] | None = None
|
||||
):
|
||||
"""Schema for a UUID-filtered trigger — pairs with uuid_trigger_to_code().
|
||||
|
||||
`extra` carries the required UUID key (a cv marker, so a dict rather than
|
||||
**kwargs); the optional single-mac filter is what uuid_trigger_to_code()
|
||||
reads back.
|
||||
"""
|
||||
return automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class),
|
||||
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
|
||||
**(extra or {}),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def advertise_trigger_schema(trigger_class: MockObjClass):
|
||||
"""on_ble_advertise schema: multi-mac list filter, unlike the single-mac
|
||||
uuid_trigger_schema() — pairs with advertise_trigger_to_code()."""
|
||||
return automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class),
|
||||
cv.Optional(CONF_MAC_ADDRESS): cv.ensure_list(cv.mac_address),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def scan_end_trigger_schema(trigger_class: MockObjClass):
|
||||
"""on_scan_end schema: id only — pairs with scan_end_trigger_to_code()."""
|
||||
return automation.validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(trigger_class)}
|
||||
)
|
||||
|
||||
|
||||
# Triggers register as ble_device_base listeners in their constructors; count
|
||||
# them where they are created so no backend can undercount the StaticVector
|
||||
# (push_back past capacity drops silently). Shares the define with
|
||||
# register_ble_device() via the core slot-counter factory.
|
||||
_count_listener = cg.slot_counter(LISTENER_COUNT_DEFINE)
|
||||
|
||||
|
||||
async def advertise_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None:
|
||||
"""Build an on_ble_advertise trigger (optional multi-mac filter)."""
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
if (macs := conf.get(CONF_MAC_ADDRESS)) is not None:
|
||||
cg.add(trigger.set_addresses([it.as_hex for it in macs]))
|
||||
await automation.build_automation(trigger, [(ESPBTDeviceConstRef, "x")], conf)
|
||||
_count_listener()
|
||||
|
||||
|
||||
async def scan_end_trigger_to_code(conf: ConfigType, var: cg.MockObj) -> None:
|
||||
"""Build an on_scan_end trigger."""
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
_count_listener()
|
||||
|
||||
|
||||
async def uuid_trigger_to_code(
|
||||
conf: ConfigType, var: cg.MockObj, key: str, setter_prefix: str
|
||||
) -> None:
|
||||
"""Build a UUID-filtered advertise trigger.
|
||||
|
||||
The UUID width picks the setter: 16-/32-bit go out as a plain hex literal,
|
||||
128-bit as a reversed byte array (BLE wire order).
|
||||
"""
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
uuid = conf[key]
|
||||
width = _UUID_WIDTHS[len(uuid)]
|
||||
value = as_hex(uuid) if width != "128" else as_reversed_hex_array(uuid)
|
||||
cg.add(getattr(trigger, f"{setter_prefix}{width}")(value))
|
||||
if (mac := conf.get(CONF_MAC_ADDRESS)) is not None:
|
||||
cg.add(trigger.set_address(mac.as_hex))
|
||||
await automation.build_automation(trigger, [(adv_data_t_const_ref, "x")], conf)
|
||||
_count_listener()
|
||||
@@ -0,0 +1,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,26 @@
|
||||
#include "ble_client_state.h"
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
const char *client_state_to_string(ClientState state) {
|
||||
switch (state) {
|
||||
case ClientState::INIT:
|
||||
return "INIT";
|
||||
case ClientState::DISCONNECTING:
|
||||
return "DISCONNECTING";
|
||||
case ClientState::IDLE:
|
||||
return "IDLE";
|
||||
case ClientState::DISCOVERED:
|
||||
return "DISCOVERED";
|
||||
case ClientState::CONNECTING:
|
||||
return "CONNECTING";
|
||||
case ClientState::CONNECTED:
|
||||
return "CONNECTED";
|
||||
case ClientState::ESTABLISHED:
|
||||
return "ESTABLISHED";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -0,0 +1,81 @@
|
||||
// ble_client_state.h
|
||||
//
|
||||
// Platform-neutral GATT client connection state types, shared by every
|
||||
// platform's GATT client implementation (esp32_ble_client, bluetooth_connection
|
||||
// backends). Moved here from esp32_ble_tracker, which re-exports them under its
|
||||
// own namespace for backward compatibility.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
/// ESPHome-private errors for the API's plain-int error fields, outside the
|
||||
/// ATT code range so they cannot be mistaken for spec errors. -1 is
|
||||
/// understood by API clients as "not connected". Shared by every GATT
|
||||
/// client backend.
|
||||
static constexpr int GATT_ERR_NOT_CONNECTED = -1;
|
||||
static constexpr int GATT_ERR_NO_MEMORY = -2;
|
||||
/// ATT "Unlikely Error" (spec 0x0E): a client-side internal inconsistency,
|
||||
/// e.g. a service table failing its own bounds checks.
|
||||
static constexpr int GATT_ERR_UNLIKELY = 0x0E;
|
||||
|
||||
/// Safety net shared by every GATT backend: force IDLE when the stack never
|
||||
/// delivers its disconnect completion.
|
||||
static constexpr uint32_t GATT_DISCONNECT_TIMEOUT_MS = 10000;
|
||||
|
||||
/// ATT MTU before negotiation completes (Bluetooth spec default).
|
||||
static constexpr uint16_t DEFAULT_ATT_MTU = 23;
|
||||
|
||||
// Preferred connection parameters shared by every platform's GATT client so
|
||||
// the backends cannot drift (units: interval 1.25 ms, timeout 10 ms; latency
|
||||
// 0). FAST covers connection setup and service discovery; MEDIUM is the
|
||||
// steady state once established. Stack defaults (12.5-15 ms) are too slow for
|
||||
// stable connections through WiFi-based BLE proxies, causing disconnections;
|
||||
// MEDIUM balances responsiveness with bandwidth usage.
|
||||
static constexpr uint16_t MEDIUM_MIN_CONN_INTERVAL = 0x07; // 7 * 1.25ms = 8.75ms
|
||||
static constexpr uint16_t MEDIUM_MAX_CONN_INTERVAL = 0x09; // 9 * 1.25ms = 11.25ms
|
||||
// The timeout value was increased from 6s to 8s to address stability issues observed
|
||||
// in certain BLE devices when operating through WiFi-based BLE proxies. The longer
|
||||
// timeout reduces the likelihood of disconnections during periods of high latency.
|
||||
static constexpr uint16_t MEDIUM_CONN_TIMEOUT = 800; // 800 * 10ms = 8s
|
||||
|
||||
// Fastest connection parameters for devices with short discovery timeouts
|
||||
static constexpr uint16_t FAST_MIN_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms (BLE minimum)
|
||||
static constexpr uint16_t FAST_MAX_CONN_INTERVAL = 0x06; // 6 * 1.25ms = 7.5ms
|
||||
static constexpr uint16_t FAST_CONN_TIMEOUT = 1000; // 1000 * 10ms = 10s
|
||||
|
||||
enum class ClientState : uint8_t {
|
||||
// Connection is allocated
|
||||
INIT,
|
||||
// Client is disconnecting
|
||||
DISCONNECTING,
|
||||
// Connection is idle, no device detected.
|
||||
IDLE,
|
||||
// Device advertisement found.
|
||||
DISCOVERED,
|
||||
// Connection in progress.
|
||||
CONNECTING,
|
||||
// Initial connection established.
|
||||
CONNECTED,
|
||||
// The client and sub-clients have completed setup.
|
||||
ESTABLISHED,
|
||||
};
|
||||
|
||||
// Helper function to convert ClientState to string
|
||||
const char *client_state_to_string(ClientState state);
|
||||
|
||||
enum class ConnectionType : uint8_t {
|
||||
// The default connection type, we hold all the services in ram
|
||||
// for the duration of the connection.
|
||||
V1,
|
||||
// The client has a cache of the services and mtu so we should not
|
||||
// fetch them again
|
||||
V3_WITH_CACHE,
|
||||
// The client does not need the services and mtu once we send them
|
||||
// so we should wipe them from memory as soon as we send them
|
||||
V3_WITHOUT_CACHE
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -0,0 +1,598 @@
|
||||
// ble_device.cpp
|
||||
//
|
||||
// Platform-neutral implementation of the shared BLE advertisement types.
|
||||
// Parses raw BLE advertisement data into ESPBTDevice.
|
||||
|
||||
#include "ble_device.h"
|
||||
|
||||
#include "ble_aes_ccm.h"
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <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 == 0) // the unset sentinel get_uuid() emits
|
||||
return {};
|
||||
if (uuid.len == ESP_UUID_LEN_16)
|
||||
return ESPBTUUID::from_uint16(uuid.uuid.uuid16);
|
||||
if (uuid.len == ESP_UUID_LEN_32)
|
||||
return ESPBTUUID::from_uint32(uuid.uuid.uuid32);
|
||||
return ESPBTUUID::from_raw(uuid.uuid.uuid128);
|
||||
}
|
||||
|
||||
esp_bt_uuid_t ESPBTUUID::get_uuid() const {
|
||||
esp_bt_uuid_t ret;
|
||||
switch (this->type_) {
|
||||
case Type::UNSET:
|
||||
ret.len = 0;
|
||||
memset(&ret.uuid, 0, sizeof(ret.uuid));
|
||||
break;
|
||||
case Type::UUID16:
|
||||
ret.len = ESP_UUID_LEN_16;
|
||||
ret.uuid.uuid16 = this->uuid_.uuid16;
|
||||
break;
|
||||
case Type::UUID32:
|
||||
ret.len = ESP_UUID_LEN_32;
|
||||
ret.uuid.uuid32 = this->uuid_.uuid32;
|
||||
break;
|
||||
default:
|
||||
case Type::UUID128:
|
||||
ret.len = ESP_UUID_LEN_128;
|
||||
memcpy(ret.uuid.uuid128, this->uuid_.uuid128, ESP_UUID_LEN_128);
|
||||
break;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) {
|
||||
this->scan_result_ = &scan_result;
|
||||
// BLEScanResult's bda is most-significant octet first; the neutral ingest
|
||||
// takes the BLE controller (LSB-first) order, so reverse — address_uint64()/
|
||||
// address_str_to() then produce exactly the historical esp32 values.
|
||||
uint8_t mac_lsb_first[MAC_ADDRESS_SIZE];
|
||||
for (uint8_t i = 0; i < 6; i++)
|
||||
mac_lsb_first[i] = scan_result.bda[5 - i];
|
||||
this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv,
|
||||
scan_result.adv_data_len + scan_result.scan_rsp_len);
|
||||
}
|
||||
#endif // USE_ESP32
|
||||
|
||||
ESPBTUUID ESPBTUUID::as_128bit() const {
|
||||
// Widening an unset UUID stays unset; expanding it would produce a set 0x0000 base UUID.
|
||||
if (this->type_ == Type::UNSET || this->type_ == Type::UUID128)
|
||||
return *this;
|
||||
uint8_t data[16];
|
||||
this->to_128bit_(data);
|
||||
return ESPBTUUID::from_raw(data);
|
||||
}
|
||||
|
||||
bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const {
|
||||
// Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains.
|
||||
switch (this->type_) {
|
||||
case Type::UNSET:
|
||||
return false;
|
||||
case Type::UUID16:
|
||||
return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1;
|
||||
case Type::UUID32:
|
||||
for (uint8_t i = 0; i < 3; i++) {
|
||||
bool a = ((this->uuid_.uuid32 >> i * 8) & 0xFF) == data1;
|
||||
bool b = ((this->uuid_.uuid32 >> (i + 1) * 8) & 0xFF) == data2;
|
||||
if (a && b)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case Type::UUID128:
|
||||
for (uint8_t i = 0; i < 15; i++) {
|
||||
if (this->uuid_.uuid128[i] == data1 && this->uuid_.uuid128[i + 1] == data2)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *ESPBTUUID::to_str(char *buf) const {
|
||||
// Identical output format to esp32_ble::ESPBTUUID::to_str.
|
||||
char *pos = buf;
|
||||
switch (this->type_) {
|
||||
case Type::UNSET:
|
||||
memcpy(buf, "None", 5);
|
||||
return buf;
|
||||
case Type::UUID16:
|
||||
*pos++ = '0';
|
||||
*pos++ = 'x';
|
||||
*pos++ = format_hex_pretty_char(this->uuid_.uuid16 >> 12);
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 8) & 0x0F);
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 4) & 0x0F);
|
||||
*pos++ = format_hex_pretty_char(this->uuid_.uuid16 & 0x0F);
|
||||
*pos = 0; // NUL-terminate
|
||||
return buf;
|
||||
case Type::UUID32:
|
||||
*pos++ = '0';
|
||||
*pos++ = 'x';
|
||||
for (int shift = 28; shift >= 0; shift -= 4)
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid32 >> shift) & 0x0F);
|
||||
*pos = 0; // NUL-terminate
|
||||
return buf;
|
||||
default:
|
||||
case Type::UUID128:
|
||||
// Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
|
||||
for (int8_t i = 15; i >= 0; i--) {
|
||||
uint8_t byte = this->uuid_.uuid128[i];
|
||||
*pos++ = format_hex_pretty_char(byte >> 4);
|
||||
*pos++ = format_hex_pretty_char(byte & 0x0F);
|
||||
if (i == 12 || i == 10 || i == 8 || i == 6)
|
||||
*pos++ = '-';
|
||||
}
|
||||
*pos = 0; // NUL-terminate
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
void ESPBTUUID::to_128bit_(uint8_t out[16]) const {
|
||||
// Bluetooth Base UUID 00000000-0000-1000-8000-00805F9B34FB (LSB-first), with the 16/32-bit
|
||||
// value placed at bytes 12..; identical expansion to esp32_ble::ESPBTUUID::as_128bit().
|
||||
// Callers screen out UNSET first (operator==, as_128bit); it would expand like 0x0000.
|
||||
static const uint8_t BASE[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
|
||||
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
if (this->type_ == Type::UUID128) {
|
||||
memcpy(out, this->uuid_.uuid128, 16);
|
||||
return;
|
||||
}
|
||||
memcpy(out, BASE, 16);
|
||||
const uint32_t value = (this->type_ == Type::UUID32) ? this->uuid_.uuid32 : this->uuid_.uuid16;
|
||||
const size_t len = (this->type_ == Type::UUID32) ? 4 : 2;
|
||||
for (size_t i = 0; i < len; i++)
|
||||
out[12 + i] = (value >> (i * 8)) & 0xFF;
|
||||
}
|
||||
|
||||
bool ESPBTUUID::operator==(const ESPBTUUID &other) const {
|
||||
if (this->type_ == other.type_) {
|
||||
switch (this->type_) {
|
||||
case Type::UNSET:
|
||||
return true;
|
||||
case Type::UUID16:
|
||||
return this->uuid_.uuid16 == other.uuid_.uuid16;
|
||||
case Type::UUID32:
|
||||
return this->uuid_.uuid32 == other.uuid_.uuid32;
|
||||
case Type::UUID128:
|
||||
return memcmp(this->uuid_.uuid128, other.uuid_.uuid128, 16) == 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Unset never equals a set UUID; 0x0000 is a valid value, distinct from "not configured".
|
||||
if (this->type_ == Type::UNSET || other.type_ == Type::UNSET)
|
||||
return false;
|
||||
// Different widths: expand both to the 128-bit Bluetooth Base UUID form and compare, so a
|
||||
// configured 16/32-bit UUID matches the equivalent 128-bit advertisement (esp32 parity).
|
||||
uint8_t a[16];
|
||||
uint8_t b[16];
|
||||
this->to_128bit_(a);
|
||||
other.to_128bit_(b);
|
||||
return memcmp(a, b, 16) == 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBLEiBeacon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); }
|
||||
|
||||
optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data, bool *prefix_rejected) {
|
||||
// iBeacon manufacturer specific data (after company-ID bytes have been stripped):
|
||||
// [0x02][0x15][16-byte UUID][2-byte major][2-byte minor][1-byte power] = exactly 23 bytes
|
||||
if (!data.uuid.contains(0x4C, 0x00)) // Apple company ID 0x004C
|
||||
return {};
|
||||
if (data.data.size() != 23)
|
||||
return {};
|
||||
// Require the iBeacon sub-type/length prefix — stricter than the legacy
|
||||
// esp32 parser, which accepted any 23-byte Apple payload and surfaced
|
||||
// non-iBeacon frames as garbage beacons.
|
||||
if (data.data[0] != 0x02 || data.data[1] != 0x15) {
|
||||
if (prefix_rejected != nullptr)
|
||||
*prefix_rejected = true;
|
||||
return {};
|
||||
}
|
||||
return ESPBLEiBeacon(data.data.data());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBTDevice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
optional<ESPBLEiBeacon> ESPBTDevice::get_ibeacon() const {
|
||||
bool prefix_rejected = false;
|
||||
uint8_t rejected_sub_type = 0;
|
||||
uint8_t rejected_len = 0;
|
||||
for (const auto &it : this->manufacturer_datas_) {
|
||||
bool rejected = false;
|
||||
auto res = ESPBLEiBeacon::from_manufacturer_data(it, &rejected);
|
||||
if (res.has_value())
|
||||
return res;
|
||||
if (rejected && !prefix_rejected) {
|
||||
prefix_rejected = true;
|
||||
rejected_sub_type = it.data[0];
|
||||
rejected_len = it.data[1];
|
||||
}
|
||||
}
|
||||
if (prefix_rejected) {
|
||||
// Only when no beacon was found at all: these frames were accepted before
|
||||
// the prefix check, so their disappearance must be observable at the
|
||||
// default log level. Throttled so a chatty non-iBeacon Apple advertiser
|
||||
// cannot flood the log; a different address may bypass the shared window
|
||||
// so that advertiser cannot mask the device that actually regressed — but
|
||||
// with a 1 s floor, or two alternating advertisers log every frame.
|
||||
static uint32_t last_log = 0;
|
||||
static uint64_t last_addr = 0;
|
||||
const uint32_t now = millis();
|
||||
const uint64_t addr = this->address_uint64();
|
||||
const uint32_t since = now - last_log;
|
||||
if (last_log == 0 || since > 60000 || (addr != last_addr && since > 1000)) {
|
||||
last_log = now;
|
||||
last_addr = addr;
|
||||
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGD(TAG, "%s: 23-byte Apple frame without iBeacon prefix ignored (sub-type 0x%02X len 0x%02X)",
|
||||
this->address_str_to(addr_buf), rejected_sub_type, rejected_len);
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
const char *ESPBTDevice::address_type_str() const {
|
||||
switch (this->address_type_) {
|
||||
case BLE_ADDR_TYPE_PUBLIC:
|
||||
return "PUBLIC";
|
||||
case BLE_ADDR_TYPE_RANDOM:
|
||||
return "RANDOM";
|
||||
case BLE_ADDR_TYPE_RPA_PUBLIC:
|
||||
return "RPA_PUBLIC";
|
||||
case BLE_ADDR_TYPE_RPA_RANDOM:
|
||||
return "RPA_RANDOM";
|
||||
default:
|
||||
return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data,
|
||||
uint16_t data_len) {
|
||||
// Ingest is BLE controller order (LSB-first); store in printable (MSB-first)
|
||||
// order so the raw address() accessor matches the historical esp32 layout.
|
||||
for (uint8_t i = 0; i < 6; i++)
|
||||
this->address_[i] = mac[5 - i];
|
||||
this->address_type_ = addr_type;
|
||||
this->rssi_ = rssi;
|
||||
this->name_len_ = 0;
|
||||
this->name_[0] = '\0';
|
||||
this->service_uuids_.clear();
|
||||
this->manufacturer_datas_.clear();
|
||||
this->service_datas_.clear();
|
||||
this->tx_powers_.clear();
|
||||
this->appearance_.reset();
|
||||
this->ad_flag_.reset();
|
||||
this->parse_adv_(data, data_len);
|
||||
|
||||
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
|
||||
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGVV(TAG,
|
||||
"Parse Result:\n"
|
||||
" Address: %s (%s)\n"
|
||||
" RSSI: %d\n"
|
||||
" Name: '%s'",
|
||||
this->address_str_to(addr_buf), this->address_type_str(), this->rssi_, this->name_);
|
||||
for (auto &it : this->tx_powers_) {
|
||||
ESP_LOGVV(TAG, " TX Power: %d", it);
|
||||
}
|
||||
if (this->appearance_.has_value()) {
|
||||
ESP_LOGVV(TAG, " Appearance: %u", *this->appearance_);
|
||||
}
|
||||
if (this->ad_flag_.has_value()) {
|
||||
ESP_LOGVV(TAG, " Ad Flag: %u", *this->ad_flag_);
|
||||
}
|
||||
char uuid_buf[UUID_STR_LEN];
|
||||
for (auto &uuid : this->service_uuids_) {
|
||||
ESP_LOGVV(TAG, " Service UUID: %s", uuid.to_str(uuid_buf));
|
||||
}
|
||||
char hex_buf[format_hex_pretty_size(BLE_ADV_MAX_LOG_BYTES)];
|
||||
for (auto &mfg_data : this->manufacturer_datas_) {
|
||||
auto ibeacon = ESPBLEiBeacon::from_manufacturer_data(mfg_data);
|
||||
if (ibeacon.has_value()) {
|
||||
ESP_LOGVV(TAG,
|
||||
" Manufacturer iBeacon:\n"
|
||||
" UUID: %s\n"
|
||||
" Major: %u\n"
|
||||
" Minor: %u\n"
|
||||
" TXPower: %d",
|
||||
ibeacon.value().get_uuid().to_str(uuid_buf), ibeacon.value().get_major(), ibeacon.value().get_minor(),
|
||||
ibeacon.value().get_signal_power());
|
||||
} else {
|
||||
ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", mfg_data.uuid.to_str(uuid_buf),
|
||||
format_hex_pretty_to(hex_buf, mfg_data.data.data(), mfg_data.data.size()));
|
||||
}
|
||||
}
|
||||
for (auto &svc_data : this->service_datas_) {
|
||||
ESP_LOGVV(TAG,
|
||||
" Service data:\n"
|
||||
" UUID: %s\n"
|
||||
" Data: %s",
|
||||
svc_data.uuid.to_str(uuid_buf),
|
||||
format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size()));
|
||||
}
|
||||
ESP_LOGVV(TAG, " Adv data: %s", format_hex_pretty_to(hex_buf, data, data_len));
|
||||
#endif // ESPHOME_LOG_HAS_VERY_VERBOSE
|
||||
}
|
||||
|
||||
// Remove before 2027.2.0
|
||||
std::string ESPBTDevice::address_str() const {
|
||||
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
return std::string(this->address_str_to(buf));
|
||||
}
|
||||
|
||||
const char *ESPBTDevice::address_str_to(char *buf) const {
|
||||
// address_ is stored in printable (MSB-first) order.
|
||||
format_mac_addr_upper(this->address_, buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
uint64_t ESPBTDevice::address_uint64() const {
|
||||
// address_ is MSB-first; byte 0 of the result is the LSB (esp32 semantics).
|
||||
uint64_t addr = 0;
|
||||
for (int i = 0; i < 6; i++)
|
||||
addr |= static_cast<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_len_) {
|
||||
uint8_t name_len = ad_data_len > MAX_ADV_NAME_LEN ? MAX_ADV_NAME_LEN : static_cast<uint8_t>(ad_data_len);
|
||||
memcpy(this->name_, ad_data, name_len);
|
||||
this->name_[name_len] = '\0';
|
||||
this->name_len_ = name_len;
|
||||
}
|
||||
break;
|
||||
|
||||
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,292 @@
|
||||
// ble_device.h
|
||||
//
|
||||
// Platform-neutral BLE advertisement types — the generic base every BLE consumer
|
||||
// (sensor components, bluetooth_proxy, automation triggers) builds against:
|
||||
// ESPBTUUID / ServiceData / ESPBLEiBeacon / ESPBTDevice / ESPBTDeviceListener
|
||||
//
|
||||
// These types are owned here on EVERY platform, with no chip-SDK types in their
|
||||
// public surface. Platform trackers produce them:
|
||||
// - esp32_ble_tracker adapts ESP-IDF scan results into ESPBTDevice and
|
||||
// re-exports these names (esp32 only) for backward compatibility;
|
||||
// - the LibreTiny trackers (bk72xx / ln882h) feed from_scan_result() directly.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
|
||||
#include <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, or "None" for an
|
||||
/// unset UUID, into buf (>= UUID_STR_LEN bytes) and return buf.
|
||||
const char *to_str(char *buf) const;
|
||||
#if defined(__cpp_lib_span)
|
||||
const char *to_str(std::span<char, UUID_STR_LEN> output) const { return this->to_str(output.data()); }
|
||||
#endif
|
||||
// UNSET is the default-constructed state; get_uuid() reports it as len 0 (the historical sentinel).
|
||||
enum class Type : uint8_t { UNSET, UUID16, UUID32, UUID128 };
|
||||
Type type() const { return this->type_; }
|
||||
/// True if a UUID has been configured (not default-constructed).
|
||||
bool is_set() const { return this->type_ != Type::UNSET; }
|
||||
uint16_t uuid16() const { return this->uuid_.uuid16; }
|
||||
uint32_t uuid32() const { return this->uuid_.uuid32; }
|
||||
const uint8_t *uuid128() const { return this->uuid_.uuid128; }
|
||||
|
||||
protected:
|
||||
// Expand to the 128-bit Bluetooth Base UUID byte form (out is 16 bytes, little-endian).
|
||||
void to_128bit_(uint8_t out[16]) const;
|
||||
|
||||
Type type_{Type::UNSET};
|
||||
union {
|
||||
uint16_t uuid16;
|
||||
uint32_t uuid32;
|
||||
uint8_t uuid128[16];
|
||||
} uuid_{};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServiceData — UUID-tagged advertisement payload (0x16 / 0xFF AD types)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ServiceData {
|
||||
ESPBTUUID uuid;
|
||||
adv_data_t data;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBLEiBeacon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ESPBLEiBeacon {
|
||||
public:
|
||||
ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); }
|
||||
explicit ESPBLEiBeacon(const uint8_t *data);
|
||||
/// prefix_rejected: caller must initialise to false; set to true ONLY when a
|
||||
/// 23-byte Apple frame was refused for lacking the 0x02/0x15 iBeacon prefix —
|
||||
/// the case the legacy esp32 parser accepted. Never written on accept or on
|
||||
/// the non-Apple/wrong-size rejects. The caller with the device address does
|
||||
/// the logging (see ESPBTDevice::get_ibeacon()).
|
||||
static optional<ESPBLEiBeacon> from_manufacturer_data(const ServiceData &data, bool *prefix_rejected = nullptr);
|
||||
|
||||
uint16_t get_major() const { return byteswap(this->beacon_data_.major); }
|
||||
uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); }
|
||||
int8_t get_signal_power() const { return this->beacon_data_.signal_power; }
|
||||
ESPBTUUID get_uuid() const { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); }
|
||||
|
||||
protected:
|
||||
struct PACKED BeaconData {
|
||||
uint8_t sub_type;
|
||||
uint8_t length;
|
||||
uint8_t proximity_uuid[16];
|
||||
uint16_t major;
|
||||
uint16_t minor;
|
||||
int8_t signal_power;
|
||||
} beacon_data_;
|
||||
};
|
||||
|
||||
/// Pack a controller-order (LSB-first) MAC into the uint64 the API speaks.
|
||||
/// Trackers with LSB-native SDKs call this at the emit site before filling
|
||||
/// RawAdvertisement::address; ESPBTDevice::address_uint64() is the equivalent
|
||||
/// for an already parsed device, whose address is stored MSB-first.
|
||||
inline uint64_t mac_lsb_first_to_uint64(const uint8_t *mac) {
|
||||
uint64_t addr = 0;
|
||||
for (int i = 0; i < 6; i++)
|
||||
addr |= static_cast<uint64_t>(mac[i]) << (i * 8);
|
||||
return addr;
|
||||
}
|
||||
|
||||
/// Unpack a uint64 BLE address into printable (MSB-first) byte order —
|
||||
/// the order bd_addr_t / esp_bd_addr_t style APIs expect.
|
||||
inline void uint64_to_mac_msb_first(uint64_t address, uint8_t out[6]) {
|
||||
for (int i = 0; i < 6; i++)
|
||||
out[i] = (address >> ((5 - i) * 8)) & 0xFF;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBTDevice — parsed BLE advertisement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ESPBTDevice {
|
||||
public:
|
||||
/// Populate from a raw scan result delivered by a BLE tracker backend.
|
||||
/// mac is least-significant octet first (BLE controller convention).
|
||||
void from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len);
|
||||
|
||||
// Alias the core constant so the two cannot drift apart.
|
||||
static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE;
|
||||
|
||||
/// Return MAC as "XX:XX:XX:XX:XX:XX" string.
|
||||
ESPDEPRECATED("Use address_str_to() instead. Removed in 2027.2.0.", "2026.8.0")
|
||||
std::string address_str() const;
|
||||
/// Writes "XX:XX:XX:XX:XX:XX\0" into buf (>= MAC_ADDRESS_PRETTY_BUFFER_SIZE bytes), returns buf.
|
||||
const char *address_str_to(char *buf) const;
|
||||
#if defined(__cpp_lib_span)
|
||||
const char *address_str_to(std::span<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.
|
||||
/// Prefer ESPBTDevice::from_scan_result(); deprecation is a follow-up pending
|
||||
/// consumer feedback on the raw scan-result fields.
|
||||
void parse_scan_rst(const esp32_ble::BLEScanResult &scan_result);
|
||||
// Exposed through a function for use in lambdas
|
||||
const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; }
|
||||
#else
|
||||
uint8_t get_address_type() const { return this->address_type_; }
|
||||
#endif
|
||||
/// Human-readable address type ("PUBLIC", "RANDOM", "RPA_PUBLIC", "RPA_RANDOM" or
|
||||
/// "UNKNOWN"), backed by the shared BLE_ADDR_TYPE_* constants above.
|
||||
const char *address_type_str() const;
|
||||
|
||||
int get_rssi() const { return rssi_; }
|
||||
/// Advertised name as a view into the fixed buffer (always NUL-terminated,
|
||||
/// so c_str() is safe); converts implicitly to std::string where needed.
|
||||
StringRef get_name() const { return StringRef(this->name_, this->name_len_); }
|
||||
|
||||
const std::vector<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;
|
||||
|
||||
protected:
|
||||
void parse_adv_(const uint8_t *payload, uint16_t len);
|
||||
|
||||
// Max name bytes in a legacy advertisement AD element (31-byte PDU minus
|
||||
// the 2-byte element header); every in-tree tracker scans legacy PDUs only.
|
||||
static constexpr uint8_t MAX_ADV_NAME_LEN = 29;
|
||||
|
||||
uint8_t address_[MAC_ADDRESS_SIZE]{0};
|
||||
uint8_t address_type_{0};
|
||||
int rssi_{0};
|
||||
// Fixed buffer instead of std::string: no per-advertisement heap churn on
|
||||
// the scan path, and no libstdc++ string/exception machinery in the image.
|
||||
char name_[MAX_ADV_NAME_LEN + 1]{};
|
||||
uint8_t name_len_{0};
|
||||
std::vector<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