mirror of
https://github.com/esphome/esphome.git
synced 2026-10-02 01:10:22 +00:00
Merge remote-tracking branch 'origin/dev' into store-yaml-firmware-work
# Conflicts: # esphome/components/api/api.proto # esphome/components/api/api_pb2.cpp # esphome/components/api/api_pb2.h # esphome/components/api/api_pb2_dump.cpp # esphome/yaml_util.py
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
# Normalize line endings to LF in the repository
|
||||
* text eol=lf
|
||||
*.png binary
|
||||
*.gif binary
|
||||
*.apng binary
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
- [ ] Bugfix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
|
||||
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
|
||||
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
|
||||
@@ -20,6 +21,10 @@
|
||||
|
||||
- esphome/esphome.io#<esphome.io PR number goes here>
|
||||
|
||||
**Pull request in [developers.esphome.io](https://github.com/esphome/developers.esphome.io) with developer documentation (if applicable):**
|
||||
|
||||
- esphome/developers.esphome.io#<developers.esphome.io PR number goes here>
|
||||
|
||||
## Test Environment
|
||||
|
||||
- [ ] ESP32
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Cache sdk-nrf
|
||||
name: Cache nRF Connect SDK
|
||||
description: >
|
||||
Resolve the pinned sdk-nrf version and cache the native sdk-nrf install
|
||||
(west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf.
|
||||
@@ -33,14 +33,14 @@ runs:
|
||||
# Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it
|
||||
# lives in the default-branch scope readable by all PRs); PRs are
|
||||
# restore-only and never push multi-GB artifacts into their own scope.
|
||||
- name: Cache sdk-nrf install (write on dev)
|
||||
- name: Cache nRF Connect SDK install (write on dev)
|
||||
if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.esphome-sdk-nrf
|
||||
# yamllint disable-line rule:line-length
|
||||
key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }}
|
||||
- name: Cache sdk-nrf install (restore-only off dev)
|
||||
- name: Cache nRF Connect SDK install (restore-only off dev)
|
||||
if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
|
||||
@@ -32,9 +32,12 @@ runs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||
# downstream jobs rely on is preserved.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
# reuse; dev pushes seed the shared copy instead.
|
||||
save-cache: ${{ github.event_name != 'pull_request' }}
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
|
||||
@@ -22,11 +22,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 +42,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,
|
||||
@@ -245,6 +245,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 +356,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 +379,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 =>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
const { describe, it } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors');
|
||||
const {
|
||||
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.
|
||||
@@ -146,6 +153,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
|
||||
|
||||
@@ -21,17 +21,20 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up uv
|
||||
# ``--system`` (below) installs into the setup-python interpreter;
|
||||
# no venv is created or restored by this workflow.
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull-request-only workflow: a save could never be shared and
|
||||
# would only consume quota.
|
||||
save-cache: "false"
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
|
||||
@@ -61,9 +61,9 @@ jobs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
push: ${{ steps.tag.outputs.push }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Set up Docker Buildx
|
||||
@@ -145,9 +145,9 @@ 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
|
||||
@@ -202,7 +202,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
|
||||
|
||||
+115
-41
@@ -28,13 +28,13 @@ jobs:
|
||||
cache-key: ${{ steps.cache-key.outputs.key }}
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Generate cache-key
|
||||
id: cache-key
|
||||
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
|
||||
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
|
||||
id: python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
- name: Restore Python virtual environment
|
||||
@@ -49,9 +49,12 @@ jobs:
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
||||
# that ``. venv/bin/activate`` see an identical layout.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
# reuse; dev pushes seed the shared copy instead.
|
||||
save-cache: ${{ github.event_name != 'pull_request' }}
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
@@ -74,7 +77,7 @@ jobs:
|
||||
if: needs.determine-jobs.outputs.python-linters == '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:
|
||||
@@ -97,7 +100,7 @@ jobs:
|
||||
if: needs.determine-jobs.outputs.core-ci == '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:
|
||||
@@ -112,7 +115,7 @@ jobs:
|
||||
script/build_codeowners.py --check
|
||||
script/build_language_schema.py --check
|
||||
script/generate-esp32-boards.py --check
|
||||
script/generate-rp2040-boards.py --check
|
||||
script/generate-rp2-boards.py --check
|
||||
script/ci_check_duplicate_test_ids.py
|
||||
|
||||
import-time:
|
||||
@@ -124,7 +127,7 @@ jobs:
|
||||
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:
|
||||
@@ -152,17 +155,17 @@ jobs:
|
||||
if: needs.determine-jobs.outputs.device-builder == 'true'
|
||||
steps:
|
||||
- name: Check out esphome (this PR)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
path: esphome
|
||||
- name: Check out esphome/device-builder
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: esphome/device-builder
|
||||
ref: main
|
||||
path: device-builder
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Set up uv
|
||||
@@ -171,9 +174,12 @@ jobs:
|
||||
# install step (order-of-magnitude faster on cold boots,
|
||||
# with its own wheel cache). actions/setup-python still
|
||||
# provides the interpreter.
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
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.
|
||||
@@ -225,7 +231,7 @@ jobs:
|
||||
if: needs.determine-jobs.outputs.core-ci == '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
|
||||
id: restore-python
|
||||
uses: ./.github/actions/restore-python
|
||||
@@ -285,7 +291,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
|
||||
@@ -357,10 +363,10 @@ 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: Set up Python 3.13
|
||||
id: python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Restore Python virtual environment
|
||||
@@ -372,9 +378,12 @@ jobs:
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pull request saves land in per-PR scopes nothing else can
|
||||
# reuse; dev pushes seed the shared copy instead.
|
||||
save-cache: ${{ github.event_name != 'pull_request' }}
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
@@ -409,7 +418,7 @@ 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
@@ -438,7 +447,7 @@ jobs:
|
||||
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
@@ -456,7 +465,7 @@ jobs:
|
||||
echo "binary=$BINARY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@a4a36bb07c0638b0b4ca52bf1f3dad1b4289e52f # v4.18.1
|
||||
uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
@@ -495,10 +504,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
|
||||
@@ -529,7 +547,7 @@ jobs:
|
||||
with:
|
||||
framework: arduino
|
||||
|
||||
- name: Cache sdk-nrf install
|
||||
- name: Cache nRF Connect SDK install
|
||||
if: matrix.cache_sdk_nrf
|
||||
uses: ./.github/actions/cache-sdk-nrf
|
||||
|
||||
@@ -558,10 +576,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
|
||||
@@ -585,7 +614,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
|
||||
@@ -665,7 +694,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
|
||||
@@ -735,7 +764,8 @@ jobs:
|
||||
include:
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 S3
|
||||
options: --environment esp32s3-idf-tidy --grep USE_ESP32_VARIANT_ESP32S3
|
||||
# yamllint disable-line rule:line-length
|
||||
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
|
||||
- id: clang-tidy
|
||||
name: Run script/clang-tidy for ESP32 P4
|
||||
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
|
||||
@@ -745,11 +775,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
|
||||
@@ -828,14 +858,15 @@ jobs:
|
||||
- name: List components
|
||||
run: echo ${{ matrix.batch.components }}
|
||||
|
||||
- name: Cache apt packages
|
||||
uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: libsdl2-dev ccache
|
||||
version: 1.1
|
||||
- name: Install apt packages
|
||||
# Not cached: this job is pull-request-only, so a cache save could
|
||||
# never be shared and would only consume quota.
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
|
||||
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
@@ -849,7 +880,7 @@ jobs:
|
||||
uses: ./.github/actions/cache-esp-idf
|
||||
with:
|
||||
restore-only: true
|
||||
- name: Cache sdk-nrf install (restore-only)
|
||||
- name: Cache nRF Connect SDK install (restore-only)
|
||||
# Only batches whose test platforms include nrf52 need the native
|
||||
# sdk-nrf install; never save -- just reuse the shared install the
|
||||
# dev nrf52 tidy job cached when present.
|
||||
@@ -980,7 +1011,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
|
||||
@@ -1006,6 +1037,36 @@ jobs:
|
||||
# Arduino framework via PlatformIO (only components with an esp32-ard test are built):
|
||||
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
|
||||
|
||||
pre-commit-seed-cache:
|
||||
name: Seed pre-commit cache
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- common
|
||||
# Saves a dev-scoped pre-commit cache that pull request runs can
|
||||
# restore, since pre-commit.ci lite itself never runs on dev pushes.
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Restore Python
|
||||
uses: ./.github/actions/restore-python
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON }}
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
- name: Cache pre-commit environments
|
||||
id: cache-pre-commit
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
# Must match the restore key in pre-commit-ci-lite
|
||||
# yamllint disable-line rule:line-length
|
||||
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Install pre-commit hook environments
|
||||
if: steps.cache-pre-commit.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install pre-commit
|
||||
pre-commit install-hooks
|
||||
|
||||
pre-commit-ci-lite:
|
||||
name: pre-commit.ci lite
|
||||
runs-on: ubuntu-latest
|
||||
@@ -1015,15 +1076,28 @@ 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@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 }}
|
||||
- uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache
|
||||
# Inlined from esphome/pre-commit-action with a restore-only cache
|
||||
# step: the pre-commit-seed-cache job owns saving this cache, so
|
||||
# pull request runs never write per-PR copies.
|
||||
- name: Restore pre-commit cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ~/.cache/pre-commit
|
||||
# Must match the key pre-commit-seed-cache saves
|
||||
# yamllint disable-line rule:line-length
|
||||
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
|
||||
- name: Run pre-commit
|
||||
env:
|
||||
SKIP: pylint,ci-custom
|
||||
run: |
|
||||
python -m pip install pre-commit
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
|
||||
if: always()
|
||||
|
||||
@@ -1041,7 +1115,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 }}
|
||||
|
||||
@@ -1223,7 +1297,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:
|
||||
@@ -1292,7 +1366,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:
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
sparse-checkout: |
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
|
||||
|
||||
@@ -52,11 +52,11 @@ jobs:
|
||||
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
uses: github/codeql-action/analyze@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -14,4 +14,4 @@ jobs:
|
||||
permissions:
|
||||
issues: write # issues.lock on closed issues
|
||||
pull-requests: write # issues.lock on closed pull requests
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
branch_build: ${{ steps.tag.outputs.branch_build }}
|
||||
deploy_env: ${{ steps.tag.outputs.deploy_env }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Get tag
|
||||
id: tag
|
||||
# yamllint disable rule:line-length
|
||||
@@ -60,9 +60,9 @@ jobs:
|
||||
contents: read # actions/checkout to build the sdist/wheel
|
||||
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Build
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
pip3 install build
|
||||
python3 -m build
|
||||
- name: Publish
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -92,9 +92,9 @@ jobs:
|
||||
os: "ubuntu-24.04-arm"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
- ghcr
|
||||
- dockerhub
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
|
||||
remove-stale-when-updated: true
|
||||
|
||||
@@ -5,7 +5,7 @@ on:
|
||||
types: [opened, reopened, labeled, unlabeled, synchronize]
|
||||
|
||||
permissions:
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr)
|
||||
pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
|
||||
const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr'];
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
|
||||
@@ -28,16 +28,16 @@ jobs:
|
||||
permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Checkout Home Assistant
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: home-assistant/core
|
||||
path: lib/home-assistant
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
# setup-python interpreter so subsequent ``pre-commit`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
|
||||
@@ -427,13 +427,14 @@ This document provides essential context for AI models interacting with this pro
|
||||
|
||||
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
|
||||
|
||||
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
|
||||
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`.
|
||||
|
||||
All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`):
|
||||
```yaml
|
||||
# test.esp32-idf.yaml — use packages for buses
|
||||
# test.esp32-idf.yaml — everything included via named packages
|
||||
packages:
|
||||
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
my_component: !include common.yaml
|
||||
```
|
||||
```yaml
|
||||
# common.yaml — component config only, NO bus definitions
|
||||
|
||||
+7
-1
@@ -69,11 +69,13 @@ 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/bl0906/* @athom-tech @jesserockz @tarontop
|
||||
esphome/components/bl0939/* @ziceva
|
||||
esphome/components/bl0940/* @dan-s-github @tobias-
|
||||
esphome/components/bl0942/* @dbuezas @dwmw2
|
||||
esphome/components/ble_client/* @buxtronix @clydebarrow
|
||||
esphome/components/ble_device_base/* @Bl00d-B0b
|
||||
esphome/components/ble_nus/* @tomaszduda23
|
||||
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
|
||||
esphome/components/bm8563/* @abmantis
|
||||
@@ -145,6 +147,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
|
||||
@@ -187,6 +190,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento
|
||||
esphome/components/factory_reset/* @anatoly-savchenkov
|
||||
esphome/components/fastled_base/* @OttoWinter
|
||||
esphome/components/feedback/* @ianchi
|
||||
esphome/components/file/* @esphome/core
|
||||
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
|
||||
esphome/components/font/* @clydebarrow @esphome/core
|
||||
esphome/components/fs3000/* @kahrendt
|
||||
@@ -208,6 +212,7 @@ esphome/components/gree/switch/* @nagyrobi
|
||||
esphome/components/grove_gas_mc_v2/* @YorkshireIoT
|
||||
esphome/components/grove_tb6612fng/* @max246
|
||||
esphome/components/growatt_solar/* @leeuwte
|
||||
esphome/components/gsl3670/* @clydebarrow
|
||||
esphome/components/gt911/* @clydebarrow @jesserockz
|
||||
esphome/components/haier/* @paveldn
|
||||
esphome/components/haier/binary_sensor/* @paveldn
|
||||
@@ -404,6 +409,7 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81
|
||||
esphome/components/pn7160_spi/* @jesserockz @kbx81
|
||||
esphome/components/power_supply/* @esphome/core
|
||||
esphome/components/preferences/* @esphome/core
|
||||
esphome/components/provisioning/* @esphome/core
|
||||
esphome/components/psram/* @esphome/core
|
||||
esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston
|
||||
esphome/components/pvvx_mithermometer/* @pasiz
|
||||
@@ -426,7 +432,7 @@ esphome/components/rf_bridge/* @jesserockz
|
||||
esphome/components/rgbct/* @jesserockz
|
||||
esphome/components/ring_buffer/* @kahrendt
|
||||
esphome/components/router/speaker/* @kahrendt
|
||||
esphome/components/rp2040/* @jesserockz
|
||||
esphome/components/rp2/* @jesserockz
|
||||
esphome/components/rp2040_ble/* @bdraco
|
||||
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
|
||||
esphome/components/rp2040_pwm/* @jesserockz
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.7.0-dev
|
||||
PROJECT_NUMBER = 2026.8.0-dev
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
@@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c
|
||||
recursive-include esphome *.py.script
|
||||
recursive-include esphome *.jinja
|
||||
recursive-include esphome LICENSE.txt
|
||||
recursive-include esphome requirements.txt
|
||||
|
||||
@@ -79,6 +79,48 @@ These *are* security bugs in this repo, and we want to hear about them privately
|
||||
- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
|
||||
below their documented guarantees.
|
||||
|
||||
## The web server is an open HTTP API by design
|
||||
|
||||
The `web_server` component exposes a plain HTTP interface for viewing and
|
||||
controlling entities, and, when the `web_server` OTA platform is enabled, for
|
||||
uploading firmware at `/update`. Its only access controls are the optional
|
||||
`web_server` `auth:` credentials and the network the device sits on.
|
||||
|
||||
When `auth:` is not configured, every endpoint is reachable by any client that
|
||||
can reach the device. This is intentional; enabling `web_server` without `auth:`
|
||||
is choosing an open control surface, in the same way that running native OTA
|
||||
without a password leaves OTA open. The API is documented and is meant to be
|
||||
called by other devices, scripts, and pages.
|
||||
|
||||
As defense-in-depth, the web server checks the `Origin` header on browser requests
|
||||
to its entity control and state endpoints: a request whose `Origin` does not match
|
||||
the address the device is served on is rejected, and the `allowed_origins` option
|
||||
widens that list. This blocks the common "confused deputy" (CSRF) case where a page
|
||||
the operator visits drives the device through their browser. It is **not** an
|
||||
authentication boundary: it only constrains browsers. Any client that omits the
|
||||
`Origin` header — `curl`, scripts, or other non-browser callers on the same
|
||||
network — reaches every endpoint exactly as before. The check also does not cover
|
||||
the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer`
|
||||
validation. The following are therefore **not** vulnerabilities in this repository:
|
||||
|
||||
- Requests without an `Origin` header (for example `curl`) reaching the control
|
||||
endpoints, whether or not `web_server` `auth:` is set.
|
||||
- Requests from an origin the operator added to `allowed_origins`.
|
||||
- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when
|
||||
web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not
|
||||
covered by the `Origin` check; this is the same exposure as running OTA without a
|
||||
password.
|
||||
|
||||
The supported defenses are `web_server` `auth:`, protecting OTA (a web password or
|
||||
a native OTA password), and keeping devices on a trusted, segmented network. See
|
||||
the security best practices guide linked above.
|
||||
|
||||
What remains in scope is bypassing `web_server` `auth:` when it *is* configured,
|
||||
and any memory-safety or protocol bug in the server reachable without credentials.
|
||||
|
||||
This section documents the current design and scope; it is not a judgment that the
|
||||
design is optimal or that it will not change.
|
||||
|
||||
## Explicitly out of scope
|
||||
|
||||
- Local attackers who already have shell access on the host that runs `esphome`.
|
||||
@@ -86,6 +128,10 @@ These *are* security bugs in this repo, and we want to hear about them privately
|
||||
- Operator-supplied hostile YAML (covered above — config authoring is trusted).
|
||||
- Attacks that require an already-authenticated device peer (someone who already
|
||||
holds the API key / OTA / web credentials).
|
||||
- Access to the device web server or its web OTA endpoint by non-browser clients
|
||||
(those that send no `Origin` header). The web server is an open HTTP API by
|
||||
design (see above); browser cross-origin requests are blocked by default, but the
|
||||
real controls are `web_server` `auth:` and network isolation.
|
||||
- Anything in the dashboard / device-builder — report that in its own repository
|
||||
(linked at the top).
|
||||
- Deployments where the operator removed protections or exposed credentials. See
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ RUN \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.27
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.6.10
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
|
||||
+34
-12
@@ -16,14 +16,10 @@ import sys
|
||||
import time
|
||||
from typing import Protocol
|
||||
|
||||
import argcomplete
|
||||
|
||||
# Note: Do not import modules from esphome.components here, as this would
|
||||
# cause them to be loaded before external components are processed, resulting
|
||||
# in the built-in version being used instead of the external component one.
|
||||
from esphome import const
|
||||
import esphome.codegen as cg
|
||||
from esphome.config import iter_component_configs, read_config, strip_default_ids
|
||||
from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
@@ -355,7 +351,7 @@ def choose_upload_log_host(
|
||||
bootsel_permission_error = False
|
||||
if (
|
||||
purpose == Purpose.UPLOADING
|
||||
and CORE.is_rp2040
|
||||
and CORE.is_rp2
|
||||
and (picotool := _find_picotool()) is not None
|
||||
):
|
||||
bootsel = detect_rp2040_bootsel(picotool)
|
||||
@@ -402,7 +398,7 @@ def choose_upload_log_host(
|
||||
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
|
||||
if (
|
||||
purpose == Purpose.UPLOADING
|
||||
and CORE.is_rp2040
|
||||
and CORE.is_rp2
|
||||
and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options)
|
||||
):
|
||||
if bootsel_permission_error:
|
||||
@@ -704,6 +700,8 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
|
||||
|
||||
def _wrap_to_code(name, comp, yaml_util):
|
||||
import esphome.codegen as cg
|
||||
|
||||
coro = coroutine(comp.to_code)
|
||||
|
||||
@functools.wraps(comp.to_code)
|
||||
@@ -739,6 +737,7 @@ def write_cpp(config: ConfigType) -> int:
|
||||
|
||||
def generate_cpp_contents(config: ConfigType) -> None:
|
||||
from esphome import yaml_util
|
||||
from esphome.config import iter_component_configs
|
||||
|
||||
_LOGGER.info("Generating C++ source...")
|
||||
|
||||
@@ -776,6 +775,13 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
|
||||
check_placeholder_credentials(config)
|
||||
|
||||
# Keep this here, NOT in codegen: config-hash and --only-generate must keep
|
||||
# working on machines that cannot run the toolchain.
|
||||
if CORE.is_esp8266:
|
||||
from esphome.components.esp8266 import check_rosetta
|
||||
|
||||
check_rosetta()
|
||||
|
||||
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
|
||||
# If you change this format, update the regex in that script as well
|
||||
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
|
||||
@@ -985,7 +991,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int:
|
||||
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
|
||||
# the upload target, but 'nobuild' skips the build phase that creates it.
|
||||
# Create it here so the upload doesn't fail.
|
||||
if CORE.is_rp2040:
|
||||
if CORE.is_rp2:
|
||||
idedata = toolchain.get_idedata(config)
|
||||
build_dir = Path(idedata.firmware_elf_path).parent
|
||||
firmware_bin = build_dir / "firmware.bin"
|
||||
@@ -1173,7 +1179,7 @@ def upload_program(
|
||||
if CORE.is_esp32 or CORE.is_esp8266:
|
||||
file = getattr(args, "file", None)
|
||||
exit_code = upload_using_esptool(config, host, file, args.upload_speed)
|
||||
elif CORE.is_rp2040 or CORE.is_libretiny:
|
||||
elif CORE.is_rp2 or CORE.is_libretiny:
|
||||
exit_code = upload_using_platformio(config, host)
|
||||
# else: Unknown target platform, exit_code remains 1
|
||||
|
||||
@@ -1457,6 +1463,7 @@ def command_wizard(args: ArgsProtocol) -> int | None:
|
||||
|
||||
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
from esphome import yaml_util
|
||||
from esphome.config import strip_default_ids
|
||||
|
||||
if getattr(args, "no_defaults", False):
|
||||
user_config = getattr(config, "user_config", None)
|
||||
@@ -1510,10 +1517,18 @@ def _redact_with_legacy_fallback(output: str) -> str:
|
||||
m = _LEGACY_REDACTION_RE.search(line)
|
||||
if m is None:
|
||||
continue
|
||||
key = m.group("key")
|
||||
if not in_substitutions:
|
||||
unmarked.add(m.group("key"))
|
||||
# Public keys (e.g. wireguard's peer_public_key) are not secret;
|
||||
# redacting them and telling maintainers to mark them cv.sensitive
|
||||
# would be wrong on both counts. Substitution keys are user-named
|
||||
# with no schema behind them, so anything secret-shaped there
|
||||
# (public or not) stays conservatively redacted.
|
||||
if "public" in key.split("_"):
|
||||
continue
|
||||
unmarked.add(key)
|
||||
lines[i] = (
|
||||
f"{line[: m.start()]}{m.group('key')}: "
|
||||
f"{line[: m.start()]}{key}: "
|
||||
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
|
||||
)
|
||||
output = "\n".join(lines)
|
||||
@@ -1647,7 +1662,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
|
||||
|
||||
# After BOOTSEL upload, wait for a new serial port to appear
|
||||
# so it shows up in the log chooser
|
||||
if successful_device is None and CORE.is_rp2040:
|
||||
if successful_device is None and CORE.is_rp2:
|
||||
_wait_for_serial_port(known_ports=pre_upload_ports)
|
||||
# If exactly one new serial port appeared, use it directly
|
||||
serial_ports = get_serial_ports()
|
||||
@@ -2483,7 +2498,12 @@ def parse_args(argv):
|
||||
# a deprecation warning).
|
||||
arguments = argv[1:]
|
||||
|
||||
argcomplete.autocomplete(parser)
|
||||
# argcomplete only does anything when the shell-completion machinery
|
||||
# invokes us with _ARGCOMPLETE set; skip the import otherwise.
|
||||
if "_ARGCOMPLETE" in os.environ:
|
||||
import argcomplete
|
||||
|
||||
argcomplete.autocomplete(parser)
|
||||
|
||||
if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS:
|
||||
args, unknown_args = parser.parse_known_args(arguments)
|
||||
@@ -2582,6 +2602,8 @@ def run_esphome(argv):
|
||||
)
|
||||
|
||||
if config is None:
|
||||
from esphome.config import read_config
|
||||
|
||||
config = read_config(
|
||||
command_line_substitutions,
|
||||
skip_external_update=skip_external,
|
||||
|
||||
@@ -20,6 +20,7 @@ from . import (
|
||||
RAM_SECTIONS,
|
||||
MemoryAnalyzer,
|
||||
)
|
||||
from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import ComponentMemory
|
||||
@@ -759,45 +760,25 @@ def main():
|
||||
print(f"Error: {build_path} is not a directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find firmware.elf
|
||||
elf_file = None
|
||||
for elf_candidate in [
|
||||
build_path / "firmware.elf",
|
||||
build_path / ".pioenvs" / build_path.name / "firmware.elf",
|
||||
]:
|
||||
if elf_candidate.exists():
|
||||
elf_file = str(elf_candidate)
|
||||
break
|
||||
|
||||
if not elf_file:
|
||||
print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
|
||||
elf_path = find_elf_path(build_path)
|
||||
if not elf_path:
|
||||
print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Find idedata.json - check current directory first, then home
|
||||
device_name = build_path.name
|
||||
idedata_candidates = [
|
||||
Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
|
||||
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
|
||||
]
|
||||
elf_file = str(elf_path)
|
||||
|
||||
idedata = None
|
||||
for idedata_path in idedata_candidates:
|
||||
if not idedata_path.exists():
|
||||
continue
|
||||
if idedata_path := find_idedata_path(build_path):
|
||||
try:
|
||||
with idedata_path.open(encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
idedata = IDEData(raw_data)
|
||||
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
|
||||
break
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
|
||||
|
||||
if not idedata:
|
||||
print(
|
||||
f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
|
||||
file=sys.stderr,
|
||||
)
|
||||
searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
|
||||
print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
|
||||
|
||||
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
|
||||
analyzer.analyze()
|
||||
|
||||
@@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266.
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
@@ -65,6 +65,7 @@ class RamSymbol:
|
||||
size: int
|
||||
section: str
|
||||
demangled: str = "" # Demangled name, set after batch demangling
|
||||
aliases: list[str] = field(default_factory=list) # Other names at same address
|
||||
|
||||
|
||||
class RamStringsAnalyzer:
|
||||
@@ -235,6 +236,11 @@ class RamStringsAnalyzer:
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return
|
||||
|
||||
# Track symbols by address so aliases (multiple names for the same
|
||||
# object, e.g. the newlib __lock___* mutexes that all alias one
|
||||
# StaticSemaphore_t) are reported once instead of once per name.
|
||||
symbols_by_addr: dict[int, RamSymbol] = {}
|
||||
|
||||
for line in output.split("\n"):
|
||||
parts = line.split()
|
||||
if len(parts) < 4:
|
||||
@@ -253,6 +259,18 @@ class RamStringsAnalyzer:
|
||||
if sym_type not in DATA_SYMBOL_TYPES:
|
||||
continue
|
||||
|
||||
if (existing := symbols_by_addr.get(addr)) is not None:
|
||||
# Prefer a global (uppercase type) name as the primary so
|
||||
# nm output order can't hide it behind a local alias.
|
||||
if sym_type.isupper() and existing.sym_type.islower():
|
||||
existing.aliases.append(existing.name)
|
||||
existing.name = name
|
||||
existing.sym_type = sym_type
|
||||
else:
|
||||
existing.aliases.append(name)
|
||||
existing.size = max(existing.size, size)
|
||||
continue
|
||||
|
||||
# Check if symbol is in a RAM section
|
||||
for section_name in self.ram_sections:
|
||||
if section_name not in self.sections:
|
||||
@@ -260,15 +278,15 @@ class RamStringsAnalyzer:
|
||||
|
||||
section = self.sections[section_name]
|
||||
if section.address <= addr < section.address + section.size:
|
||||
self.ram_symbols.append(
|
||||
RamSymbol(
|
||||
name=name,
|
||||
sym_type=sym_type,
|
||||
address=addr,
|
||||
size=size,
|
||||
section=section_name,
|
||||
)
|
||||
symbol = RamSymbol(
|
||||
name=name,
|
||||
sym_type=sym_type,
|
||||
address=addr,
|
||||
size=size,
|
||||
section=section_name,
|
||||
)
|
||||
symbols_by_addr[addr] = symbol
|
||||
self.ram_symbols.append(symbol)
|
||||
break
|
||||
|
||||
def _demangle_symbols(self) -> None:
|
||||
@@ -436,7 +454,13 @@ class RamStringsAnalyzer:
|
||||
for symbol in largest_symbols:
|
||||
# Use demangled name if available, otherwise raw name
|
||||
display_name = symbol.demangled or symbol.name
|
||||
name_display = display_name[:49] if len(display_name) > 49 else display_name
|
||||
# Truncate the name, not the alias note, so merged aliases stay
|
||||
# visible even for long demangled C++ names.
|
||||
alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else ""
|
||||
max_name_len = 49 - len(alias_note)
|
||||
if len(display_name) > max_name_len:
|
||||
display_name = display_name[:max_name_len]
|
||||
name_display = display_name + alias_note
|
||||
lines.append(
|
||||
f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}"
|
||||
)
|
||||
|
||||
@@ -23,6 +23,78 @@ TOOLCHAIN_PREFIXES = [
|
||||
]
|
||||
|
||||
|
||||
def find_elf_path(build_path: Path) -> Path | None:
|
||||
"""Locate the firmware ELF inside an ESPHome build directory.
|
||||
|
||||
The layout depends on the toolchain that produced the build, so try each
|
||||
known one in turn.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
Path to the ELF file, or None if no known layout matches
|
||||
"""
|
||||
name = build_path.name
|
||||
for candidate in (
|
||||
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
|
||||
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
|
||||
build_path / "build" / "firmware.elf",
|
||||
# PlatformIO
|
||||
build_path / "firmware.elf",
|
||||
build_path / ".pioenvs" / name / "firmware.elf",
|
||||
# LibreTiny uses raw_firmware.elf
|
||||
build_path / "raw_firmware.elf",
|
||||
build_path / ".pioenvs" / name / "raw_firmware.elf",
|
||||
# Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2
|
||||
build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf",
|
||||
build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf",
|
||||
):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def idedata_candidates(build_path: Path) -> list[Path]:
|
||||
"""Return the idedata locations searched for a build directory, in order.
|
||||
|
||||
Exposed so a caller reporting "not found" can name the paths it tried
|
||||
without keeping its own copy of the list.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
The candidate idedata JSON paths, most specific first
|
||||
"""
|
||||
name = build_path.name
|
||||
return [
|
||||
# In .pioenvs for test builds
|
||||
build_path / ".pioenvs" / name / "idedata.json",
|
||||
# Both toolchains cache it in the data dir, which holds this build dir:
|
||||
# <data_dir>/idedata/<name>.json next to <data_dir>/build/<name>
|
||||
build_path.parent.parent / "idedata" / f"{name}.json",
|
||||
# Regular builds, invoked from the config dir or from anywhere
|
||||
Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
|
||||
Path.home() / ".esphome" / "idedata" / f"{name}.json",
|
||||
]
|
||||
|
||||
|
||||
def find_idedata_path(build_path: Path) -> Path | None:
|
||||
"""Locate the idedata JSON belonging to an ESPHome build directory.
|
||||
|
||||
Args:
|
||||
build_path: Path to an ESPHome build directory
|
||||
|
||||
Returns:
|
||||
Path to the idedata JSON, or None if it was not found
|
||||
"""
|
||||
for candidate in idedata_candidates(build_path):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _find_in_platformio_packages(tool_name: str) -> str | None:
|
||||
"""Search for a tool in PlatformIO package directories.
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ from pathlib import Path
|
||||
from esphome.components.esp32 import get_esp32_variant, idf_version
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
from esphome.framework_helpers import get_project_compile_flags, get_project_link_flags
|
||||
from esphome.framework_helpers import (
|
||||
get_project_compile_flags,
|
||||
get_project_cxx_compile_flags,
|
||||
get_project_link_flags,
|
||||
)
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
|
||||
# Replaces the IDF default C++ standard (-std=gnu++2b appended to
|
||||
@@ -91,6 +95,14 @@ def get_project_cmakelists(minimal: bool = False) -> str:
|
||||
for flag in project_compile_opts
|
||||
)
|
||||
|
||||
# Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
|
||||
# (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
|
||||
# -Wno-volatile is passed on a C compile.
|
||||
cxx_compile_options = "\n".join(
|
||||
f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
|
||||
for flag in get_project_cxx_compile_flags()
|
||||
)
|
||||
|
||||
cpp_standard_options = (
|
||||
CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard)
|
||||
if CORE.cpp_standard
|
||||
@@ -155,6 +167,8 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
|
||||
|
||||
{cpp_standard_options}
|
||||
|
||||
{cxx_compile_options}
|
||||
|
||||
{extra_compile_options}
|
||||
|
||||
{managed_components_property}
|
||||
|
||||
@@ -108,7 +108,6 @@ Import("env")
|
||||
def write_cxx_flags_script() -> None:
|
||||
path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME)
|
||||
contents = CXX_FLAGS_FILE_CONTENTS
|
||||
if not CORE.is_host:
|
||||
contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])'
|
||||
contents += "\n"
|
||||
for flag in sorted(CORE.cxx_build_flags):
|
||||
contents += f'env.Append(CXXFLAGS=["{flag}"])\n'
|
||||
write_file_if_changed(path, contents)
|
||||
|
||||
+135
-3
@@ -7,12 +7,12 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from enum import StrEnum
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
|
||||
import re
|
||||
import shutil
|
||||
import tarfile
|
||||
@@ -32,6 +32,8 @@ from esphome.core import CORE, EsphomeError
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "bundle"
|
||||
|
||||
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
|
||||
MANIFEST_FILENAME = "manifest.json"
|
||||
CURRENT_MANIFEST_VERSION = 1
|
||||
@@ -49,6 +51,7 @@ class ManifestKey(StrEnum):
|
||||
MANIFEST_VERSION = "manifest_version"
|
||||
ESPHOME_VERSION = "esphome_version"
|
||||
CONFIG_FILENAME = "config_filename"
|
||||
CONFIG_DIR = "config_dir"
|
||||
FILES = "files"
|
||||
HAS_SECRETS = "has_secrets"
|
||||
|
||||
@@ -110,6 +113,126 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
|
||||
return keys
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleData:
|
||||
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
|
||||
|
||||
extra_files: list[Path] = field(default_factory=list)
|
||||
# Original config dir parsed from an extracted bundle's manifest.json,
|
||||
# kept in the path flavor of the machine the bundle was created on.
|
||||
# The checked flag makes the manifest lookup happen at most once per run;
|
||||
# CORE.data is cleared between runs.
|
||||
original_config_dir: PurePath | None = None
|
||||
original_config_dir_checked: bool = False
|
||||
|
||||
|
||||
def _get_data() -> BundleData:
|
||||
if DOMAIN not in CORE.data:
|
||||
CORE.data[DOMAIN] = BundleData()
|
||||
return CORE.data[DOMAIN]
|
||||
|
||||
|
||||
def add_bundle_file(path: Path) -> None:
|
||||
"""Register a file that a bundle must include.
|
||||
|
||||
Bundle discovery walks the validated config, so it only finds files the config
|
||||
names. Components call this during validation for files it cannot see, such as a
|
||||
file that is referenced from inside another file.
|
||||
|
||||
A relative path is taken as relative to the config directory. Files outside the
|
||||
config directory are skipped when the bundle is built.
|
||||
"""
|
||||
_get_data().extra_files.append(CORE.relative_config_path(path))
|
||||
|
||||
|
||||
# Windows paths start with a drive letter or contain backslashes; POSIX
|
||||
# paths do neither in practice, so this is how the flavor of a recorded
|
||||
# path string is recognized on any host.
|
||||
_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
|
||||
|
||||
|
||||
def _path_flavor(value: str) -> type[PurePath]:
|
||||
"""Pick the pure path class matching the flavor ``value`` was written in."""
|
||||
if "\\" in value or _WINDOWS_DRIVE_RE.match(value):
|
||||
return PureWindowsPath
|
||||
return PurePosixPath
|
||||
|
||||
|
||||
def _load_original_config_dir() -> PurePath | None:
|
||||
"""Read the original config dir from an extracted bundle's manifest.
|
||||
|
||||
Returns None when the current config dir is not an extracted bundle or
|
||||
the manifest does not record the original config dir.
|
||||
"""
|
||||
manifest_path = CORE.config_dir / MANIFEST_FILENAME
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
# The common case: this config dir is not an extracted bundle.
|
||||
return None
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err:
|
||||
# A manifest.json is present but unreadable or malformed. Say so
|
||||
# instead of letting it look identical to "not a bundle".
|
||||
_LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err)
|
||||
return None
|
||||
if not isinstance(manifest, dict):
|
||||
return None
|
||||
# A manifest.json in the config dir does not have to be ours. Only trust
|
||||
# one that looks like a bundle manifest for exactly this config file.
|
||||
version = manifest.get(ManifestKey.MANIFEST_VERSION)
|
||||
if not isinstance(version, int) or version < 1:
|
||||
return None
|
||||
if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name:
|
||||
return None
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
if not isinstance(config_dir, str) or not config_dir:
|
||||
return None
|
||||
return _path_flavor(config_dir)(config_dir)
|
||||
|
||||
|
||||
def remap_bundle_path(value: str) -> Path | None:
|
||||
"""Remap an absolute path from the machine a bundle was created on.
|
||||
|
||||
A bundled config may reference files by absolute path. The referenced
|
||||
files ship inside the bundle at their config-relative locations, but the
|
||||
YAML text is copied verbatim, so after extraction on another machine the
|
||||
absolute reference points at a path that only existed on the creating
|
||||
machine. The bundle manifest records that machine's config dir; when
|
||||
``value`` names a path that lived under it, return the corresponding
|
||||
file next to the extracted config.
|
||||
|
||||
``value`` is the raw path string from the config. It is parsed with the
|
||||
original machine's path flavor, so a bundle created on Windows remaps on
|
||||
a POSIX build server and vice versa.
|
||||
|
||||
Returns None when not compiling an extracted bundle, when ``value`` was
|
||||
not under the original config dir, or when the bundle does not contain
|
||||
the file.
|
||||
"""
|
||||
data = _get_data()
|
||||
if not data.original_config_dir_checked:
|
||||
data.original_config_dir_checked = True
|
||||
data.original_config_dir = _load_original_config_dir()
|
||||
original_dir = data.original_config_dir
|
||||
if original_dir is None:
|
||||
return None
|
||||
path = type(original_dir)(value)
|
||||
if not path.is_absolute():
|
||||
return None
|
||||
try:
|
||||
rel = path.relative_to(original_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
# relative_to is lexical, so ".." segments survive it. Refuse them: the
|
||||
# remapped file must land strictly inside the extracted config tree.
|
||||
if ".." in rel.parts:
|
||||
return None
|
||||
remapped = CORE.relative_config_path(Path(*rel.parts))
|
||||
if not remapped.exists():
|
||||
return None
|
||||
return remapped
|
||||
|
||||
|
||||
@dataclass
|
||||
class BundleFile:
|
||||
"""A file to include in the bundle."""
|
||||
@@ -136,6 +259,7 @@ class BundleManifest:
|
||||
config_filename: str
|
||||
files: list[str]
|
||||
has_secrets: bool
|
||||
config_dir: str | None = None
|
||||
|
||||
|
||||
class ConfigBundleCreator:
|
||||
@@ -276,13 +400,18 @@ class ConfigBundleCreator:
|
||||
with known file extensions are also resolved and checked.
|
||||
|
||||
Core ESPHome concepts that use relative paths or directories
|
||||
are handled explicitly.
|
||||
are handled explicitly. Files the config does not name at all are
|
||||
registered by their component with add_bundle_file().
|
||||
"""
|
||||
config = self._config
|
||||
|
||||
# Generic walk: find all file paths in the validated config
|
||||
self._walk_config_for_files(config)
|
||||
|
||||
# Files registered by components during validation
|
||||
for extra_file in _get_data().extra_files:
|
||||
self._add_file(extra_file)
|
||||
|
||||
# --- Core ESPHome concepts needing explicit handling ---
|
||||
|
||||
# esphome.includes / includes_c - can be relative paths and directories
|
||||
@@ -395,6 +524,7 @@ class ConfigBundleCreator:
|
||||
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
|
||||
ManifestKey.ESPHOME_VERSION: const.__version__,
|
||||
ManifestKey.CONFIG_FILENAME: self._config_path.name,
|
||||
ManifestKey.CONFIG_DIR: str(self._config_dir),
|
||||
ManifestKey.FILES: [f.path for f in files],
|
||||
ManifestKey.HAS_SECRETS: has_secrets,
|
||||
}
|
||||
@@ -479,12 +609,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
|
||||
except tarfile.TarError as err:
|
||||
raise EsphomeError(f"Failed to read bundle: {err}") from err
|
||||
|
||||
config_dir = manifest.get(ManifestKey.CONFIG_DIR)
|
||||
return BundleManifest(
|
||||
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
|
||||
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
|
||||
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
|
||||
files=manifest.get(ManifestKey.FILES, []),
|
||||
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
|
||||
config_dir=config_dir if isinstance(config_dir, str) else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401
|
||||
add,
|
||||
add_build_flag,
|
||||
add_build_unflag,
|
||||
add_cxx_build_flag,
|
||||
add_define,
|
||||
add_global,
|
||||
add_library,
|
||||
@@ -52,6 +53,7 @@ from esphome.cpp_helpers import ( # noqa: F401
|
||||
past_safe_mode,
|
||||
register_component,
|
||||
register_parented,
|
||||
set_setup_priority,
|
||||
)
|
||||
from esphome.cpp_types import ( # noqa: F401
|
||||
NAN,
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Importing `esphome.loader` here installs the component-alias
|
||||
# ``sys.meta_path`` finder before any submodule lookup runs. Without this,
|
||||
# `from esphome.components import <legacy_alias>` from a fresh interpreter
|
||||
# can race the finder install and raise ImportError, since the legacy
|
||||
# alias dir no longer exists on disk.
|
||||
from esphome import loader as _loader # noqa: F401
|
||||
|
||||
@@ -227,12 +227,12 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = {
|
||||
|
||||
def validate_adc_pin(value):
|
||||
if str(value).upper() == "VCC":
|
||||
if CORE.is_rp2040:
|
||||
if CORE.is_rp2:
|
||||
return pins.internal_gpio_input_pin_schema(29)
|
||||
return cv.only_on([PLATFORM_ESP8266])("VCC")
|
||||
|
||||
if str(value).upper() == "TEMPERATURE":
|
||||
return cv.only_on_rp2040("TEMPERATURE")
|
||||
return cv.only_on_rp2("TEMPERATURE")
|
||||
|
||||
if CORE.is_esp32:
|
||||
conf = pins.internal_gpio_input_pin_schema(value)
|
||||
@@ -261,11 +261,11 @@ def validate_adc_pin(value):
|
||||
raise cv.Invalid("ESP8266: Only pin A0 (GPIO17) supports ADC")
|
||||
return conf
|
||||
|
||||
if CORE.is_rp2040:
|
||||
if CORE.is_rp2:
|
||||
conf = pins.internal_gpio_input_pin_schema(value)
|
||||
number = conf[CONF_NUMBER]
|
||||
if number not in (26, 27, 28, 29):
|
||||
raise cv.Invalid("RP2040: Only pins 26, 27, 28 and 29 support ADC")
|
||||
raise cv.Invalid("RP2: Only pins 26, 27, 28 and 29 support ADC")
|
||||
return conf
|
||||
|
||||
if CORE.is_libretiny:
|
||||
|
||||
@@ -123,9 +123,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
|
||||
void set_autorange(bool autorange) { this->autorange_ = autorange; }
|
||||
#endif // USE_ESP32
|
||||
|
||||
#ifdef USE_RP2040
|
||||
#ifdef USE_RP2
|
||||
void set_is_temperature() { this->is_temperature_ = true; }
|
||||
#endif // USE_RP2040
|
||||
#endif // USE_RP2
|
||||
|
||||
protected:
|
||||
uint8_t sample_count_{1};
|
||||
@@ -152,9 +152,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
|
||||
static adc_oneshot_unit_handle_t shared_adc_handles[2];
|
||||
#endif // USE_ESP32
|
||||
|
||||
#ifdef USE_RP2040
|
||||
#ifdef USE_RP2
|
||||
bool is_temperature_{false};
|
||||
#endif // USE_RP2040
|
||||
#endif // USE_RP2
|
||||
|
||||
#ifdef USE_ZEPHYR
|
||||
const struct adc_dt_spec *channel_ = nullptr;
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
#ifdef USE_RP2040
|
||||
#ifdef USE_RP2
|
||||
|
||||
#include "adc_sensor.h"
|
||||
#include "esphome/core/log.h"
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
namespace esphome::adc {
|
||||
|
||||
static const char *const TAG = "adc.rp2040";
|
||||
static const char *const TAG = "adc.rp2";
|
||||
|
||||
void ADCSensor::setup() {
|
||||
static bool initialized = false;
|
||||
@@ -102,4 +102,4 @@ float ADCSensor::sample() {
|
||||
|
||||
} // namespace esphome::adc
|
||||
|
||||
#endif // USE_RP2040
|
||||
#endif // USE_RP2
|
||||
@@ -201,7 +201,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
},
|
||||
"adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO},
|
||||
"adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO},
|
||||
"adc_sensor_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
|
||||
"adc_sensor_libretiny.cpp": {
|
||||
PlatformFramework.BK72XX_ARDUINO,
|
||||
PlatformFramework.RTL87XX_ARDUINO,
|
||||
|
||||
@@ -1,114 +1,36 @@
|
||||
import logging
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy top-level `animation:` deprecation shim -- REMOVE this whole file after
|
||||
# 2027.1.0.
|
||||
#
|
||||
# Animations are now a platform of the `image:` component (`platform:
|
||||
# animation`); the real schema, actions and codegen live in `image.py`. This
|
||||
# module only keeps the deprecated top-level `animation:` key working during the
|
||||
# deprecation window: it reuses that schema/codegen and adds a one-shot
|
||||
# deprecation warning (with a pasteable migrated `image:` block) at validation
|
||||
# time. Deleting this file drops the top-level form entirely.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LOOP
|
||||
import esphome.components.image as espImage
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_REPEAT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
|
||||
|
||||
AUTO_LOAD = ["image"]
|
||||
AUTO_LOAD = ["image", "file"]
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
DEPENDENCIES = ["display"]
|
||||
MULTI_CONF = True
|
||||
MULTI_CONF_NO_DEFAULT = True
|
||||
|
||||
CONF_START_FRAME = "start_frame"
|
||||
CONF_END_FRAME = "end_frame"
|
||||
CONF_FRAME = "frame"
|
||||
DOMAIN = "animation"
|
||||
|
||||
animation_ns = cg.esphome_ns.namespace("animation")
|
||||
LEGACY_REMOVAL_VERSION = "2027.1.0"
|
||||
|
||||
Animation_ = animation_ns.class_("Animation", espImage.Image_)
|
||||
|
||||
# Actions
|
||||
NextFrameAction = animation_ns.class_(
|
||||
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
PrevFrameAction = animation_ns.class_(
|
||||
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
SetFrameAction = animation_ns.class_(
|
||||
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
_capture_legacy_entry, _warn_legacy_animation = (
|
||||
espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION)
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
espImage.IMAGE_SCHEMA.extend(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.declare_id(Animation_),
|
||||
cv.Optional(CONF_LOOP): cv.All(
|
||||
{
|
||||
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
|
||||
cv.Optional(CONF_END_FRAME): cv.positive_int,
|
||||
cv.Optional(CONF_REPEAT): cv.positive_int,
|
||||
}
|
||||
),
|
||||
},
|
||||
),
|
||||
espImage.validate_settings,
|
||||
)
|
||||
CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ANIMATION_CONFIG_SCHEMA)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _warn_legacy_animation
|
||||
|
||||
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
SET_FRAME_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
cv.Required(CONF_FRAME): cv.uint16_t,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
async def animation_action_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
if (frame := config.get(CONF_FRAME)) is not None:
|
||||
template_ = await cg.templatable(frame, args, cg.uint16)
|
||||
cg.add(var.set_frame(template_))
|
||||
return var
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
(
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
image_type,
|
||||
trans_value,
|
||||
frame_count,
|
||||
) = await espImage.write_image(config, all_frames=True)
|
||||
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
frame_count,
|
||||
image_type,
|
||||
trans_value,
|
||||
)
|
||||
if loop_config := config.get(CONF_LOOP):
|
||||
start = loop_config[CONF_START_FRAME]
|
||||
end = loop_config.get(CONF_END_FRAME, frame_count)
|
||||
count = loop_config.get(CONF_REPEAT, -1)
|
||||
cg.add(var.set_loop(start, end, count))
|
||||
to_code = setup_animation
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.const import CONF_LOOP
|
||||
from esphome.components.file.image import image_schema, write_image
|
||||
from esphome.components.image import Image_, validate_settings
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_REPEAT
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@syndlex"]
|
||||
AUTO_LOAD = ["file"]
|
||||
DEPENDENCIES = ["display"]
|
||||
|
||||
CONF_START_FRAME = "start_frame"
|
||||
CONF_END_FRAME = "end_frame"
|
||||
CONF_FRAME = "frame"
|
||||
|
||||
animation_ns = cg.esphome_ns.namespace("animation")
|
||||
|
||||
Animation_ = animation_ns.class_("Animation", Image_)
|
||||
|
||||
# Actions
|
||||
NextFrameAction = animation_ns.class_(
|
||||
"AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
PrevFrameAction = animation_ns.class_(
|
||||
"AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
SetFrameAction = animation_ns.class_(
|
||||
"AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
|
||||
)
|
||||
|
||||
ANIMATION_SCHEMA = image_schema(Animation_).extend(
|
||||
{
|
||||
cv.Optional(CONF_LOOP): cv.All(
|
||||
{
|
||||
cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
|
||||
cv.Optional(CONF_END_FRAME): cv.positive_int,
|
||||
cv.Optional(CONF_REPEAT): cv.positive_int,
|
||||
}
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
# Shared schema used by both the (deprecated) top-level `animation:` key and the
|
||||
# `image:` `platform: animation` entry.
|
||||
ANIMATION_CONFIG_SCHEMA = cv.All(ANIMATION_SCHEMA, validate_settings)
|
||||
|
||||
|
||||
NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
PREV_FRAME_SCHEMA = automation.maybe_simple_id(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
}
|
||||
)
|
||||
SET_FRAME_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.use_id(Animation_),
|
||||
cv.Required(CONF_FRAME): cv.uint16_t,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
@automation.register_action(
|
||||
"animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
|
||||
)
|
||||
async def animation_action_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
|
||||
if (frame := config.get(CONF_FRAME)) is not None:
|
||||
template_ = await cg.templatable(frame, args, cg.uint16)
|
||||
cg.add(var.set_frame(template_))
|
||||
return var
|
||||
|
||||
|
||||
async def setup_animation(config: ConfigType) -> None:
|
||||
(
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
image_type,
|
||||
trans_value,
|
||||
frame_count,
|
||||
) = await write_image(config, all_frames=True)
|
||||
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
prog_arr,
|
||||
width,
|
||||
height,
|
||||
frame_count,
|
||||
image_type,
|
||||
trans_value,
|
||||
)
|
||||
if loop_config := config.get(CONF_LOOP):
|
||||
start = loop_config[CONF_START_FRAME]
|
||||
end = loop_config.get(CONF_END_FRAME, frame_count)
|
||||
count = loop_config.get(CONF_REPEAT, -1)
|
||||
cg.add(var.set_loop(start, end, count))
|
||||
|
||||
|
||||
CONFIG_SCHEMA = ANIMATION_CONFIG_SCHEMA
|
||||
|
||||
to_code = setup_animation
|
||||
@@ -112,6 +112,23 @@ CONF_MAX_SEND_QUEUE = "max_send_queue"
|
||||
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
|
||||
|
||||
|
||||
def _register_provisioning_source(config: ConfigType) -> ConfigType:
|
||||
"""Register the API as a provisioning source when encryption is enabled.
|
||||
|
||||
With no ``key`` the device boots unprovisioned and is set up on first
|
||||
connection; a YAML ``key`` means it is born provisioned. Either way the API
|
||||
drives the provisioning manager, so it counts as a source for `provisioning:`.
|
||||
A hardcoded ``key`` is reported so `provisioning:` can warn about it.
|
||||
"""
|
||||
if (encryption := config.get(CONF_ENCRYPTION)) is not None:
|
||||
from esphome.components import provisioning
|
||||
|
||||
provisioning.register_source("api")
|
||||
if CONF_KEY in encryption:
|
||||
provisioning.report_hardcoded_credentials("api")
|
||||
return config
|
||||
|
||||
|
||||
def validate_encryption_key(value):
|
||||
value = cv.string_strict(value)
|
||||
try:
|
||||
@@ -300,7 +317,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_LISTEN_BACKLOG,
|
||||
esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets
|
||||
esp32=4, # More RAM (520KB), BSD sockets
|
||||
rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266
|
||||
rp2=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266
|
||||
bk72xx=4, # Moderate RAM, BSD-style sockets
|
||||
rtl87xx=4, # Moderate RAM, BSD-style sockets
|
||||
host=4, # Abundant resources
|
||||
@@ -311,7 +328,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_MAX_CONNECTIONS,
|
||||
esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes
|
||||
esp32=5, # 520KB RAM available
|
||||
rp2040=4, # 264KB RAM but LWIP constraints
|
||||
rp2=4, # 264KB RAM but LWIP constraints
|
||||
bk72xx=5, # Moderate RAM
|
||||
rtl87xx=5, # Moderate RAM
|
||||
host=8, # Abundant resources
|
||||
@@ -326,7 +343,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
CONF_MAX_SEND_QUEUE,
|
||||
esp8266=4, # Limited RAM, need to fail fast
|
||||
esp32=8, # More RAM, can buffer more
|
||||
rp2040=8, # Moderate RAM
|
||||
rp2=8, # Moderate RAM
|
||||
bk72xx=8, # Moderate RAM
|
||||
nrf52=8, # Moderate RAM
|
||||
rtl87xx=8, # Moderate RAM
|
||||
@@ -337,6 +354,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
).extend(cv.COMPONENT_SCHEMA),
|
||||
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
|
||||
_consume_api_sockets,
|
||||
_register_provisioning_source,
|
||||
)
|
||||
|
||||
|
||||
@@ -470,8 +488,11 @@ async def to_code(config: ConfigType) -> None:
|
||||
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
|
||||
else:
|
||||
# No key provided, but encryption desired
|
||||
# This will allow a plaintext client to provide a noise key,
|
||||
# send it to the device, and then switch to noise.
|
||||
# Until a key is set, the device accepts both Noise connections
|
||||
# using the well-known all-zeros PSK (preferred: the key travels
|
||||
# encrypted, protecting against passive sniffing) and plaintext
|
||||
# connections (deprecated, remove after 2027.2.0) so a client can
|
||||
# provide a noise key and the device then switches to noise only.
|
||||
# The key will be saved in flash and used for future connections
|
||||
# and plaintext disabled. Only a factory reset can remove it.
|
||||
cg.add_define("USE_API_PLAINTEXT")
|
||||
|
||||
@@ -160,6 +160,16 @@ message AuthenticationResponse {
|
||||
bool invalid_password = 1;
|
||||
}
|
||||
|
||||
// Reason a party is requesting the connection be closed.
|
||||
enum DisconnectReason {
|
||||
// No specific reason / not provided (default for older peers).
|
||||
DISCONNECT_REASON_UNSPECIFIED = 0;
|
||||
// The device's provisioning window has expired. The device must be reset
|
||||
// (power-cycled) to reopen the provisioning window before it will accept a
|
||||
// connection again.
|
||||
DISCONNECT_REASON_PROVISIONING_CLOSED = 1;
|
||||
}
|
||||
|
||||
// Request to close the connection.
|
||||
// Can be sent by both the client and server
|
||||
message DisconnectRequest {
|
||||
@@ -168,6 +178,10 @@ message DisconnectRequest {
|
||||
option (no_delay) = true;
|
||||
|
||||
// Do not close the connection before the acknowledgement arrives
|
||||
|
||||
// Optional reason the connection is being closed. Older peers that do not
|
||||
// send this field will report DISCONNECT_REASON_UNSPECIFIED (0).
|
||||
DisconnectReason reason = 1;
|
||||
}
|
||||
|
||||
message DisconnectResponse {
|
||||
@@ -299,10 +313,15 @@ message DeviceInfoResponse {
|
||||
// Serial proxy instance metadata
|
||||
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
|
||||
|
||||
// Device is unprovisioned and accepts Noise handshakes with the well-known
|
||||
// all-zeros PSK, so the api encryption key can be provisioned without being
|
||||
// sent in plaintext (protects against passive sniffing, not active MITM)
|
||||
bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
|
||||
|
||||
// Whether this firmware embeds its YAML configuration for recovery via
|
||||
// `get_yaml`. Clients use this to skip the request entirely when the
|
||||
// device cannot answer it instead of waiting for a timeout.
|
||||
bool has_store_yaml = 26 [(field_ifdef) = "USE_STORE_YAML"];
|
||||
bool has_store_yaml = 27 [(field_ifdef) = "USE_STORE_YAML"];
|
||||
}
|
||||
|
||||
message ListEntitiesRequest {
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/version.h"
|
||||
#ifdef USE_PROVISIONING
|
||||
#include "esphome/components/provisioning/provisioning.h"
|
||||
#endif
|
||||
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
#include "esphome/components/deep_sleep/deep_sleep_component.h"
|
||||
@@ -198,6 +201,29 @@ APIConnection::~APIConnection() {
|
||||
#endif
|
||||
}
|
||||
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
void APIConnection::upgrade_helper_to_noise_() {
|
||||
// The client opened with a Noise hello while this device has no encryption
|
||||
// key set. Replace the plaintext helper with a Noise helper so the key can
|
||||
// be provisioned over an encrypted channel: the noise context PSK is all
|
||||
// zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519
|
||||
// exchange, so a passive listener cannot read the session. A publicly known
|
||||
// PSK authenticates nobody; this protects against sniffing only.
|
||||
auto *plaintext = static_cast<APIPlaintextFrameHelper *>(this->helper_.get());
|
||||
uint8_t header[3];
|
||||
uint8_t header_len = plaintext->get_consumed_header(header);
|
||||
auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx());
|
||||
// Carry over the peername-based client name (Hello has not arrived yet)
|
||||
const char *name = plaintext->get_client_name();
|
||||
noise->set_client_name(name, strlen(name));
|
||||
this->helper_.reset(noise); // destroys the plaintext helper
|
||||
APIError err = noise->init_from_handoff(header, header_len);
|
||||
if (err != APIError::OK) {
|
||||
this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err);
|
||||
}
|
||||
}
|
||||
#endif // USE_API_NOISE && USE_API_PLAINTEXT
|
||||
|
||||
void APIConnection::destroy_active_iterator_() {
|
||||
switch (this->active_iterator_) {
|
||||
case ActiveIterator::LIST_ENTITIES:
|
||||
@@ -256,6 +282,15 @@ void APIConnection::loop() {
|
||||
// No more data available
|
||||
break;
|
||||
} else if (err != APIError::OK) {
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
// Checked inside the error branch to keep the hot err == OK path
|
||||
// free of it; this can only fire on the first bytes of a plaintext
|
||||
// helper on an unprovisioned device
|
||||
if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) {
|
||||
this->upgrade_helper_to_noise_();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
this->fatal_error_with_log_(LOG_STR("Reading failed"), err);
|
||||
return;
|
||||
} else {
|
||||
@@ -769,6 +804,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();
|
||||
@@ -1428,7 +1464,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet
|
||||
|
||||
#ifdef USE_ZWAVE_PROXY
|
||||
void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
|
||||
zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len);
|
||||
zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len);
|
||||
}
|
||||
|
||||
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
|
||||
@@ -1513,6 +1549,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);
|
||||
}
|
||||
|
||||
@@ -1791,12 +1828,6 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(),
|
||||
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
|
||||
|
||||
// TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal)
|
||||
if (!this->client_supports_api_version(1, 14)) {
|
||||
ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(),
|
||||
this->client_api_version_major_, this->client_api_version_minor_);
|
||||
}
|
||||
|
||||
HelloResponse resp;
|
||||
resp.api_version_major = 1;
|
||||
resp.api_version_minor = 14;
|
||||
@@ -1804,6 +1835,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
|
||||
resp.server_info = ESPHOME_VERSION_REF;
|
||||
resp.name = StringRef(App.get_name());
|
||||
|
||||
#ifdef USE_PROVISIONING
|
||||
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
|
||||
// The provisioning window has closed without the device being provisioned.
|
||||
// Acknowledge the hello so the client can read the server name, then request
|
||||
// disconnect with the reason. Authentication is intentionally not completed.
|
||||
this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
|
||||
this->send_message(resp);
|
||||
DisconnectRequest req;
|
||||
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
|
||||
return this->send_message(req);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
|
||||
this->complete_authentication_();
|
||||
|
||||
@@ -1839,7 +1883,7 @@ bool APIConnection::send_device_info_response_() {
|
||||
// Manufacturer string - define once, handle ESP8266 PROGMEM separately
|
||||
#if defined(USE_ESP8266) || defined(USE_ESP32)
|
||||
#define ESPHOME_MANUFACTURER "Espressif"
|
||||
#elif defined(USE_RP2040)
|
||||
#elif defined(USE_RP2)
|
||||
#define ESPHOME_MANUFACTURER "Raspberry Pi"
|
||||
#elif defined(USE_BK72XX)
|
||||
#define ESPHOME_MANUFACTURER "Beken"
|
||||
@@ -1928,6 +1972,12 @@ bool APIConnection::send_device_info_response_() {
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
resp.api_encryption_supported = true;
|
||||
#ifndef USE_API_NOISE_PSK_FROM_YAML
|
||||
// No key from YAML: while no key is set, the key can be provisioned over a
|
||||
// zero-PSK Noise connection. Gated on the YAML define (not the plaintext
|
||||
// one) so this advertisement survives the plaintext removal in 2027.2.0.
|
||||
resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
|
||||
#endif
|
||||
#endif
|
||||
#ifdef USE_DEVICES
|
||||
size_t device_index = 0;
|
||||
@@ -1958,7 +2008,8 @@ void APIConnection::on_hello_request(const HelloRequest &msg) {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
}
|
||||
void APIConnection::on_disconnect_request() {
|
||||
void APIConnection::on_disconnect_request(const DisconnectRequest & /*msg*/) {
|
||||
// The reason is informational when a client disconnects us; we always ack and close.
|
||||
if (!this->send_disconnect_response_()) {
|
||||
this->on_fatal_error();
|
||||
}
|
||||
@@ -2086,6 +2137,15 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
|
||||
NoiseEncryptionSetKeyResponse resp;
|
||||
resp.success = false;
|
||||
|
||||
#ifdef USE_PROVISIONING
|
||||
// Refuse to set a key once the provisioning window has closed (defense in depth;
|
||||
// such connections are already rejected at hello).
|
||||
if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
|
||||
ESP_LOGW(TAG, "Provisioning closed; rejecting key set");
|
||||
return this->send_message(resp);
|
||||
}
|
||||
#endif
|
||||
|
||||
psk_t psk{};
|
||||
if (msg.key_len == 0) {
|
||||
if (this->parent_->clear_noise_psk(true)) {
|
||||
@@ -2095,10 +2155,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
|
||||
}
|
||||
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
|
||||
ESP_LOGW(TAG, "Invalid encryption key length");
|
||||
} else if (APINoiseContext::is_all_zeros(psk)) {
|
||||
// Accepting the reserved provisioning PSK would report success without
|
||||
// enabling encryption (or silently clear an existing key)
|
||||
ESP_LOGW(TAG, "Rejecting all-zero encryption key");
|
||||
} else if (!this->parent_->save_noise_psk(psk, true)) {
|
||||
ESP_LOGW(TAG, "Failed to save encryption key");
|
||||
} else {
|
||||
resp.success = true;
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
if (this->helper_->frame_footer_size() == 0) {
|
||||
// Plaintext transport has no frame footer; Noise always has the MAC footer.
|
||||
// Remove after 2027.2.0 together with plaintext support on keyless devices.
|
||||
ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
return this->send_message(resp);
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
#ifdef USE_ESP32_CRASH_HANDLER
|
||||
#include "esphome/components/esp32/crash_handler.h"
|
||||
#endif
|
||||
#ifdef USE_RP2040_CRASH_HANDLER
|
||||
#include "esphome/components/rp2040/crash_handler.h"
|
||||
#ifdef USE_RP2_CRASH_HANDLER
|
||||
#include "esphome/components/rp2/crash_handler.h"
|
||||
#endif
|
||||
#ifdef USE_ESP8266_CRASH_HANDLER
|
||||
#include "esphome/components/esp8266/crash_handler.h"
|
||||
@@ -169,10 +169,14 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
#endif
|
||||
bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len);
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
// Returns whether this client has subscribed to Home Assistant actions; the message
|
||||
// is only handed to the send path when subscribed. A true return does not guarantee
|
||||
// delivery - it lets the caller warn when no connected client has the subscription.
|
||||
bool send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
if (!this->flags_.service_call_subscription)
|
||||
return;
|
||||
return false;
|
||||
this->send_message(call);
|
||||
return true;
|
||||
}
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
|
||||
@@ -262,7 +266,7 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void on_get_time_response(const GetTimeResponse &value);
|
||||
#endif
|
||||
void on_hello_request(const HelloRequest &msg);
|
||||
void on_disconnect_request();
|
||||
void on_disconnect_request(const DisconnectRequest &msg);
|
||||
void on_ping_request();
|
||||
void on_device_info_request();
|
||||
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
|
||||
@@ -282,8 +286,8 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
esp32::crash_handler_log();
|
||||
esp32::crash_handler_clear();
|
||||
#endif
|
||||
#ifdef USE_RP2040_CRASH_HANDLER
|
||||
rp2040::crash_handler_log();
|
||||
#ifdef USE_RP2_CRASH_HANDLER
|
||||
rp2::crash_handler_log();
|
||||
#endif
|
||||
#ifdef USE_ESP8266_CRASH_HANDLER
|
||||
esp8266::crash_handler_log();
|
||||
@@ -635,6 +639,11 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
void destroy_active_iterator_();
|
||||
void begin_iterator_(ActiveIterator type);
|
||||
void finalize_iterator_sync_();
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
// Swap the plaintext helper for a Noise helper after the client opened
|
||||
// with a Noise hello on an unprovisioned device (zero-PSK provisioning).
|
||||
void upgrade_helper_to_noise_();
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
std::unique_ptr<camera::CameraImageReader> image_reader_;
|
||||
#endif
|
||||
|
||||
@@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) {
|
||||
return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE");
|
||||
}
|
||||
#endif
|
||||
// PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before
|
||||
// any logging can happen, so it intentionally has no entry here.
|
||||
return LOG_STR("UNKNOWN");
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,11 @@ enum class APIError : uint16_t {
|
||||
HANDSHAKESTATE_SPLIT_FAILED = 1020,
|
||||
BAD_HANDSHAKE_ERROR_BYTE = 1021,
|
||||
#endif
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
// Not an error: an unprovisioned device received a Noise client hello on a
|
||||
// plaintext connection; the caller must hand the socket off to a Noise helper.
|
||||
PROTOCOL_SWITCH_TO_NOISE = 1023,
|
||||
#endif
|
||||
};
|
||||
|
||||
const LogString *api_error_to_logstr(APIError err);
|
||||
@@ -200,6 +205,12 @@ class APIFrameHelper {
|
||||
// or track that they stopped early and retry without this check.
|
||||
// See Socket::ready() for details.
|
||||
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
|
||||
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
|
||||
// Move the socket out of this helper so a replacement helper can take it
|
||||
// over (plaintext to Noise handoff on unprovisioned devices). The drained
|
||||
// helper must be destroyed right after.
|
||||
std::unique_ptr<socket::Socket> release_socket_for_switch() { return std::move(this->socket_); }
|
||||
#endif
|
||||
// Release excess memory from internal buffers after initial sync
|
||||
void release_buffers() {
|
||||
// rx_buf_: Safe to clear only if no partial read in progress.
|
||||
|
||||
@@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() {
|
||||
state_ = State::CLIENT_HELLO;
|
||||
return APIError::OK;
|
||||
}
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
|
||||
APIError err = this->init();
|
||||
if (err != APIError::OK) {
|
||||
return err;
|
||||
}
|
||||
// Seed the header bytes the plaintext helper consumed before detecting the
|
||||
// Noise indicator; try_read_frame_ resumes from rx_header_buf_len_.
|
||||
std::memcpy(this->rx_header_buf_, header, header_len);
|
||||
this->rx_header_buf_len_ = header_len;
|
||||
// Pump the handshake without gating on socket_->ready(): on LWIP the
|
||||
// plaintext helper's partial read can drain rcvevent while the rest of the
|
||||
// client hello sits in the lastdata cache, so ready() may report false even
|
||||
// though data is available.
|
||||
return this->pump_handshake_();
|
||||
}
|
||||
#endif // USE_API_PLAINTEXT
|
||||
|
||||
/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal
|
||||
/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK
|
||||
/// and resume on the next loop().
|
||||
APIError APINoiseFrameHelper::pump_handshake_() {
|
||||
while (this->state_ != State::DATA) {
|
||||
APIError err = this->state_action_();
|
||||
if (err == APIError::WOULD_BLOCK) {
|
||||
break;
|
||||
}
|
||||
if (err != APIError::OK) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
return APIError::OK;
|
||||
}
|
||||
|
||||
// Helper for handling handshake frame errors
|
||||
APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) {
|
||||
if (aerr == APIError::BAD_INDICATOR) {
|
||||
@@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func
|
||||
|
||||
/// Run through handshake messages (if in that phase)
|
||||
APIError APINoiseFrameHelper::loop() {
|
||||
// Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once
|
||||
// the rx buffer is consumed. Re-checking each iteration would block handshake writes
|
||||
// that must follow reads, deadlocking the handshake. state_action() will return
|
||||
// WOULD_BLOCK when no more data is available to read.
|
||||
bool socket_ready = this->socket_->ready();
|
||||
while (state_ != State::DATA && socket_ready) {
|
||||
APIError err = state_action_();
|
||||
if (err == APIError::WOULD_BLOCK) {
|
||||
break;
|
||||
}
|
||||
// Check ready() once, not per state transition. On ESP8266 LWIP raw TCP,
|
||||
// ready() returns false once the rx buffer is consumed. Re-checking each
|
||||
// iteration would block handshake writes that must follow reads,
|
||||
// deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when
|
||||
// no more data is available to read.
|
||||
if (state_ != State::DATA && this->socket_->ready()) {
|
||||
APIError err = this->pump_handshake_();
|
||||
if (err != APIError::OK) {
|
||||
return err;
|
||||
}
|
||||
|
||||
@@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper {
|
||||
}
|
||||
~APINoiseFrameHelper() override;
|
||||
APIError init() override;
|
||||
#ifdef USE_API_PLAINTEXT
|
||||
// Take over a connection whose first bytes were consumed by a plaintext
|
||||
// helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE).
|
||||
// Seeds the already-read header bytes and pumps the handshake state machine
|
||||
// until it would block.
|
||||
APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
|
||||
#endif
|
||||
APIError loop() override;
|
||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
|
||||
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
|
||||
|
||||
protected:
|
||||
APIError pump_handshake_();
|
||||
APIError state_action_();
|
||||
APIError state_action_client_hello_();
|
||||
APIError state_action_server_hello_();
|
||||
|
||||
@@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
|
||||
// If this was the first read, validate the indicator byte
|
||||
if (rx_header_buf_pos_ == 0 && received > 0) {
|
||||
if (rx_header_buf_[0] != 0x00) {
|
||||
#ifdef USE_API_NOISE
|
||||
// Dual build (encryption supported but no key set): a 0x01 first byte
|
||||
// is a Noise client hello. Hand the connection off to a Noise helper
|
||||
// running the all-zeros provisioning PSK so the encryption key can be
|
||||
// set without crossing the wire in plaintext. Preserve the bytes we
|
||||
// already consumed; they are the start of the Noise 3-byte header.
|
||||
if (rx_header_buf_[0] == 0x01) {
|
||||
rx_header_buf_pos_ = static_cast<uint8_t>(received);
|
||||
return APIError::PROTOCOL_SWITCH_TO_NOISE;
|
||||
}
|
||||
#endif
|
||||
state_ = State::FAILED;
|
||||
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
|
||||
return APIError::BAD_INDICATOR;
|
||||
|
||||
@@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
|
||||
APIError read_packet(ReadPacketBuffer *buffer) override;
|
||||
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
|
||||
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span<const MessageInfo> messages) override;
|
||||
#ifdef USE_API_NOISE
|
||||
// After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the
|
||||
// header bytes already consumed from the socket (at most 3, the size of the
|
||||
// Noise fixed header) so the replacement Noise helper can be seeded with them.
|
||||
uint8_t get_consumed_header(uint8_t out[3]) const {
|
||||
memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_);
|
||||
return this->rx_header_buf_pos_;
|
||||
}
|
||||
#endif
|
||||
|
||||
protected:
|
||||
APIError try_read_frame_();
|
||||
|
||||
@@ -10,13 +10,20 @@ using psk_t = std::array<uint8_t, 32>;
|
||||
|
||||
class APINoiseContext {
|
||||
public:
|
||||
// The all-zeros PSK is reserved: it marks the device as unprovisioned and
|
||||
// doubles as the well-known provisioning PSK that unprovisioned devices
|
||||
// accept for Noise handshakes (passive-sniffing protection only, no
|
||||
// authentication). It is never a valid real key.
|
||||
static bool is_all_zeros(const psk_t &psk) {
|
||||
uint8_t acc = 0;
|
||||
for (uint8_t b : psk) {
|
||||
acc |= b;
|
||||
}
|
||||
return acc == 0;
|
||||
}
|
||||
void set_psk(psk_t psk) {
|
||||
this->psk_ = psk;
|
||||
bool has_psk = false;
|
||||
for (auto i : psk) {
|
||||
has_psk |= i;
|
||||
}
|
||||
this->has_psk_ = has_psk;
|
||||
this->has_psk_ = !is_all_zeros(psk);
|
||||
}
|
||||
const psk_t &get_psk() const { return this->psk_; }
|
||||
bool has_psk() const { return this->has_psk_; }
|
||||
|
||||
@@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const {
|
||||
size += 2 + this->name.size();
|
||||
return size;
|
||||
}
|
||||
bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
|
||||
switch (field_id) {
|
||||
case 1:
|
||||
this->reason = static_cast<enums::DisconnectReason>(value);
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast<uint32_t>(this->reason));
|
||||
return pos;
|
||||
}
|
||||
uint32_t DisconnectRequest::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += this->reason ? 2 : 0;
|
||||
return size;
|
||||
}
|
||||
#ifdef USE_AREAS
|
||||
uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
@@ -151,8 +171,11 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
|
||||
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it);
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_STORE_YAML
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->has_store_yaml);
|
||||
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 27, this->has_store_yaml);
|
||||
#endif
|
||||
return pos;
|
||||
}
|
||||
@@ -216,6 +239,9 @@ uint32_t DeviceInfoResponse::calculate_size() const {
|
||||
size += ProtoSize::calc_message_force(2, it.calculate_size());
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_STORE_YAML
|
||||
size += ProtoSize::calc_bool(2, this->has_store_yaml);
|
||||
#endif
|
||||
|
||||
@@ -11,6 +11,10 @@ namespace esphome::api {
|
||||
|
||||
namespace enums {
|
||||
|
||||
enum DisconnectReason : uint32_t {
|
||||
DISCONNECT_REASON_UNSPECIFIED = 0,
|
||||
DISCONNECT_REASON_PROVISIONING_CLOSED = 1,
|
||||
};
|
||||
enum SerialProxyPortType : uint32_t {
|
||||
SERIAL_PROXY_PORT_TYPE_TTL = 0,
|
||||
SERIAL_PROXY_PORT_TYPE_RS232 = 1,
|
||||
@@ -427,18 +431,22 @@ class HelloResponse final : public ProtoMessage {
|
||||
|
||||
protected:
|
||||
};
|
||||
class DisconnectRequest final : public ProtoMessage {
|
||||
class DisconnectRequest final : public ProtoDecodableMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 5;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 0;
|
||||
static constexpr uint8_t ESTIMATED_SIZE = 2;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("disconnect_request"); }
|
||||
#endif
|
||||
enums::DisconnectReason reason{};
|
||||
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
|
||||
uint32_t calculate_size() const;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const char *dump_to(DumpBuffer &out) const override;
|
||||
#endif
|
||||
|
||||
protected:
|
||||
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
|
||||
};
|
||||
class DisconnectResponse final : public ProtoMessage {
|
||||
public:
|
||||
@@ -525,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage {
|
||||
class DeviceInfoResponse final : public ProtoMessage {
|
||||
public:
|
||||
static constexpr uint8_t MESSAGE_TYPE = 10;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 312;
|
||||
static constexpr uint16_t ESTIMATED_SIZE = 315;
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
|
||||
#endif
|
||||
@@ -581,6 +589,9 @@ class DeviceInfoResponse final : public ProtoMessage {
|
||||
#ifdef USE_SERIAL_PROXY
|
||||
std::array<SerialProxyInfo, SERIAL_PROXY_COUNT> serial_proxies{};
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
bool api_encryption_provisionable{false};
|
||||
#endif
|
||||
#ifdef USE_STORE_YAML
|
||||
bool has_store_yaml{false};
|
||||
#endif
|
||||
|
||||
@@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint
|
||||
}
|
||||
#pragma GCC diagnostic pop
|
||||
|
||||
template<> const char *proto_enum_to_string<enums::DisconnectReason>(enums::DisconnectReason value) {
|
||||
switch (value) {
|
||||
case enums::DISCONNECT_REASON_UNSPECIFIED:
|
||||
return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED");
|
||||
case enums::DISCONNECT_REASON_PROVISIONING_CLOSED:
|
||||
return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED");
|
||||
default:
|
||||
return ESPHOME_PSTR("UNKNOWN");
|
||||
}
|
||||
}
|
||||
template<> const char *proto_enum_to_string<enums::SerialProxyPortType>(enums::SerialProxyPortType value) {
|
||||
switch (value) {
|
||||
case enums::SERIAL_PROXY_PORT_TYPE_TTL:
|
||||
@@ -864,7 +874,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const {
|
||||
return out.c_str();
|
||||
}
|
||||
const char *DisconnectRequest::dump_to(DumpBuffer &out) const {
|
||||
out.append_p(ESPHOME_PSTR("DisconnectRequest {}"));
|
||||
MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest"));
|
||||
dump_field(out, ESPHOME_PSTR("reason"), static_cast<enums::DisconnectReason>(this->reason));
|
||||
return out.c_str();
|
||||
}
|
||||
const char *DisconnectResponse::dump_to(DumpBuffer &out) const {
|
||||
@@ -972,6 +983,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
|
||||
out.append("\n");
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_API_NOISE
|
||||
dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
|
||||
#endif
|
||||
#ifdef USE_STORE_YAML
|
||||
dump_field(out, ESPHOME_PSTR("has_store_yaml"), this->has_store_yaml);
|
||||
#endif
|
||||
|
||||
@@ -51,10 +51,12 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
|
||||
break;
|
||||
}
|
||||
case DisconnectRequest::MESSAGE_TYPE: {
|
||||
DisconnectRequest msg;
|
||||
msg.decode(msg_data, msg_size);
|
||||
#ifdef HAS_PROTO_MESSAGE_DUMP
|
||||
this->log_receive_message_(LOG_STR("on_disconnect_request"));
|
||||
this->log_receive_message_(LOG_STR("on_disconnect_request"), msg);
|
||||
#endif
|
||||
this->on_disconnect_request();
|
||||
this->on_disconnect_request(msg);
|
||||
break;
|
||||
}
|
||||
case DisconnectResponse::MESSAGE_TYPE: {
|
||||
|
||||
@@ -21,7 +21,7 @@ class APIServerConnectionBase {
|
||||
|
||||
void on_hello_request(const HelloRequest &value){};
|
||||
|
||||
void on_disconnect_request(){};
|
||||
void on_disconnect_request(const DisconnectRequest &value){};
|
||||
void on_disconnect_response(){};
|
||||
void on_ping_request(){};
|
||||
void on_ping_response(){};
|
||||
|
||||
@@ -107,8 +107,30 @@ void APIServer::setup() {
|
||||
|
||||
// Initialize last_connected_ for reboot timeout tracking
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
// Set warning status if reboot timeout is enabled
|
||||
if (this->reboot_timeout_ != 0) {
|
||||
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
|
||||
// Register with the provisioning manager (provisioning:) as a source and
|
||||
// report our current state (provisioned == an encryption key is set). When the
|
||||
// window closes, disconnect any client still attempting to provision so it learns
|
||||
// the reason. The manager owns the timeout, window state and on_timeout automation.
|
||||
if (provisioning::global_provisioning_manager != nullptr) {
|
||||
this->provisioning_source_ = provisioning::global_provisioning_manager->register_source();
|
||||
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_,
|
||||
this->noise_ctx_.has_psk());
|
||||
provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
|
||||
for (auto &c : this->active_clients()) {
|
||||
DisconnectRequest req;
|
||||
req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
|
||||
// Best-effort: if the send buffer is full the reason is dropped, but the
|
||||
// client still learns the window is closed when it reconnects (rejected at
|
||||
// hello) or via the socket close.
|
||||
c->send_message(req);
|
||||
}
|
||||
});
|
||||
}
|
||||
#endif
|
||||
// Set warning status if reboot timeout is enabled (suppressed while provisioning
|
||||
// is pending so the device waits to be onboarded instead of rebooting).
|
||||
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
}
|
||||
}
|
||||
@@ -121,8 +143,10 @@ void APIServer::loop() {
|
||||
|
||||
if (this->api_connection_count_ == 0) {
|
||||
// Check reboot timeout - done in loop to avoid scheduler heap churn
|
||||
// (cancelled scheduler items sit in heap memory until their scheduled time)
|
||||
if (this->reboot_timeout_ != 0) {
|
||||
// (cancelled scheduler items sit in heap memory until their scheduled time).
|
||||
// Suppressed while a provisioning window is pending so the device waits to be
|
||||
// onboarded / reset instead of rebooting itself; resumes once provisioned.
|
||||
if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
const uint32_t now = App.get_loop_component_start_time();
|
||||
if (now - this->last_connected_ > this->reboot_timeout_) {
|
||||
ESP_LOGE(TAG, "No clients; rebooting");
|
||||
@@ -194,7 +218,8 @@ void APIServer::remove_client_(uint8_t client_index) {
|
||||
this->clients_[last_index].reset();
|
||||
|
||||
// Last client disconnected - set warning and start tracking for reboot timeout
|
||||
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0) {
|
||||
// (suppressed while provisioning is pending - see loop()).
|
||||
if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_set_warning(LOG_STR("waiting for client connection"));
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
@@ -232,7 +257,7 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
conn->start();
|
||||
|
||||
// First client connected - clear warning and update timestamp
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0) {
|
||||
if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
|
||||
this->status_clear_warning();
|
||||
this->last_connected_ = App.get_loop_component_start_time();
|
||||
}
|
||||
@@ -240,12 +265,13 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
|
||||
}
|
||||
|
||||
void APIServer::dump_config() {
|
||||
char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Server:\n"
|
||||
" Address: %s:%u\n"
|
||||
" Listen backlog: %u\n"
|
||||
" Max connections: %u",
|
||||
network::get_use_address(), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS);
|
||||
network::get_use_address_to(addr_buf), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS);
|
||||
#ifdef USE_API_NOISE
|
||||
ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk()));
|
||||
if (!this->noise_ctx_.has_psk()) {
|
||||
@@ -400,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat
|
||||
|
||||
#ifdef USE_API_HOMEASSISTANT_SERVICES
|
||||
void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) {
|
||||
bool has_subscriber = false;
|
||||
for (auto &client : this->active_clients()) {
|
||||
client->send_homeassistant_action(call);
|
||||
has_subscriber |= client->send_homeassistant_action(call);
|
||||
}
|
||||
if (!has_subscriber) {
|
||||
// Home Assistant subscribes to actions shortly *after* authenticating, so actions
|
||||
// fired right at connection time (on_client_connected, on_time_sync, ...) can
|
||||
// arrive before the subscription and are lost - warn instead of failing silently.
|
||||
ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(),
|
||||
this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected");
|
||||
}
|
||||
}
|
||||
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
|
||||
@@ -571,8 +605,16 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
|
||||
}
|
||||
|
||||
SavedNoisePsk new_saved_psk{psk};
|
||||
return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
|
||||
make_active);
|
||||
bool result = this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
|
||||
make_active);
|
||||
#ifdef USE_PROVISIONING
|
||||
// The device now has a key; report provisioned so the provisioning window is
|
||||
// satisfied and the reboot timeout resumes normal operation.
|
||||
if (result && provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, true);
|
||||
}
|
||||
#endif
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
bool APIServer::clear_noise_psk(bool make_active) {
|
||||
@@ -583,8 +625,16 @@ bool APIServer::clear_noise_psk(bool make_active) {
|
||||
return false;
|
||||
#else
|
||||
SavedNoisePsk empty_psk{};
|
||||
return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
|
||||
make_active);
|
||||
bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
|
||||
make_active);
|
||||
#ifdef USE_PROVISIONING
|
||||
// The key was cleared; report unprovisioned so a subsequent reboot reopens the
|
||||
// provisioning window.
|
||||
if (result && provisioning::global_provisioning_manager != nullptr) {
|
||||
provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, false);
|
||||
}
|
||||
#endif
|
||||
return result;
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
#include "esphome/core/controller.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "esphome/core/string_ref.h"
|
||||
#ifdef USE_PROVISIONING
|
||||
#include "esphome/components/provisioning/provisioning.h"
|
||||
#endif
|
||||
#ifdef USE_LOGGER
|
||||
#include "esphome/components/logger/logger.h"
|
||||
#endif
|
||||
@@ -255,6 +258,19 @@ class APIServer final : public Component,
|
||||
// Remove a disconnected client by index. Swaps with the last populated slot and resets it.
|
||||
void __attribute__((noinline)) remove_client_(uint8_t client_index);
|
||||
|
||||
#ifdef USE_PROVISIONING
|
||||
// True while a configured provisioning window is still pending (the device is
|
||||
// unprovisioned). Suppresses the reboot timeout and its warning so the device is
|
||||
// not auto-rebooted while waiting to be provisioned. False when no provisioning
|
||||
// window is configured.
|
||||
bool provisioning_pending_() const {
|
||||
return provisioning::global_provisioning_manager != nullptr &&
|
||||
provisioning::global_provisioning_manager->window_pending();
|
||||
}
|
||||
#else
|
||||
bool provisioning_pending_() const { return false; }
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
|
||||
bool make_active);
|
||||
@@ -332,7 +348,10 @@ class APIServer final : public Component,
|
||||
uint8_t listen_backlog_{4};
|
||||
bool shutting_down_ = false;
|
||||
uint8_t api_connection_count_{0};
|
||||
// 7 bytes used, 1 byte padding
|
||||
#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
|
||||
// Index assigned by the provisioning manager for reporting this transport's state.
|
||||
uint8_t provisioning_source_{0};
|
||||
#endif
|
||||
|
||||
#ifdef USE_API_NOISE
|
||||
APINoiseContext noise_ctx_;
|
||||
|
||||
@@ -18,7 +18,7 @@ with warnings.catch_warnings():
|
||||
import contextlib
|
||||
|
||||
from esphome.const import CONF_KEY, CONF_PORT, __version__
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.core import CORE
|
||||
from esphome.util import safe_print
|
||||
|
||||
from . import CONF_ENCRYPTION
|
||||
@@ -36,15 +36,17 @@ class _LogLineProcessor:
|
||||
"""Feeds incoming log lines to the stack-trace decoder.
|
||||
|
||||
Two responsibilities beyond just calling the decoder:
|
||||
1. Catch EsphomeError. on_log runs inside an asyncio protocol
|
||||
callback; if an exception escapes, the loop tears the transport
|
||||
down with "Fatal error: protocol.data_received() call failed."
|
||||
and ReconnectLogic immediately reconnects, the device replays
|
||||
the same crash trace, and we loop forever.
|
||||
2. Disable decoding after the first failure. _decode_pc shells out
|
||||
to PlatformIO via _run_idedata, which is expensive; a single
|
||||
crash dump can contain many PC/BT lines and we don't want to
|
||||
retry the failing subprocess for each one.
|
||||
1. Catch everything the decoder can raise. aioesphomeapi isolates
|
||||
exceptions raised by log handlers, so an escaping one no longer
|
||||
kills the session, but it does log a full traceback per line. A
|
||||
crash dump carries a PC line plus one per backtrace frame, so the
|
||||
tracebacks bury the dump the user is trying to read. Decoding is a
|
||||
diagnostic nicety; nothing it raises is worth that noise.
|
||||
2. Disable decoding after the first failure. _decode_pc shells out to
|
||||
the toolchain to resolve addr2line, which is expensive; a single
|
||||
crash dump can contain many PC/BT lines and we don't want to retry
|
||||
the failing subprocess for each one. This only works if every
|
||||
failure is caught, which is why 1 is not narrowed to EsphomeError.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None:
|
||||
@@ -61,12 +63,13 @@ class _LogLineProcessor:
|
||||
self.backtrace_state = self._platform_handler(
|
||||
self._config, raw_line, self.backtrace_state
|
||||
)
|
||||
except EsphomeError as exc:
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
|
||||
self._decode_enabled = False
|
||||
self.backtrace_state = False
|
||||
# _run_idedata raises EsphomeError with no message; fall back
|
||||
# to a generic explanation when str(exc) is empty.
|
||||
detail = str(exc) or "build artifacts not found locally"
|
||||
_LOGGER.debug("Stack-trace decoding failed", exc_info=True)
|
||||
_LOGGER.warning(
|
||||
"Crash trace decoding unavailable: %s. "
|
||||
"Run 'esphome compile' for this device to enable PC decoding.",
|
||||
@@ -152,6 +155,9 @@ async def async_run_logs(
|
||||
name=name,
|
||||
subscribe_states=subscribe_states,
|
||||
allow_plaintext_fallback=True,
|
||||
# A top-level ``deep_sleep:`` block means the device is only awake
|
||||
# briefly; cap the reconnect backoff so a wake window is not missed.
|
||||
deep_sleep="deep_sleep" in config,
|
||||
)
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
|
||||
@@ -7,6 +7,7 @@ AQICalculatorType = aqi_ns.enum("AQICalculatorType")
|
||||
|
||||
CONF_AQI = "aqi"
|
||||
CONF_CALCULATION_TYPE = "calculation_type"
|
||||
CONF_EXTENDED_RANGE = "extended_range"
|
||||
|
||||
AQI_CALCULATION_TYPE = {
|
||||
"CAQI": AQICalculatorType.CAQI_TYPE,
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace esphome::aqi {
|
||||
|
||||
class AbstractAQICalculator {
|
||||
public:
|
||||
virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value) = 0;
|
||||
virtual uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) = 0;
|
||||
};
|
||||
|
||||
} // namespace esphome::aqi
|
||||
|
||||
@@ -11,10 +11,12 @@ namespace esphome::aqi {
|
||||
|
||||
class AQICalculator : public AbstractAQICalculator {
|
||||
public:
|
||||
uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override {
|
||||
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
|
||||
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
|
||||
uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool extended_range) override {
|
||||
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID, extended_range);
|
||||
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID, extended_range);
|
||||
float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
|
||||
// extended_range lets the index run past the standard maximum, so clamp to the sensor's range.
|
||||
aqi = std::min(aqi, static_cast<float>(std::numeric_limits<uint16_t>::max()));
|
||||
return static_cast<uint16_t>(std::lround(aqi));
|
||||
}
|
||||
|
||||
@@ -30,7 +32,7 @@ class AQICalculator : public AbstractAQICalculator {
|
||||
{35.5f, 55.5f},
|
||||
{55.5f, 125.5f},
|
||||
{125.5f, 225.5f},
|
||||
{225.5f, std::numeric_limits<float>::max()}
|
||||
{225.5f, 500.4f} // EPA 2024: AQI 301-500 maps to PM2.5 225.5-500.4 ug/m3
|
||||
// clang-format on
|
||||
};
|
||||
|
||||
@@ -41,11 +43,11 @@ class AQICalculator : public AbstractAQICalculator {
|
||||
{155.0f, 255.0f},
|
||||
{255.0f, 355.0f},
|
||||
{355.0f, 425.0f},
|
||||
{425.0f, std::numeric_limits<float>::max()}
|
||||
{425.0f, 604.0f} // EPA: AQI 301-500 maps to PM10 425-604 ug/m3 (top of the 401-500 band)
|
||||
// clang-format on
|
||||
};
|
||||
|
||||
static float calculate_index(float value, const float array[NUM_LEVELS][2]) {
|
||||
static float calculate_index(float value, const float array[NUM_LEVELS][2], bool extended_range) {
|
||||
int grid_index = get_grid_index(value, array);
|
||||
if (grid_index == -1) {
|
||||
return -1.0f;
|
||||
@@ -55,14 +57,22 @@ class AQICalculator : public AbstractAQICalculator {
|
||||
float conc_lo = array[grid_index][0];
|
||||
float conc_hi = array[grid_index][1];
|
||||
|
||||
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
|
||||
float index = (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
|
||||
|
||||
// Concentrations above the highest breakpoint run the linear fit past aqi_hi. By default we
|
||||
// clamp to the standard maximum; with extended_range we keep the extrapolated "over-range"
|
||||
// value so heavy pollution reports numbers beyond what the standard defines.
|
||||
if (grid_index == NUM_LEVELS - 1 && !extended_range && index > aqi_hi) {
|
||||
return aqi_hi;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
|
||||
for (int i = 0; i < NUM_LEVELS; i++) {
|
||||
const bool in_range =
|
||||
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
|
||||
: (value < array[i][1])); // others exclusive on hi
|
||||
// The top band is open-ended: any value at or above its lower breakpoint falls into it,
|
||||
// and calculate_index() decides whether to clamp or extrapolate.
|
||||
const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]);
|
||||
if (in_range) {
|
||||
return i;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ void AQISensor::setup() {
|
||||
void AQISensor::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "AQI Sensor:");
|
||||
ESP_LOGCONFIG(TAG, " Calculation Type: %s", this->aqi_calc_type_ == AQI_TYPE ? "AQI" : "CAQI");
|
||||
ESP_LOGCONFIG(TAG, " Extended Range: %s", this->extended_range_ ? "enabled" : "disabled");
|
||||
if (this->pm_2_5_sensor_ != nullptr) {
|
||||
ESP_LOGCONFIG(TAG, " PM2.5 Sensor: '%s'", this->pm_2_5_sensor_->get_name().c_str());
|
||||
}
|
||||
@@ -44,7 +45,7 @@ void AQISensor::calculate_aqi_() {
|
||||
return;
|
||||
}
|
||||
|
||||
uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_);
|
||||
uint16_t aqi = calculator->get_aqi(this->pm_2_5_value_, this->pm_10_0_value_, this->extended_range_);
|
||||
this->publish_state(aqi);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ class AQISensor final : public sensor::Sensor, public Component {
|
||||
void set_pm_2_5_sensor(sensor::Sensor *sensor) { this->pm_2_5_sensor_ = sensor; }
|
||||
void set_pm_10_0_sensor(sensor::Sensor *sensor) { this->pm_10_0_sensor_ = sensor; }
|
||||
void set_aqi_calculation_type(AQICalculatorType type) { this->aqi_calc_type_ = type; }
|
||||
void set_extended_range(bool extended_range) { this->extended_range_ = extended_range; }
|
||||
|
||||
protected:
|
||||
void calculate_aqi_();
|
||||
@@ -21,6 +22,7 @@ class AQISensor final : public sensor::Sensor, public Component {
|
||||
sensor::Sensor *pm_2_5_sensor_{nullptr};
|
||||
sensor::Sensor *pm_10_0_sensor_{nullptr};
|
||||
AQICalculatorType aqi_calc_type_{AQI_TYPE};
|
||||
bool extended_range_{false};
|
||||
AQICalculatorFactory aqi_calculator_factory_;
|
||||
|
||||
float pm_2_5_value_{NAN};
|
||||
|
||||
@@ -9,25 +9,28 @@ namespace esphome::aqi {
|
||||
|
||||
class CAQICalculator : public AbstractAQICalculator {
|
||||
public:
|
||||
uint16_t get_aqi(float pm2_5_value, float pm10_0_value) override {
|
||||
// The CAQI (CITEAIR) scale defines no maximum: its top "Very high" class is simply ">100". We
|
||||
// therefore always extrapolate the top band past 100 without limit, so the extended_range flag
|
||||
// (which lifts the AQI calculator's fixed 500 cap) has no meaning here and is ignored.
|
||||
uint16_t get_aqi(float pm2_5_value, float pm10_0_value, bool /*extended_range*/) override {
|
||||
float pm2_5_index = calculate_index(pm2_5_value, PM2_5_GRID);
|
||||
float pm10_0_index = calculate_index(pm10_0_value, PM10_0_GRID);
|
||||
float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
|
||||
aqi = std::min(aqi, static_cast<float>(std::numeric_limits<uint16_t>::max()));
|
||||
return static_cast<uint16_t>(std::lround(aqi));
|
||||
}
|
||||
|
||||
protected:
|
||||
static constexpr int NUM_LEVELS = 5;
|
||||
static constexpr int NUM_LEVELS = 4;
|
||||
|
||||
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}, {101, 400}};
|
||||
static constexpr int INDEX_GRID[NUM_LEVELS][2] = {{0, 25}, {26, 50}, {51, 75}, {76, 100}};
|
||||
|
||||
static constexpr float PM2_5_GRID[NUM_LEVELS][2] = {
|
||||
// clang-format off
|
||||
{0.0f, 15.1f},
|
||||
{15.1f, 30.1f},
|
||||
{30.1f, 55.1f},
|
||||
{55.1f, 110.1f},
|
||||
{110.1f, std::numeric_limits<float>::max()}
|
||||
{55.1f, 110.1f}
|
||||
// clang-format on
|
||||
};
|
||||
|
||||
@@ -36,8 +39,7 @@ class CAQICalculator : public AbstractAQICalculator {
|
||||
{0.0f, 25.1f},
|
||||
{25.1f, 50.1f},
|
||||
{50.1f, 90.1f},
|
||||
{90.1f, 180.1f},
|
||||
{180.1f, std::numeric_limits<float>::max()}
|
||||
{90.1f, 180.1f}
|
||||
// clang-format on
|
||||
};
|
||||
|
||||
@@ -52,14 +54,15 @@ class CAQICalculator : public AbstractAQICalculator {
|
||||
float conc_lo = array[grid_index][0];
|
||||
float conc_hi = array[grid_index][1];
|
||||
|
||||
// The top band is open-ended (see get_grid_index), so for concentrations above the last
|
||||
// breakpoint this linear fit extrapolates past 100 unbounded, matching CAQI's open ">100" class.
|
||||
return (value - conc_lo) * (aqi_hi - aqi_lo) / (conc_hi - conc_lo) + aqi_lo;
|
||||
}
|
||||
|
||||
static int get_grid_index(float value, const float array[NUM_LEVELS][2]) {
|
||||
for (int i = 0; i < NUM_LEVELS; i++) {
|
||||
const bool in_range =
|
||||
(value >= array[i][0]) && ((i == NUM_LEVELS - 1) ? (value <= array[i][1]) // last bucket inclusive
|
||||
: (value < array[i][1])); // others exclusive on hi
|
||||
// The top band is open-ended: any value at or above its lower breakpoint falls into it.
|
||||
const bool in_range = (value >= array[i][0]) && (i == NUM_LEVELS - 1 || value < array[i][1]);
|
||||
if (in_range) {
|
||||
return i;
|
||||
}
|
||||
|
||||
@@ -8,14 +8,25 @@ from esphome.const import (
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
|
||||
from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, aqi_ns
|
||||
from . import AQI_CALCULATION_TYPE, CONF_CALCULATION_TYPE, CONF_EXTENDED_RANGE, aqi_ns
|
||||
|
||||
CODEOWNERS = ["@jasstrong"]
|
||||
DEPENDENCIES = ["sensor"]
|
||||
|
||||
AQISensor = aqi_ns.class_("AQISensor", sensor.Sensor, cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = (
|
||||
|
||||
def _validate_extended_range(config):
|
||||
if CONF_EXTENDED_RANGE in config and config[CONF_CALCULATION_TYPE] == "CAQI":
|
||||
raise cv.Invalid(
|
||||
f"'{CONF_EXTENDED_RANGE}' is not supported with 'calculation_type: CAQI'. "
|
||||
"CAQI has no maximum value by specification, so it is always reported unbounded.",
|
||||
[CONF_EXTENDED_RANGE],
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
sensor.sensor_schema(
|
||||
AQISensor,
|
||||
accuracy_decimals=0,
|
||||
@@ -29,9 +40,11 @@ CONFIG_SCHEMA = (
|
||||
cv.Required(CONF_CALCULATION_TYPE): cv.enum(
|
||||
AQI_CALCULATION_TYPE, upper=True
|
||||
),
|
||||
cv.Optional(CONF_EXTENDED_RANGE): cv.boolean,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
_validate_extended_range,
|
||||
)
|
||||
|
||||
|
||||
@@ -46,3 +59,4 @@ async def to_code(config):
|
||||
cg.add(var.set_pm_10_0_sensor(pm_10_0_sensor))
|
||||
|
||||
cg.add(var.set_aqi_calculation_type(config[CONF_CALCULATION_TYPE]))
|
||||
cg.add(var.set_extended_range(config.get(CONF_EXTENDED_RANGE, False)))
|
||||
|
||||
@@ -24,11 +24,7 @@ void I2CAS3935Component::write_register(uint8_t reg, uint8_t mask, uint8_t bits,
|
||||
|
||||
uint8_t I2CAS3935Component::read_register(uint8_t reg) {
|
||||
uint8_t value;
|
||||
if (write(®, 1) != i2c::ERROR_OK) {
|
||||
ESP_LOGW(TAG, "Writing register failed!");
|
||||
return 0;
|
||||
}
|
||||
if (read(&value, 1) != i2c::ERROR_OK) {
|
||||
if (!this->read_byte(reg, &value)) {
|
||||
ESP_LOGW(TAG, "Reading register failed!");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ from esphome.const import (
|
||||
CONF_CLEAR,
|
||||
CONF_GAIN,
|
||||
CONF_ID,
|
||||
DEVICE_CLASS_ILLUMINANCE,
|
||||
DEVICE_CLASS_EMPTY,
|
||||
ICON_BRIGHTNESS_5,
|
||||
STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
@@ -54,7 +54,7 @@ SENSOR_SCHEMA = sensor.sensor_schema(
|
||||
unit_of_measurement=UNIT_COUNTS,
|
||||
icon=ICON_BRIGHTNESS_5,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_ILLUMINANCE,
|
||||
device_class=DEVICE_CLASS_EMPTY,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ def AUTO_LOAD() -> list[str]:
|
||||
if (
|
||||
not CORE.is_esp32
|
||||
and not CORE.is_esp8266
|
||||
and not CORE.is_rp2040
|
||||
and not CORE.is_rp2
|
||||
and not CORE.is_libretiny
|
||||
):
|
||||
return ["socket"]
|
||||
@@ -37,7 +37,7 @@ async def to_code(config):
|
||||
elif CORE.is_esp8266:
|
||||
# https://github.com/ESP32Async/ESPAsyncTCP
|
||||
cg.add_library("ESP32Async/ESPAsyncTCP", "2.0.0")
|
||||
elif CORE.is_rp2040:
|
||||
elif CORE.is_rp2:
|
||||
# https://github.com/ayushsharma82/RPAsyncTCP
|
||||
# RPAsyncTCP is a drop-in replacement for AsyncTCP_RP2040W with better
|
||||
# ESPAsyncWebServer compatibility
|
||||
@@ -47,6 +47,6 @@ async def to_code(config):
|
||||
|
||||
def FILTER_SOURCE_FILES() -> list[str]:
|
||||
# Exclude socket implementation for platforms that use AsyncTCP libraries
|
||||
if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2040 or CORE.is_libretiny:
|
||||
if CORE.is_esp32 or CORE.is_esp8266 or CORE.is_rp2 or CORE.is_libretiny:
|
||||
return ["async_tcp_socket.cpp"]
|
||||
return []
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
#elif defined(USE_ESP8266)
|
||||
// Use ESPAsyncTCP library for ESP8266 (always Arduino)
|
||||
#include <ESPAsyncTCP.h>
|
||||
#elif defined(USE_RP2040)
|
||||
#elif defined(USE_RP2)
|
||||
// Use RPAsyncTCP library for RP2040
|
||||
#include <RPAsyncTCP.h>
|
||||
#else
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#include "async_tcp_socket.h"
|
||||
|
||||
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \
|
||||
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \
|
||||
(defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS))
|
||||
|
||||
#include "esphome/components/network/util.h"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2040) && !defined(USE_LIBRETINY) && \
|
||||
#if !defined(USE_ESP32) && !defined(USE_ESP8266) && !defined(USE_RP2) && !defined(USE_LIBRETINY) && \
|
||||
(defined(USE_SOCKET_IMPL_LWIP_SOCKETS) || defined(USE_SOCKET_IMPL_BSD_SOCKETS))
|
||||
|
||||
#include "esphome/components/socket/socket.h"
|
||||
|
||||
@@ -65,12 +65,11 @@ optional<ParseResult> ATCMiThermometer::parse_header_(const esp32_ble_tracker::S
|
||||
return {};
|
||||
}
|
||||
|
||||
static uint8_t last_frame_count = 0;
|
||||
if (last_frame_count == raw[12]) {
|
||||
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", last_frame_count);
|
||||
if (this->last_frame_count_ == raw[12]) {
|
||||
ESP_LOGVV(TAG, "parse_header(): duplicate data packet received (%hhu).", this->last_frame_count_);
|
||||
return {};
|
||||
}
|
||||
last_frame_count = raw[12];
|
||||
this->last_frame_count_ = raw[12];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ class ATCMiThermometer final : public Component, public esp32_ble_tracker::ESPBT
|
||||
sensor::Sensor *battery_voltage_{nullptr};
|
||||
sensor::Sensor *signal_strength_{nullptr};
|
||||
|
||||
uint8_t last_frame_count_{0};
|
||||
|
||||
optional<ParseResult> parse_header_(const esp32_ble_tracker::ServiceData &service_data);
|
||||
bool parse_message_(const std::vector<uint8_t> &message, ParseResult &result);
|
||||
bool report_results_(const optional<ParseResult> &result, const char *address);
|
||||
|
||||
@@ -113,7 +113,9 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
|
||||
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"]
|
||||
if file_type == "wav":
|
||||
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"]
|
||||
elif file_type in ("mp3", "mpeg", "mpga"):
|
||||
elif file_type in ("mp1", "mp2", "mp3", "mpeg", "mpga"):
|
||||
# With puremagic >=2.0 this can cause some MP3 (Layer III) files to be labeled as "mp1"/"mp2".
|
||||
# Treat those labels as MP3 so we still pick the MP3 decoder.
|
||||
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"]
|
||||
elif file_type == "flac":
|
||||
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"]
|
||||
|
||||
@@ -37,15 +37,15 @@ namespace esphome::beken_spi_led_strip {
|
||||
|
||||
static const char *const TAG = "beken_spi_led_strip";
|
||||
|
||||
struct spi_data_t {
|
||||
struct SpiData {
|
||||
SemaphoreHandle_t dma_tx_semaphore;
|
||||
volatile bool tx_in_progress;
|
||||
bool first_run;
|
||||
};
|
||||
|
||||
static spi_data_t *spi_data = nullptr;
|
||||
static SpiData *spi_data = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static void set_spi_ctrl_register(unsigned long bit, bool val) {
|
||||
static void set_spi_ctrl_register(uint32_t bit, bool val) {
|
||||
uint32_t value = REG_READ(SPI_CTRL);
|
||||
if (val == 0) {
|
||||
value &= ~bit;
|
||||
@@ -55,7 +55,7 @@ static void set_spi_ctrl_register(unsigned long bit, bool val) {
|
||||
REG_WRITE(SPI_CTRL, value);
|
||||
}
|
||||
|
||||
static void set_spi_config_register(unsigned long bit, bool val) {
|
||||
static void set_spi_config_register(uint32_t bit, bool val) {
|
||||
uint32_t value = REG_READ(SPI_CONFIG);
|
||||
if (val == 0) {
|
||||
value &= ~bit;
|
||||
@@ -67,7 +67,7 @@ static void set_spi_config_register(unsigned long bit, bool val) {
|
||||
|
||||
void spi_dma_tx_enable(bool enable) {
|
||||
GDMA_CFG_ST en_cfg;
|
||||
set_spi_config_register(SPI_TX_EN, enable ? 1 : 0);
|
||||
set_spi_config_register(SPI_TX_EN, enable);
|
||||
en_cfg.channel = SPI_TX_DMA_CHANNEL;
|
||||
en_cfg.param = enable ? 1 : 0;
|
||||
sddev_control(GDMA_DEV_NAME, CMD_GDMA_SET_DMA_ENABLE, &en_cfg);
|
||||
@@ -110,13 +110,13 @@ static void spi_set_clock(uint32_t max_hz) {
|
||||
param &= ~(SPI_CKR_MASK << SPI_CKR_POSI);
|
||||
param |= (div << SPI_CKR_POSI);
|
||||
REG_WRITE(SPI_CTRL, param);
|
||||
ESP_LOGD(TAG, "target frequency: %d, actual frequency: %d", max_hz, source_clk / 2 / div);
|
||||
ESP_LOGD(TAG, "target frequency: %" PRIu32 ", actual frequency: %d", max_hz, source_clk / 2 / div);
|
||||
}
|
||||
|
||||
void spi_dma_tx_finish_callback(unsigned int param) {
|
||||
spi_data->tx_in_progress = false;
|
||||
xSemaphoreGive(spi_data->dma_tx_semaphore);
|
||||
spi_dma_tx_enable(0);
|
||||
spi_dma_tx_enable(false);
|
||||
}
|
||||
|
||||
void BekenSPILEDStripLightOutput::setup() {
|
||||
@@ -161,7 +161,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
return;
|
||||
}
|
||||
|
||||
spi_data = (spi_data_t *) calloc(1, sizeof(spi_data_t));
|
||||
spi_data = (SpiData *) calloc(1, sizeof(SpiData)); // NOLINT(cppcoreguidelines-no-malloc)
|
||||
if (spi_data == nullptr) {
|
||||
ESP_LOGE(TAG, "Cannot allocate spi_data!");
|
||||
this->mark_failed();
|
||||
@@ -177,20 +177,20 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
|
||||
spi_data->first_run = true;
|
||||
|
||||
set_spi_ctrl_register(MSTEN, 0);
|
||||
set_spi_ctrl_register(BIT_WDTH, 0);
|
||||
set_spi_ctrl_register(MSTEN, false);
|
||||
set_spi_ctrl_register(BIT_WDTH, false);
|
||||
spi_set_clock(this->spi_frequency_);
|
||||
set_spi_ctrl_register(CKPOL, 0);
|
||||
set_spi_ctrl_register(CKPHA, 0);
|
||||
set_spi_ctrl_register(MSTEN, 1);
|
||||
set_spi_ctrl_register(SPIEN, 1);
|
||||
set_spi_ctrl_register(CKPOL, false);
|
||||
set_spi_ctrl_register(CKPHA, false);
|
||||
set_spi_ctrl_register(MSTEN, true);
|
||||
set_spi_ctrl_register(SPIEN, true);
|
||||
|
||||
set_spi_ctrl_register(TXINT_EN, 0);
|
||||
set_spi_ctrl_register(RXINT_EN, 0);
|
||||
set_spi_config_register(SPI_TX_FINISH_EN, 1);
|
||||
set_spi_config_register(SPI_RX_FINISH_EN, 1);
|
||||
set_spi_ctrl_register(RXOVR_EN, 0);
|
||||
set_spi_ctrl_register(TXOVR_EN, 0);
|
||||
set_spi_ctrl_register(TXINT_EN, false);
|
||||
set_spi_ctrl_register(RXINT_EN, false);
|
||||
set_spi_config_register(SPI_TX_FINISH_EN, true);
|
||||
set_spi_config_register(SPI_RX_FINISH_EN, true);
|
||||
set_spi_ctrl_register(RXOVR_EN, false);
|
||||
set_spi_ctrl_register(TXOVR_EN, false);
|
||||
|
||||
value = REG_READ(SPI_CTRL);
|
||||
value &= ~CTRL_NSSMD_3;
|
||||
@@ -199,7 +199,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
|
||||
value = GFUNC_MODE_SPI_DMA;
|
||||
sddev_control(GPIO_DEV_NAME, CMD_GPIO_ENABLE_SECOND, &value);
|
||||
set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, 0);
|
||||
set_spi_ctrl_register(SPI_S_CS_UP_INT_EN, false);
|
||||
|
||||
GDMA_CFG_ST en_cfg;
|
||||
GDMACFG_TPYES_ST init_cfg;
|
||||
@@ -210,7 +210,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
init_cfg.dstptr_incr = 0;
|
||||
init_cfg.srcptr_incr = 1;
|
||||
init_cfg.src_start_addr = this->dma_buf_;
|
||||
init_cfg.dst_start_addr = (void *) SPI_DAT; // SPI_DMA_REG4_TXFIFO
|
||||
init_cfg.dst_start_addr = (void *) SPI_DAT; // NOLINT(performance-no-int-to-ptr) SPI_DMA_REG4_TXFIFO
|
||||
init_cfg.channel = SPI_TX_DMA_CHANNEL;
|
||||
init_cfg.prio = 0; // 10
|
||||
init_cfg.u.type4.src_loop_start_addr = this->dma_buf_;
|
||||
@@ -230,7 +230,7 @@ void BekenSPILEDStripLightOutput::setup() {
|
||||
en_cfg.param = 0;
|
||||
sddev_control(GDMA_DEV_NAME, CMD_GDMA_CFG_SRCADDR_LOOP, &en_cfg);
|
||||
|
||||
spi_dma_tx_enable(0);
|
||||
spi_dma_tx_enable(false);
|
||||
|
||||
value = REG_READ(SPI_CONFIG);
|
||||
value &= ~(0xFFF << 8);
|
||||
@@ -247,7 +247,8 @@ void BekenSPILEDStripLightOutput::set_led_params(uint8_t bit0, uint8_t bit1, uin
|
||||
void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
|
||||
// protect from refreshing too often
|
||||
uint32_t now = micros();
|
||||
if (*this->max_refresh_rate_ != 0 && (now - this->last_refresh_) < *this->max_refresh_rate_) {
|
||||
if (this->max_refresh_rate_.has_value() && *this->max_refresh_rate_ != 0 &&
|
||||
(now - this->last_refresh_) < *this->max_refresh_rate_) {
|
||||
// try again next loop iteration, so that this change won't get lost
|
||||
this->schedule_show();
|
||||
return;
|
||||
@@ -293,7 +294,7 @@ void BekenSPILEDStripLightOutput::write_state(light::LightState *state) {
|
||||
}
|
||||
|
||||
spi_data->first_run = false;
|
||||
spi_dma_tx_enable(1);
|
||||
spi_dma_tx_enable(true);
|
||||
|
||||
this->status_clear_warning();
|
||||
}
|
||||
@@ -376,7 +377,7 @@ void BekenSPILEDStripLightOutput::dump_config() {
|
||||
" RGB Order: %s\n"
|
||||
" Max refresh rate: %" PRIu32 "\n"
|
||||
" Number of LEDs: %u",
|
||||
rgb_order, *this->max_refresh_rate_, this->num_leds_);
|
||||
rgb_order, this->max_refresh_rate_.value_or(0), this->num_leds_);
|
||||
}
|
||||
|
||||
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
@@ -448,7 +448,9 @@ _BINARY_SENSOR_SCHEMA = (
|
||||
cv.Exclusive(
|
||||
CONF_TRIGGER_ON_INITIAL_STATE, CONF_TRIGGER_ON_INITIAL_STATE
|
||||
): cv.boolean,
|
||||
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
|
||||
cv.Optional(
|
||||
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
|
||||
): validate_device_class,
|
||||
cv.Optional(CONF_FILTERS): validate_filters,
|
||||
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
|
||||
cv.Optional(CONF_ON_RELEASE): automation.validate_automation({}),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""BK72xx BLE — BLE controller support for the BLE-5.x LibreTiny Beken chips.
|
||||
|
||||
The platform analog of esp32_ble / rp2040_ble: owns the Beken BDK BLE stack
|
||||
bring-up and the controller BLE address. Consumers (bk72xx_ble_tracker) build
|
||||
on this component and contain no SDK calls of their own.
|
||||
|
||||
Supported SoCs (BLE 5.x): BK7231N/BK7236 (BLE 5.1), BK7238/BK7252N/BK7253
|
||||
(BLE 5.2), and any future BLE-5.x SoC. Capability is detected at compile time,
|
||||
not by a chip list: the C++ guards on `__has_include("ble_api.h")` — the Beken
|
||||
BLE 5.x public API header, which the LibreTiny beken-72xx builder ships only
|
||||
for BLE-5.x SoCs. BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) fail
|
||||
with a clear #error.
|
||||
|
||||
No framework patch is needed: the LibreTiny beken-72xx builder already compiles
|
||||
and links the BLE 5.x stack (CFG_SUPPORT_BLE=1 + CFG_BLE_VERSION=BLE_VERSION_5_x;
|
||||
prebuilt libble_<chip>.a per SoC). This component only calls into it via the
|
||||
public ble_api.h.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import libretiny
|
||||
from esphome.components.libretiny.const import FAMILY_BK7231N, FAMILY_BK7238
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENABLE_ON_BOOT, CONF_ID
|
||||
from esphome.types import ConfigType
|
||||
|
||||
DEPENDENCIES = ["bk72xx"]
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
bk72xx_ble_ns = cg.esphome_ns.namespace("bk72xx_ble")
|
||||
BK72xxBLE = bk72xx_ble_ns.class_("BK72xxBLE", cg.Component)
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(BK72xxBLE),
|
||||
# Default off: on the single-core BK72xx, bringing the BLE stack up during
|
||||
# boot competes with the WiFi connection handshake. Consumers enable the
|
||||
# stack lazily on first use (e.g. the tracker's first scan start).
|
||||
cv.Optional(CONF_ENABLE_ON_BOOT, default=False): cv.boolean,
|
||||
}
|
||||
).extend(cv.COMPONENT_SCHEMA)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT]))
|
||||
|
||||
# Enable the BLE stack in the build (the '#h' maps to sys_config.h; the value
|
||||
# is a list). ESPHome's libretiny platform normally appends CFG_SUPPORT_BLE=0
|
||||
# on BK7231N/BK7238 (saves ~21KB RAM/~200KB Flash when BLE is unused) to this
|
||||
# SAME key — and add_platformio_option appends list values, it never replaces.
|
||||
# The platform therefore skips its disable when this component is configured,
|
||||
# so this =1 is the single CFG_SUPPORT_BLE define emitted.
|
||||
cg.add_platformio_option("custom_options.sys_config#h", ["CFG_SUPPORT_BLE=1"])
|
||||
|
||||
# Pin the Beken BDK release the BLE 5.x stack is validated against. The
|
||||
# bundled 3.0.33 has an older BLE header/library layout — and with
|
||||
# CFG_SUPPORT_BLE=1 the SDK runs its BLE init unconditionally during boot
|
||||
# (the reason the libretiny platform sets =0 when BLE is unused), so a
|
||||
# mismatched BDK can crash the device before WiFi comes up regardless of
|
||||
# enable_on_boot. Pinning here makes a plain config build against the
|
||||
# validated BDK without any manual platformio_options.
|
||||
_LOGGER.warning(
|
||||
"bk72xx_ble builds with beken-bdk 3.0.78 instead of the platform's bundled "
|
||||
"default: the default's older BLE layout can crash the device at boot when "
|
||||
"BLE is compiled in"
|
||||
)
|
||||
cg.add_platformio_option("custom_versions.beken-bdk", "3.0.78")
|
||||
|
||||
# The BDK exposes the controller's BLE address as `common_default_bdaddr` on
|
||||
# BK7231N, but NOT on BK7238 (its BLE stack has no such symbol; the address is
|
||||
# derived from the WiFi MAC instead — the BDK's own fallback). Tell the C++
|
||||
# which path is available so it doesn't reference a missing symbol.
|
||||
family = libretiny.get_libretiny_family()
|
||||
if family == FAMILY_BK7231N:
|
||||
cg.add_define("BK72XX_BLE_HAS_COMMON_BDADDR")
|
||||
elif family == FAMILY_BK7238:
|
||||
# ESPHome's LibreTiny disables BLE on BK7238 because the SDK can hang at
|
||||
# WiFi STA startup when BLE init runs. This component re-enables BLE, so
|
||||
# warn loudly: BK7238 is accepted but not hardware-verified and may be
|
||||
# WiFi-unstable with BLE on.
|
||||
_LOGGER.warning(
|
||||
"bk72xx_ble on BK7238: enabling BLE is known to risk a WiFi STA startup "
|
||||
"hang on this family and is not yet hardware-verified. Expect possible "
|
||||
"instability."
|
||||
)
|
||||
|
||||
cg.add_define("USE_BK72XX_BLE")
|
||||
@@ -0,0 +1,292 @@
|
||||
// bk72xx_ble.cpp
|
||||
//
|
||||
// BLE controller support for the BK72xx BLE-5.x chips (LibreTiny beken-72xx
|
||||
// family) — the platform analog of esp32_ble / rp2040_ble. Owns everything that
|
||||
// talks to the Beken BDK BLE stack:
|
||||
// - one-time stack bring-up (ble_set_notice_cb() + ble_entry()),
|
||||
// - the controller BLE address,
|
||||
// - the raw controller scan primitives (bk_ble_scan_start/stop),
|
||||
// - the scan-report ring: the BDK notice callback (BLE task) takes a report
|
||||
// from a fixed pool and pushes it on a lock-free SPSC queue; loop() drains,
|
||||
// dispatches on the main task and returns reports to the pool — the same
|
||||
// EventPool + LockFreeQueue handoff esp32_ble uses, zero allocation at
|
||||
// steady state.
|
||||
// Consumers contain no SDK calls of their own.
|
||||
//
|
||||
// NOTE: the Beken BDK BLE 5.x stack is compiled and linked by the LibreTiny
|
||||
// beken-72xx builder itself (prebuilt libble_<chip>.a + ble_5_x sources, gated
|
||||
// on CFG_SUPPORT_BLE / CFG_BLE_VERSION in sys_config.h). This component only
|
||||
// calls into it via the public ble_api.h — no framework patch is required.
|
||||
|
||||
#include "bk72xx_ble.h" // pulls esphome/core/defines.h for USE_BK72XX_BLE
|
||||
|
||||
#ifdef USE_BK72XX_BLE
|
||||
|
||||
#include <cstring>
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h" // get_mac_address_raw()
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SDK-capability gate (not a chip allowlist).
|
||||
// This component drives the Beken BLE *5.x* controller via its public API,
|
||||
// `ble_api.h`, which the LibreTiny beken-72xx builder ships only for the
|
||||
// BLE-5.x SoCs (it selects the `ble_pub` 5.x stack from CFG_BLE_VERSION; the
|
||||
// 4.2 SoCs build a different, older API with no ble_api.h). Gate on the header
|
||||
// itself so any BLE-5.x Beken chip — present or future — is supported without a
|
||||
// hard-coded list, and a non-5.x build fails here with a clear message instead
|
||||
// of a cryptic "ble_api.h: No such file or directory".
|
||||
// ---------------------------------------------------------------------------
|
||||
#if defined(CLANG_TIDY)
|
||||
// The clang-tidy environment does not carry the full Beken BDK BLE 5.x API
|
||||
// (its ble_api.h variant lacks parts of the 5.x surface), so there is nothing
|
||||
// accurate to analyze the SDK calls against — skip the file under analysis.
|
||||
#define BK72XX_BLE_NO_SDK
|
||||
#elif !__has_include("ble_api.h")
|
||||
#error \
|
||||
"bk72xx_ble requires a BLE 5.x Beken SDK (ble_api.h). Supported SoCs: BK7231N/BK7236 (BLE 5.1) and BK7238/BK7252N/BK7253 (BLE 5.2). BK7231T/BK7251/BK7271 (BLE 4.2) and BK7231Q (no BLE) are not supported."
|
||||
#endif
|
||||
|
||||
#ifndef BK72XX_BLE_NO_SDK
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Beken BDK BLE 5.x SDK — public API.
|
||||
// Exposed on the include path by the LibreTiny beken-72xx builder
|
||||
// (cores/.../ble_5_x_rw + driver/include). Wrapped in extern "C" because these
|
||||
// are C headers consumed from C++ (a standard C-header-from-C++ pattern).
|
||||
// ---------------------------------------------------------------------------
|
||||
extern "C" {
|
||||
#include "ble_api.h" // bk_ble_scan_start/stop, ble_entry, ble_set_notice_cb,
|
||||
// app_ble_get_idle_actv_idx_handle, struct scan_param,
|
||||
// recv_adv_t, ble_notice_t, BLE_5_REPORT_ADV, SCAN_ACTV
|
||||
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
|
||||
#include "common_bt_defines.h" // struct bd_addr
|
||||
// The controller's public BLE address, populated by the BDK during ble_entry().
|
||||
// Present on BK7231N; the other BLE-5.x chips' stacks have no such symbol — there the
|
||||
// address is derived from the WiFi MAC instead (matching the BDK's own fallback).
|
||||
extern struct bd_addr common_default_bdaddr;
|
||||
#endif
|
||||
// ble_entry() brings up the BDK BLE stack; it is not declared in ble_api.h, so
|
||||
// declare it here.
|
||||
void ble_entry(void);
|
||||
}
|
||||
|
||||
namespace esphome::bk72xx_ble {
|
||||
|
||||
static const char *const TAG = "bk72xx_ble";
|
||||
|
||||
// The BDK notice callback is a plain C function pointer with no user argument,
|
||||
// so it reaches the (single) component instance through a file-static pointer.
|
||||
static BK72xxBLE *s_ble = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BLE notice callback — runs in the BDK BLE task context.
|
||||
// The BK controller reports every advertisement as a BLE_5_REPORT_ADV notice
|
||||
// carrying a recv_adv_t. Copy it into the queue and return; all dispatch
|
||||
// happens in loop() on the main task.
|
||||
// ---------------------------------------------------------------------------
|
||||
static void ble_notice_callback(ble_notice_t notice, void *param) {
|
||||
if (s_ble == nullptr || param == nullptr)
|
||||
return;
|
||||
if (notice != BLE_5_REPORT_ADV)
|
||||
return;
|
||||
|
||||
const recv_adv_t *info = reinterpret_cast<const recv_adv_t *>(param);
|
||||
// rssi is a signed dBm carried in a uint8_t; cast through int8_t (standard for
|
||||
// a signed dBm value packed in a uint8_t).
|
||||
s_ble->enqueue_scan_report(info->adv_addr, static_cast<int8_t>(info->rssi), info->adv_addr_type, info->data,
|
||||
info->data_len);
|
||||
}
|
||||
|
||||
void BK72xxBLE::enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data,
|
||||
uint16_t data_len) {
|
||||
BLEScanReport *report = this->report_pool_.allocate();
|
||||
if (report == nullptr) {
|
||||
// Pool exhausted — the queue is full; count and drop.
|
||||
this->report_queue_.increment_dropped_count();
|
||||
return;
|
||||
}
|
||||
memcpy(report->mac, mac, 6);
|
||||
report->rssi = rssi;
|
||||
report->addr_type = addr_type;
|
||||
report->data_len =
|
||||
(data_len <= sizeof(report->data)) ? static_cast<uint8_t>(data_len) : static_cast<uint8_t>(sizeof(report->data));
|
||||
memcpy(report->data, data, report->data_len);
|
||||
// Cannot fail: the pool is sized to the queue capacity.
|
||||
this->report_queue_.push(report);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void BK72xxBLE::setup() {
|
||||
s_ble = this;
|
||||
// Resolve the MAC early so get_mac_lsb_first() is valid for consumers before
|
||||
// the stack is up (it is re-read once ble_entry() has run).
|
||||
this->resolve_mac_();
|
||||
if (this->enable_on_boot_) {
|
||||
this->enable();
|
||||
}
|
||||
}
|
||||
|
||||
// AFTER_WIFI, not BLUETOOTH: replicates the proven pre-split timing — the BDK
|
||||
// is first touched only once WiFi is up (single-core WiFi/BLE bring-up order).
|
||||
float BK72xxBLE::get_setup_priority() const { return setup_priority::AFTER_WIFI; }
|
||||
|
||||
void BK72xxBLE::enable() {
|
||||
if (this->state_ != BLEComponentState::STATE_OFF)
|
||||
return;
|
||||
this->state_ = BLEComponentState::ENABLING;
|
||||
|
||||
// One-time BLE stack init: register the notice callback, then bring up the
|
||||
// BDK BLE stack. The BDK has no teardown path — init happens at most once.
|
||||
ble_set_notice_cb(ble_notice_callback);
|
||||
ble_entry();
|
||||
|
||||
delay(100); // NOLINT — one-time BLE stack init; the SDK needs this settle time
|
||||
|
||||
// Re-read the BLE MAC now that the controller is up (common_default_bdaddr is
|
||||
// populated by ble_entry()); resolve_mac_() may have fallen back earlier.
|
||||
this->resolve_mac_();
|
||||
|
||||
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
|
||||
// Liveness heuristic (BK7231N): a healthy ble_entry() populates
|
||||
// common_default_bdaddr during init, so all-zero after the settle delay
|
||||
// suggests the stack did not come up. The BDK entry point returns void — no
|
||||
// return code exists — so warn rather than fail: scan starts against a dead
|
||||
// stack already fail cleanly downstream (no idle activity handle).
|
||||
bool bdaddr_live = false;
|
||||
for (uint8_t b : common_default_bdaddr.addr) {
|
||||
if (b != 0) {
|
||||
bdaddr_live = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!bdaddr_live)
|
||||
ESP_LOGW(TAG, "Controller address still unset after init; BLE stack may not have started");
|
||||
#endif
|
||||
|
||||
this->state_ = BLEComponentState::ACTIVE;
|
||||
ESP_LOGD(TAG, "BLE stack initialised");
|
||||
}
|
||||
|
||||
void BK72xxBLE::loop() {
|
||||
// Drain the lock-free ring filled by the BLE task; all per-report work runs
|
||||
// here on the main task, then the report returns to the pool.
|
||||
BLEScanReport *report = this->report_queue_.pop();
|
||||
if (report == nullptr)
|
||||
return;
|
||||
do {
|
||||
for (auto *listener : this->scan_listeners_)
|
||||
listener->on_scan_report(*report);
|
||||
this->report_pool_.release(report);
|
||||
} while ((report = this->report_queue_.pop()) != nullptr);
|
||||
|
||||
// Log dropped reports — only reachable when reports were processed; drops can
|
||||
// only occur while the queue is full, and only this loop drains it.
|
||||
uint16_t dropped = this->report_queue_.get_and_reset_dropped_count();
|
||||
if (dropped > 0)
|
||||
ESP_LOGW(TAG, "Dropped %u scan reports due to queue overflow", dropped);
|
||||
}
|
||||
|
||||
void BK72xxBLE::get_mac_lsb_first(uint8_t out[6]) const {
|
||||
for (int i = 0; i < 6; i++)
|
||||
out[i] = this->ble_mac_[i];
|
||||
}
|
||||
|
||||
void BK72xxBLE::dump_config() {
|
||||
// ble_mac_ is stored LSB-first (BLE convention); print [5..0] for the
|
||||
// MSB-first order Home Assistant shows.
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"BK72xx BLE:\n"
|
||||
" MAC address: %02X:%02X:%02X:%02X:%02X:%02X\n"
|
||||
" Active: %s",
|
||||
this->ble_mac_[5], this->ble_mac_[4], this->ble_mac_[3], this->ble_mac_[2], this->ble_mac_[1],
|
||||
this->ble_mac_[0], YESNO(this->is_active()));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MAC resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void BK72xxBLE::resolve_mac_() {
|
||||
#ifdef BK72XX_BLE_HAS_COMMON_BDADDR
|
||||
// BK7231N: the BDK populates common_default_bdaddr (LSB-first, BLE convention)
|
||||
// during ble_entry(). It may still be zero before the stack is up; if so, fall
|
||||
// through to the WiFi-derived MAC below.
|
||||
bool nonzero = false;
|
||||
for (uint8_t b : common_default_bdaddr.addr) {
|
||||
if (b != 0) {
|
||||
nonzero = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nonzero) {
|
||||
memcpy(this->ble_mac_, common_default_bdaddr.addr, 6);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
// Chips whose BLE stack does not export common_default_bdaddr (BK7238 and the other
|
||||
// BLE-5.x SoCs), or BK7231N before the stack is up: derive the BLE MAC exactly as the
|
||||
// Beken BDK does in bdaddr_env_init() — the WiFi STA MAC with only its last byte
|
||||
// incremented (sta_mac[5] += 1, a plain byte increment with no carry into the next
|
||||
// byte), OUI unchanged. This reproduces the address the controller advertises with
|
||||
// (verified against the BK7231N BLE-5.1 and BK7252N/BK7238 BLE-5.2 SDK sources), so it
|
||||
// matches on every device, including the last-byte == 0xFF edge that a 24-bit increment
|
||||
// would carry differently.
|
||||
uint8_t wifi_mac[6];
|
||||
get_mac_address_raw(wifi_mac); // MSB-first
|
||||
const uint8_t ble[6] = {wifi_mac[0], wifi_mac[1], wifi_mac[2],
|
||||
wifi_mac[3], wifi_mac[4], static_cast<uint8_t>(wifi_mac[5] + 1)};
|
||||
// Store LSB-first to match recv_adv_t adv_addr ordering.
|
||||
for (int i = 0; i < 6; i++)
|
||||
this->ble_mac_[i] = ble[5 - i];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Controller scan primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool BK72xxBLE::scan_start(uint16_t interval, uint16_t window) {
|
||||
if (!this->is_active())
|
||||
this->enable();
|
||||
|
||||
if (this->scan_actv_idx_ != 0xFF) {
|
||||
// Already scanning — stop first so this call cleanly restarts with the new
|
||||
// parameters (the BDK cannot start a second scan on a busy activity).
|
||||
this->scan_stop();
|
||||
}
|
||||
|
||||
struct scan_param sp;
|
||||
memset(&sp, 0, sizeof(sp));
|
||||
sp.channel_map = 7; // advertising channels 37/38/39
|
||||
sp.interval = interval;
|
||||
sp.window = window;
|
||||
|
||||
this->scan_actv_idx_ = app_ble_get_idle_actv_idx_handle(SCAN_ACTV);
|
||||
if (this->scan_actv_idx_ == 0xFF) {
|
||||
ESP_LOGE(TAG, "Scan start failed: no idle activity handle");
|
||||
return false;
|
||||
}
|
||||
ble_err_t ret = bk_ble_scan_start(this->scan_actv_idx_, &sp, nullptr);
|
||||
if (ret != ERR_SUCCESS) {
|
||||
ESP_LOGE(TAG, "Scan start failed (err %d)", static_cast<int>(ret));
|
||||
this->scan_actv_idx_ = 0xFF;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void BK72xxBLE::scan_stop() {
|
||||
if (this->scan_actv_idx_ != 0xFF) {
|
||||
bk_ble_scan_stop(this->scan_actv_idx_, nullptr);
|
||||
this->scan_actv_idx_ = 0xFF;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
#endif // BK72XX_BLE_NO_SDK
|
||||
#endif // USE_BK72XX_BLE
|
||||
@@ -0,0 +1,98 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_BK72XX_BLE
|
||||
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/event_pool.h"
|
||||
#include "esphome/core/lock_free_queue.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace esphome::bk72xx_ble {
|
||||
|
||||
enum class BLEComponentState : uint8_t {
|
||||
STATE_OFF = 0,
|
||||
ENABLING,
|
||||
ACTIVE,
|
||||
};
|
||||
|
||||
/// One advertisement report from the controller.
|
||||
struct BLEScanReport {
|
||||
uint8_t mac[6]; // LSB-first, as the controller delivers it
|
||||
int8_t rssi; // signed dBm
|
||||
uint8_t addr_type;
|
||||
uint8_t data_len; // bytes valid in data[]
|
||||
uint8_t data[62]; // legacy advertisement (31) + scan response (31)
|
||||
|
||||
// EventPool contract: nothing is heap-allocated inside a report.
|
||||
void release() {}
|
||||
};
|
||||
|
||||
/// Consumer interface for controller scan reports. on_scan_report() always runs
|
||||
/// on the ESPHome main task: reports are queued from the BDK BLE task and
|
||||
/// drained by the controller's loop(), so consumers never deal with cross-task
|
||||
/// state (the esp32_ble event-queue pattern).
|
||||
class BLEScanListener {
|
||||
public:
|
||||
virtual void on_scan_report(const BLEScanReport &report) = 0;
|
||||
|
||||
protected:
|
||||
~BLEScanListener() = default; // deletion via this interface is not part of the contract
|
||||
};
|
||||
|
||||
// Maximum reports buffered between the BLE task and loop().
|
||||
static constexpr uint8_t MAX_SCAN_REPORT_QUEUE_SIZE = 64;
|
||||
|
||||
class BK72xxBLE final : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
float get_setup_priority() const override;
|
||||
|
||||
/// Bring up the BDK BLE stack (one-time; the BDK has no teardown path).
|
||||
void enable();
|
||||
bool is_active() const { return this->state_ == BLEComponentState::ACTIVE; }
|
||||
|
||||
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
|
||||
|
||||
/// Controller BLE address, least-significant octet first (BLE convention).
|
||||
void get_mac_lsb_first(uint8_t out[6]) const;
|
||||
|
||||
/// Register a consumer for scan reports (delivered on the main task via loop()).
|
||||
void register_scan_listener(BLEScanListener *listener) { this->scan_listeners_.push_back(listener); }
|
||||
|
||||
/// Start the controller scan. Interval/window are in BLE units (0.625 ms).
|
||||
/// Enables the stack first if needed. Returns false on controller failure.
|
||||
bool scan_start(uint16_t interval, uint16_t window);
|
||||
/// Stop the controller scan (no-op when not scanning).
|
||||
void scan_stop();
|
||||
|
||||
/// Internal: buffer one controller report (BDK notice callback, BLE task
|
||||
/// context — bounded copy under the scheduler lock, nothing else).
|
||||
void enqueue_scan_report(const uint8_t *mac, int8_t rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len);
|
||||
|
||||
protected:
|
||||
void resolve_mac_();
|
||||
|
||||
std::vector<BLEScanListener *> scan_listeners_;
|
||||
// Report ring: the BDK notice callback (BLE task) allocates a report from the
|
||||
// pool, fills it and pushes the pointer; loop() pops, dispatches and releases.
|
||||
// Lock-free SPSC, zero allocation at steady state — the esp32_ble pattern.
|
||||
esphome::LockFreeQueue<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE> report_queue_;
|
||||
// Pool sized to queue capacity (SIZE-1): the ring reserves one slot, so
|
||||
// allocate() returns nullptr before push() can fail. This prevents leaking a
|
||||
// pool slot on a failed push and keeps release() off the producer path.
|
||||
esphome::EventPool<BLEScanReport, MAX_SCAN_REPORT_QUEUE_SIZE - 1> report_pool_;
|
||||
uint8_t ble_mac_[6]{0}; // LSB-first (BLE convention)
|
||||
uint8_t scan_actv_idx_{0xFF};
|
||||
BLEComponentState state_{BLEComponentState::STATE_OFF};
|
||||
bool enable_on_boot_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::bk72xx_ble
|
||||
|
||||
#endif // USE_BK72XX_BLE
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
ble_device_base — the platform-neutral BLE layer.
|
||||
|
||||
Owns the shared advertisement types (ESPBTUUID / ESPBTDevice / ServiceData /
|
||||
ESPBLEiBeacon / ESPBTDeviceListener, in ble_device.h) and the tracker contract
|
||||
(BLEHub, in ble_hub.h) on every platform.
|
||||
|
||||
BLE consumers (sensor components, bluetooth_proxy) bind to whichever tracker the
|
||||
configuration declares via `cv.use_id(BLEHub)` — ESPHome resolves any declared
|
||||
subclass, so there is no platform table here and no dependency in either
|
||||
direction. A sensor appends inject_ble_hub to its CONFIG_SCHEMA (via cv.All) and
|
||||
calls register_ble_device() in to_code; a tracker component subclasses BLEHub
|
||||
(C++ and codegen class). Adding a new BLE chip requires only a new tracker
|
||||
component.
|
||||
|
||||
AES-CCM decryption for encrypted advertisements is provided portably in
|
||||
ble_aes_ccm.h.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@Bl00d-B0b"]
|
||||
|
||||
CONF_BLE_HUB_ID = "ble_hub_id"
|
||||
|
||||
# CORE.data key: number of parsed-advertisement listeners registered in this
|
||||
# build. Trackers whose codegen sizes storage at compile time (esp32's
|
||||
# StaticVector count define) read it in their final coroutine.
|
||||
KEY_BLE_LISTENER_COUNT = "ble_device_base_listener_count"
|
||||
|
||||
ble_device_base_ns = cg.esphome_ns.namespace("ble_device_base")
|
||||
|
||||
# The neutral tracker contract. Every tracker's codegen class declares this as a
|
||||
# parent, which is what lets cv.use_id(BLEHub) resolve any of them.
|
||||
BLEHub = ble_device_base_ns.class_("BLEHub")
|
||||
|
||||
# The neutral listener base (C++: ble_device_base::ESPBTDeviceListener).
|
||||
ESPBTDeviceListener = ble_device_base_ns.class_("ESPBTDeviceListener")
|
||||
|
||||
|
||||
def inject_ble_hub(config: ConfigType) -> ConfigType:
|
||||
"""Validator: auto-resolve the configured BLE tracker into the config.
|
||||
|
||||
Append via cv.All to a BLE consumer's CONFIG_SCHEMA. Uses cv.GenerateID +
|
||||
cv.use_id(BLEHub): an omitted id resolves to the single declared tracker on
|
||||
any platform; multiple trackers can be disambiguated with an explicit
|
||||
ble_hub_id.
|
||||
"""
|
||||
return cv.Schema(
|
||||
{cv.GenerateID(CONF_BLE_HUB_ID): cv.use_id(BLEHub)}, extra=cv.ALLOW_EXTRA
|
||||
)(config)
|
||||
|
||||
|
||||
def request_irk_support() -> None:
|
||||
"""Compile in resolve_irk()'s software-AES path. Called by sensors with an
|
||||
irk: option so builds without IRK do not carry the resolution code."""
|
||||
cg.add_define("USE_BLE_DEVICE_IRK")
|
||||
|
||||
|
||||
def get_listener_count() -> int:
|
||||
"""Number of parsed listeners registered so far (for tracker codegen)."""
|
||||
return CORE.data.get(KEY_BLE_LISTENER_COUNT, 0)
|
||||
|
||||
|
||||
async def register_ble_device(var: cg.MockObj, config: ConfigType) -> cg.MockObj:
|
||||
"""Register `var` as a parsed-advertisement listener on the configured hub."""
|
||||
hub = await cg.get_variable(config[CONF_BLE_HUB_ID])
|
||||
cg.add(hub.register_listener(var))
|
||||
CORE.data[KEY_BLE_LISTENER_COUNT] = CORE.data.get(KEY_BLE_LISTENER_COUNT, 0) + 1
|
||||
return var
|
||||
|
||||
|
||||
# ---- shared validation / codegen helpers (platform-neutral) ----
|
||||
BT_UUID16_FORMAT = "XXXX"
|
||||
BT_UUID32_FORMAT = "XXXXXXXX"
|
||||
BT_UUID128_FORMAT = "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
|
||||
|
||||
_BT_UUID16_RE = re.compile("^[A-F0-9]{4,}$")
|
||||
_BT_UUID32_RE = re.compile("^[A-F0-9]{8,}$")
|
||||
_BT_UUID128_RE = re.compile(
|
||||
"^[A-F0-9]{8,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{4,}-[A-F0-9]{12,}$"
|
||||
)
|
||||
|
||||
|
||||
# Validator table keyed by input length: (compiled pattern, label used in errors).
|
||||
_BT_UUID_FORMATS = {
|
||||
len(BT_UUID16_FORMAT): (_BT_UUID16_RE, "16 bit"),
|
||||
len(BT_UUID32_FORMAT): (_BT_UUID32_RE, "32 bit"),
|
||||
len(BT_UUID128_FORMAT): (_BT_UUID128_RE, "128"),
|
||||
}
|
||||
|
||||
|
||||
def bt_uuid(value: str) -> str:
|
||||
in_value = cv.string_strict(value)
|
||||
value = in_value.upper()
|
||||
|
||||
fmt = _BT_UUID_FORMATS.get(len(value))
|
||||
if fmt is None:
|
||||
raise cv.Invalid(
|
||||
f"Bluetooth UUID must be in 16 bit '{BT_UUID16_FORMAT}', 32 bit '{BT_UUID32_FORMAT}', or 128 bit '{BT_UUID128_FORMAT}' format"
|
||||
)
|
||||
pattern, label = fmt
|
||||
if not pattern.match(value):
|
||||
raise cv.Invalid(
|
||||
f"Invalid hexadecimal value for {label} UUID format: '{in_value}'"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def as_hex(value: str) -> cg.RawExpression:
|
||||
return cg.RawExpression(f"0x{value}ULL")
|
||||
|
||||
|
||||
def _hex_array_expression(value: str, reverse: bool) -> cg.RawExpression:
|
||||
value = value.replace("-", "")
|
||||
cpp_array = [
|
||||
f"0x{part}" for part in [value[i : i + 2] for i in range(0, len(value), 2)]
|
||||
]
|
||||
if reverse:
|
||||
cpp_array.reverse()
|
||||
return cg.RawExpression(f"(uint8_t*)(const uint8_t[16]){{{','.join(cpp_array)}}}")
|
||||
|
||||
|
||||
def as_hex_array(value: str) -> cg.RawExpression:
|
||||
return _hex_array_expression(value, reverse=False)
|
||||
|
||||
|
||||
def as_reversed_hex_array(value: str) -> cg.RawExpression:
|
||||
return _hex_array_expression(value, reverse=True)
|
||||
@@ -0,0 +1,202 @@
|
||||
#include "ble_aes_ccm.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
namespace {
|
||||
|
||||
// AES-128 forward cipher only — CCM uses the block cipher in the encrypt
|
||||
// direction for both the CTR keystream and the CBC-MAC.
|
||||
const uint8_t SBOX[256] = {
|
||||
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76, //
|
||||
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, //
|
||||
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15, //
|
||||
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75, //
|
||||
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, //
|
||||
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf, //
|
||||
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8, //
|
||||
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, //
|
||||
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73, //
|
||||
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb, //
|
||||
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, //
|
||||
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08, //
|
||||
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a, //
|
||||
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, //
|
||||
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf, //
|
||||
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16, //
|
||||
};
|
||||
|
||||
const uint8_t RCON[11] = {0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36};
|
||||
|
||||
inline uint8_t xtime(uint8_t x) { return static_cast<uint8_t>((x << 1) ^ ((x & 0x80) ? 0x1b : 0x00)); }
|
||||
|
||||
// AES-128 forward cipher with on-the-fly key schedule.
|
||||
class Aes128 {
|
||||
public:
|
||||
explicit Aes128(const uint8_t key[16]) {
|
||||
memcpy(this->rk_, key, 16);
|
||||
for (size_t i = 16; i < 176; i += 4) {
|
||||
uint8_t t[4] = {this->rk_[i - 4], this->rk_[i - 3], this->rk_[i - 2], this->rk_[i - 1]};
|
||||
if (i % 16 == 0) {
|
||||
const uint8_t tmp = t[0];
|
||||
t[0] = static_cast<uint8_t>(SBOX[t[1]] ^ RCON[i / 16]);
|
||||
t[1] = SBOX[t[2]];
|
||||
t[2] = SBOX[t[3]];
|
||||
t[3] = SBOX[tmp];
|
||||
}
|
||||
for (size_t j = 0; j < 4; j++)
|
||||
this->rk_[i + j] = static_cast<uint8_t>(this->rk_[i - 16 + j] ^ t[j]);
|
||||
}
|
||||
}
|
||||
|
||||
void encrypt(const uint8_t in[16], uint8_t out[16]) const {
|
||||
uint8_t s[16];
|
||||
memcpy(s, in, 16);
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
s[i] ^= this->rk_[i];
|
||||
|
||||
for (size_t round = 1; round < 10; round++) {
|
||||
for (uint8_t &b : s)
|
||||
b = SBOX[b];
|
||||
shift_rows(s);
|
||||
for (size_t c = 0; c < 4; c++) {
|
||||
uint8_t *col = s + c * 4;
|
||||
const uint8_t a0 = col[0], a1 = col[1], a2 = col[2], a3 = col[3];
|
||||
const uint8_t h = static_cast<uint8_t>(a0 ^ a1 ^ a2 ^ a3);
|
||||
col[0] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a0 ^ a1)));
|
||||
col[1] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a1 ^ a2)));
|
||||
col[2] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a2 ^ a3)));
|
||||
col[3] ^= static_cast<uint8_t>(h ^ xtime(static_cast<uint8_t>(a3 ^ a0)));
|
||||
}
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
s[i] ^= this->rk_[round * 16 + i];
|
||||
}
|
||||
|
||||
for (uint8_t &b : s)
|
||||
b = SBOX[b];
|
||||
shift_rows(s);
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
s[i] ^= this->rk_[160 + i];
|
||||
memcpy(out, s, 16);
|
||||
}
|
||||
|
||||
protected:
|
||||
static void shift_rows(uint8_t s[16]) {
|
||||
uint8_t t = s[1];
|
||||
s[1] = s[5];
|
||||
s[5] = s[9];
|
||||
s[9] = s[13];
|
||||
s[13] = t;
|
||||
t = s[2];
|
||||
s[2] = s[10];
|
||||
s[10] = t;
|
||||
t = s[6];
|
||||
s[6] = s[14];
|
||||
s[14] = t;
|
||||
t = s[3];
|
||||
s[3] = s[15];
|
||||
s[15] = s[11];
|
||||
s[11] = s[7];
|
||||
s[7] = t;
|
||||
}
|
||||
|
||||
uint8_t rk_[176];
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]) {
|
||||
Aes128 aes(key);
|
||||
aes.encrypt(in, out);
|
||||
}
|
||||
|
||||
bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad,
|
||||
size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext,
|
||||
const uint8_t *tag, size_t tag_len) {
|
||||
// CCM length field width L and tag width M (RFC 3610 §2.2). For a 13-byte
|
||||
// nonce L = 2; BTHome uses M = 4.
|
||||
if (nonce_len < 7 || nonce_len > 13 || tag_len < 4 || tag_len > 16)
|
||||
return false;
|
||||
const size_t l = 15 - nonce_len;
|
||||
const size_t m = tag_len;
|
||||
|
||||
const Aes128 aes(key);
|
||||
|
||||
// Build CTR block A_i = [L-1] | nonce | counter(L bytes, big-endian).
|
||||
uint8_t a[16];
|
||||
auto build_ctr = [&](uint32_t counter) {
|
||||
a[0] = static_cast<uint8_t>(l - 1);
|
||||
memcpy(a + 1, nonce, nonce_len);
|
||||
memset(a + 1 + nonce_len, 0, l);
|
||||
for (size_t i = 0; i < l; i++)
|
||||
a[15 - i] = static_cast<uint8_t>((counter >> (8 * i)) & 0xff);
|
||||
};
|
||||
|
||||
// S_0 = E(A_0); its first m bytes mask the transmitted tag.
|
||||
uint8_t s0[16];
|
||||
build_ctr(0);
|
||||
aes.encrypt(a, s0);
|
||||
|
||||
// CTR-decrypt ciphertext into plaintext using S_1, S_2, ...
|
||||
uint8_t ks[16];
|
||||
for (size_t off = 0; off < ct_len; off += 16) {
|
||||
build_ctr(static_cast<uint32_t>(off / 16) + 1);
|
||||
aes.encrypt(a, ks);
|
||||
const size_t n = std::min(static_cast<size_t>(16), ct_len - off);
|
||||
for (size_t i = 0; i < n; i++)
|
||||
plaintext[off + i] = static_cast<uint8_t>(ciphertext[off + i] ^ ks[i]);
|
||||
}
|
||||
|
||||
// CBC-MAC over B_0 | (formatted AAD) | plaintext.
|
||||
uint8_t x[16];
|
||||
uint8_t b0[16];
|
||||
const uint8_t flags = static_cast<uint8_t>((aad_len > 0 ? 0x40 : 0x00) | (((m - 2) / 2) << 3) | (l - 1));
|
||||
b0[0] = flags;
|
||||
memcpy(b0 + 1, nonce, nonce_len);
|
||||
memset(b0 + 1 + nonce_len, 0, l);
|
||||
for (size_t i = 0; i < l; i++)
|
||||
b0[15 - i] = static_cast<uint8_t>((ct_len >> (8 * i)) & 0xff);
|
||||
aes.encrypt(b0, x); // X_1 = E(B_0)
|
||||
|
||||
if (aad_len > 0) {
|
||||
// Only the < 2^16-2^8 encoding is needed for BLE-sized AAD.
|
||||
uint8_t blk[16] = {0};
|
||||
blk[0] = static_cast<uint8_t>((aad_len >> 8) & 0xff);
|
||||
blk[1] = static_cast<uint8_t>(aad_len & 0xff);
|
||||
size_t ai = 0;
|
||||
size_t pos = 2;
|
||||
while (pos < 16 && ai < aad_len)
|
||||
blk[pos++] = aad[ai++];
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
x[i] ^= blk[i];
|
||||
aes.encrypt(x, x);
|
||||
while (ai < aad_len) {
|
||||
memset(blk, 0, 16);
|
||||
const size_t n = std::min(static_cast<size_t>(16), aad_len - ai);
|
||||
memcpy(blk, aad + ai, n);
|
||||
ai += n;
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
x[i] ^= blk[i];
|
||||
aes.encrypt(x, x);
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t off = 0; off < ct_len; off += 16) {
|
||||
uint8_t blk[16] = {0};
|
||||
const size_t n = std::min(static_cast<size_t>(16), ct_len - off);
|
||||
memcpy(blk, plaintext + off, n);
|
||||
for (size_t i = 0; i < 16; i++)
|
||||
x[i] ^= blk[i];
|
||||
aes.encrypt(x, x);
|
||||
}
|
||||
|
||||
// Expected tag U = T XOR S_0[0..m). Constant-time compare with the received tag.
|
||||
uint8_t diff = 0;
|
||||
for (size_t i = 0; i < m; i++)
|
||||
diff |= static_cast<uint8_t>((x[i] ^ s0[i]) ^ tag[i]);
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -0,0 +1,34 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
// Self-contained AES-128-CCM authenticated decryption (RFC 3610).
|
||||
//
|
||||
// Encrypted BLE advertisements (BTHome, several Xiaomi/ATC variants) use
|
||||
// AES-128-CCM. The platform crypto that provides it is inconsistent across BLE
|
||||
// targets: ESP-IDF exposes PSA/mbedtls, but a LibreTiny SDK may keep its mbedtls
|
||||
// internal (e.g. the beken-72xx SDK ships mbedtls with CCM enabled but does not
|
||||
// put it on the application include path), so a sensor cannot rely on
|
||||
// <mbedtls/ccm.h> being available. This software implementation makes
|
||||
// encrypted-advertisement decryption work on every BLE platform without a
|
||||
// per-chip crypto dependency. Decryption volume is tiny (one short block per
|
||||
// matching advertisement), so software AES is not a meaningful cost.
|
||||
//
|
||||
// Verifies the CCM authentication tag and, on success, writes `ct_len` decrypted
|
||||
// bytes to `plaintext` and returns true. Returns false when authentication fails
|
||||
// (the caller must then discard `plaintext`). The CCM parameters follow the
|
||||
// caller (BTHome: 13-byte nonce, 4-byte tag, no associated data); `aad` may be
|
||||
// null when `aad_len` is 0.
|
||||
/// AES-128 single-block encrypt (the same software cipher CCM uses). Used by
|
||||
/// ESPBTDevice::resolve_irk() for the Bluetooth "ah" RPA hash, so IRK matching
|
||||
/// works identically on every platform with no chip crypto dependency.
|
||||
void aes128_encrypt_block(const uint8_t key[16], const uint8_t in[16], uint8_t out[16]);
|
||||
|
||||
bool aes_ccm_auth_decrypt(const uint8_t key[16], const uint8_t *nonce, size_t nonce_len, const uint8_t *aad,
|
||||
size_t aad_len, const uint8_t *ciphertext, size_t ct_len, uint8_t *plaintext,
|
||||
const uint8_t *tag, size_t tag_len);
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -0,0 +1,496 @@
|
||||
// ble_device.cpp
|
||||
//
|
||||
// Platform-neutral implementation of the shared BLE advertisement types.
|
||||
// Parses raw BLE advertisement data into ESPBTDevice.
|
||||
|
||||
#include "ble_device.h"
|
||||
|
||||
#include "ble_aes_ccm.h"
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
static const char *const TAG = "ble_device_base";
|
||||
|
||||
// Longest advertisement payload worth hex-dumping at VERY_VERBOSE
|
||||
// (legacy advertising: 31-byte adv + 31-byte scan response).
|
||||
static constexpr size_t BLE_ADV_MAX_LOG_BYTES = 62;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBTUUID
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ESPBTUUID ESPBTUUID::from_uint16(uint16_t uuid) {
|
||||
ESPBTUUID ret;
|
||||
ret.type_ = Type::UUID16;
|
||||
ret.uuid_.uuid16 = uuid;
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESPBTUUID ESPBTUUID::from_uint32(uint32_t uuid) {
|
||||
ESPBTUUID ret;
|
||||
ret.type_ = Type::UUID32;
|
||||
ret.uuid_.uuid32 = uuid;
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESPBTUUID ESPBTUUID::from_raw(const uint8_t *data) {
|
||||
ESPBTUUID ret;
|
||||
ret.type_ = Type::UUID128;
|
||||
memcpy(ret.uuid_.uuid128, data, 16);
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESPBTUUID ESPBTUUID::from_raw_reversed(const uint8_t *data) {
|
||||
ESPBTUUID ret;
|
||||
ret.type_ = Type::UUID128;
|
||||
for (int i = 0; i < 16; i++)
|
||||
ret.uuid_.uuid128[i] = data[15 - i];
|
||||
return ret;
|
||||
}
|
||||
|
||||
ESPBTUUID ESPBTUUID::from_raw(const char *data, size_t length) {
|
||||
// Same text-parsing semantics as the historical esp32_ble::ESPBTUUID::from_raw.
|
||||
ESPBTUUID ret;
|
||||
if (length == 4) {
|
||||
// 16-bit UUID as 4-character hex string
|
||||
auto parsed = parse_hex<uint16_t>(data, length);
|
||||
if (parsed.has_value()) {
|
||||
ret.type_ = Type::UUID16;
|
||||
ret.uuid_.uuid16 = parsed.value();
|
||||
}
|
||||
} else if (length == 8) {
|
||||
// 32-bit UUID as 8-character hex string
|
||||
auto parsed = parse_hex<uint32_t>(data, length);
|
||||
if (parsed.has_value()) {
|
||||
ret.type_ = Type::UUID32;
|
||||
ret.uuid_.uuid32 = parsed.value();
|
||||
}
|
||||
} else if (length == 16) {
|
||||
// 16 raw bytes (little-endian 128-bit UUID)
|
||||
ret.type_ = Type::UUID128;
|
||||
memcpy(ret.uuid_.uuid128, reinterpret_cast<const uint8_t *>(data), 16);
|
||||
} else if (length == 36) {
|
||||
// Dashed text form XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
|
||||
ret.type_ = Type::UUID128;
|
||||
int n = 0;
|
||||
for (size_t i = 0; i < length; i += 2) {
|
||||
if (data[i] == '-')
|
||||
i++;
|
||||
uint8_t msb = data[i];
|
||||
uint8_t lsb = data[i + 1];
|
||||
if (msb > '9')
|
||||
msb -= 7;
|
||||
if (lsb > '9')
|
||||
lsb -= 7;
|
||||
ret.uuid_.uuid128[15 - n++] = ((msb & 0x0F) << 4) | (lsb & 0x0F);
|
||||
}
|
||||
} else {
|
||||
ESP_LOGE(TAG, "ERROR: UUID value not 4, 8, 16 or 36 bytes - %s", data);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32
|
||||
ESPBTUUID ESPBTUUID::from_uuid(esp_bt_uuid_t uuid) {
|
||||
if (uuid.len == ESP_UUID_LEN_16)
|
||||
return ESPBTUUID::from_uint16(uuid.uuid.uuid16);
|
||||
if (uuid.len == ESP_UUID_LEN_32)
|
||||
return ESPBTUUID::from_uint32(uuid.uuid.uuid32);
|
||||
return ESPBTUUID::from_raw(uuid.uuid.uuid128);
|
||||
}
|
||||
|
||||
esp_bt_uuid_t ESPBTUUID::get_uuid() const {
|
||||
esp_bt_uuid_t ret;
|
||||
switch (this->type_) {
|
||||
case Type::UUID16:
|
||||
ret.len = ESP_UUID_LEN_16;
|
||||
ret.uuid.uuid16 = this->uuid_.uuid16;
|
||||
break;
|
||||
case Type::UUID32:
|
||||
ret.len = ESP_UUID_LEN_32;
|
||||
ret.uuid.uuid32 = this->uuid_.uuid32;
|
||||
break;
|
||||
default:
|
||||
case Type::UUID128:
|
||||
ret.len = ESP_UUID_LEN_128;
|
||||
memcpy(ret.uuid.uuid128, this->uuid_.uuid128, ESP_UUID_LEN_128);
|
||||
break;
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
void ESPBTDevice::parse_scan_rst(const esp32_ble::BLEScanResult &scan_result) {
|
||||
this->scan_result_ = &scan_result;
|
||||
// BLEScanResult's bda is most-significant octet first; the neutral ingest
|
||||
// takes the BLE controller (LSB-first) order, so reverse — address_uint64()/
|
||||
// address_str() then produce exactly the historical esp32 values.
|
||||
uint8_t mac_lsb_first[6];
|
||||
for (uint8_t i = 0; i < 6; i++)
|
||||
mac_lsb_first[i] = scan_result.bda[5 - i];
|
||||
this->from_scan_result(mac_lsb_first, scan_result.rssi, scan_result.ble_addr_type, scan_result.ble_adv,
|
||||
scan_result.adv_data_len + scan_result.scan_rsp_len);
|
||||
}
|
||||
#endif // USE_ESP32
|
||||
|
||||
ESPBTUUID ESPBTUUID::as_128bit() const {
|
||||
if (this->type_ == Type::UUID128)
|
||||
return *this;
|
||||
uint8_t data[16];
|
||||
this->to_128bit_(data);
|
||||
return ESPBTUUID::from_raw(data);
|
||||
}
|
||||
|
||||
bool ESPBTUUID::contains(uint8_t data1, uint8_t data2) const {
|
||||
// Adjacent byte-pair search — identical semantics to esp32_ble::ESPBTUUID::contains.
|
||||
switch (this->type_) {
|
||||
case Type::UUID16:
|
||||
return (this->uuid_.uuid16 >> 8) == data2 && (this->uuid_.uuid16 & 0xFF) == data1;
|
||||
case Type::UUID32:
|
||||
for (uint8_t i = 0; i < 3; i++) {
|
||||
bool a = ((this->uuid_.uuid32 >> i * 8) & 0xFF) == data1;
|
||||
bool b = ((this->uuid_.uuid32 >> (i + 1) * 8) & 0xFF) == data2;
|
||||
if (a && b)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
case Type::UUID128:
|
||||
for (uint8_t i = 0; i < 15; i++) {
|
||||
if (this->uuid_.uuid128[i] == data1 && this->uuid_.uuid128[i + 1] == data2)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const char *ESPBTUUID::to_str(char *buf) const {
|
||||
// Identical output format to esp32_ble::ESPBTUUID::to_str.
|
||||
char *pos = buf;
|
||||
switch (this->type_) {
|
||||
case Type::UUID16:
|
||||
*pos++ = '0';
|
||||
*pos++ = 'x';
|
||||
*pos++ = format_hex_pretty_char(this->uuid_.uuid16 >> 12);
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 8) & 0x0F);
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid16 >> 4) & 0x0F);
|
||||
*pos++ = format_hex_pretty_char(this->uuid_.uuid16 & 0x0F);
|
||||
*pos = 0; // NUL-terminate
|
||||
return buf;
|
||||
case Type::UUID32:
|
||||
*pos++ = '0';
|
||||
*pos++ = 'x';
|
||||
for (int shift = 28; shift >= 0; shift -= 4)
|
||||
*pos++ = format_hex_pretty_char((this->uuid_.uuid32 >> shift) & 0x0F);
|
||||
*pos = 0; // NUL-terminate
|
||||
return buf;
|
||||
default:
|
||||
case Type::UUID128:
|
||||
// Format: XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX
|
||||
for (int8_t i = 15; i >= 0; i--) {
|
||||
uint8_t byte = this->uuid_.uuid128[i];
|
||||
*pos++ = format_hex_pretty_char(byte >> 4);
|
||||
*pos++ = format_hex_pretty_char(byte & 0x0F);
|
||||
if (i == 12 || i == 10 || i == 8 || i == 6)
|
||||
*pos++ = '-';
|
||||
}
|
||||
*pos = 0; // NUL-terminate
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
void ESPBTUUID::to_128bit_(uint8_t out[16]) const {
|
||||
// Bluetooth Base UUID 00000000-0000-1000-8000-00805F9B34FB (LSB-first), with the 16/32-bit
|
||||
// value placed at bytes 12..; identical expansion to esp32_ble::ESPBTUUID::as_128bit().
|
||||
static const uint8_t BASE[16] = {0xFB, 0x34, 0x9B, 0x5F, 0x80, 0x00, 0x00, 0x80,
|
||||
0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00};
|
||||
if (this->type_ == Type::UUID128) {
|
||||
memcpy(out, this->uuid_.uuid128, 16);
|
||||
return;
|
||||
}
|
||||
memcpy(out, BASE, 16);
|
||||
const uint32_t value = (this->type_ == Type::UUID32) ? this->uuid_.uuid32 : this->uuid_.uuid16;
|
||||
const size_t len = (this->type_ == Type::UUID32) ? 4 : 2;
|
||||
for (size_t i = 0; i < len; i++)
|
||||
out[12 + i] = (value >> (i * 8)) & 0xFF;
|
||||
}
|
||||
|
||||
bool ESPBTUUID::operator==(const ESPBTUUID &other) const {
|
||||
if (this->type_ == other.type_) {
|
||||
switch (this->type_) {
|
||||
case Type::UUID16:
|
||||
return this->uuid_.uuid16 == other.uuid_.uuid16;
|
||||
case Type::UUID32:
|
||||
return this->uuid_.uuid32 == other.uuid_.uuid32;
|
||||
case Type::UUID128:
|
||||
return memcmp(this->uuid_.uuid128, other.uuid_.uuid128, 16) == 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Different widths: expand both to the 128-bit Bluetooth Base UUID form and compare, so a
|
||||
// configured 16/32-bit UUID matches the equivalent 128-bit advertisement (esp32 parity).
|
||||
uint8_t a[16];
|
||||
uint8_t b[16];
|
||||
this->to_128bit_(a);
|
||||
other.to_128bit_(b);
|
||||
return memcmp(a, b, 16) == 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBLEiBeacon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
ESPBLEiBeacon::ESPBLEiBeacon(const uint8_t *data) { memcpy(&this->beacon_data_, data, sizeof(this->beacon_data_)); }
|
||||
|
||||
optional<ESPBLEiBeacon> ESPBLEiBeacon::from_manufacturer_data(const ServiceData &data) {
|
||||
// iBeacon manufacturer specific data (after company-ID bytes have been stripped):
|
||||
// [0x02][0x15][16-byte UUID][2-byte major][2-byte minor][1-byte power] = exactly 23 bytes
|
||||
// Parity with esp32_ble_tracker: gate on the Apple company ID and length only.
|
||||
// (Checking the 0x02/0x15 sub-type prefix would be stricter, but is a behavior
|
||||
// change; it belongs to a follow-up, not this refactor.)
|
||||
if (!data.uuid.contains(0x4C, 0x00)) // Apple company ID 0x004C
|
||||
return {};
|
||||
if (data.data.size() != 23)
|
||||
return {};
|
||||
return ESPBLEiBeacon(data.data.data());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBTDevice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void ESPBTDevice::from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data,
|
||||
uint16_t data_len) {
|
||||
// Ingest is BLE controller order (LSB-first); store in printable (MSB-first)
|
||||
// order so the raw address() accessor matches the historical esp32 layout.
|
||||
for (uint8_t i = 0; i < 6; i++)
|
||||
this->address_[i] = mac[5 - i];
|
||||
this->address_type_ = addr_type;
|
||||
this->rssi_ = rssi;
|
||||
this->name_.clear();
|
||||
this->service_uuids_.clear();
|
||||
this->manufacturer_datas_.clear();
|
||||
this->service_datas_.clear();
|
||||
this->tx_powers_.clear();
|
||||
this->appearance_.reset();
|
||||
this->ad_flag_.reset();
|
||||
this->parse_adv_(data, data_len);
|
||||
|
||||
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
|
||||
ESP_LOGVV(TAG, "Parse Result:");
|
||||
const char *address_type;
|
||||
switch (this->address_type_) {
|
||||
case BLE_ADDR_TYPE_PUBLIC:
|
||||
address_type = "PUBLIC";
|
||||
break;
|
||||
case BLE_ADDR_TYPE_RANDOM:
|
||||
address_type = "RANDOM";
|
||||
break;
|
||||
case BLE_ADDR_TYPE_RPA_PUBLIC:
|
||||
address_type = "RPA_PUBLIC";
|
||||
break;
|
||||
case BLE_ADDR_TYPE_RPA_RANDOM:
|
||||
address_type = "RPA_RANDOM";
|
||||
break;
|
||||
default:
|
||||
address_type = "UNKNOWN";
|
||||
break;
|
||||
}
|
||||
char addr_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
ESP_LOGVV(TAG, " Address: %s (%s)", this->address_str_to(addr_buf), address_type);
|
||||
ESP_LOGVV(TAG, " RSSI: %d", this->rssi_);
|
||||
ESP_LOGVV(TAG, " Name: '%s'", this->name_.c_str());
|
||||
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:");
|
||||
ESP_LOGVV(TAG, " UUID: %s", ibeacon.value().get_uuid().to_str(uuid_buf));
|
||||
ESP_LOGVV(TAG, " Major: %u", ibeacon.value().get_major());
|
||||
ESP_LOGVV(TAG, " Minor: %u", ibeacon.value().get_minor());
|
||||
ESP_LOGVV(TAG, " TXPower: %d", ibeacon.value().get_signal_power());
|
||||
} else {
|
||||
ESP_LOGVV(TAG, " Manufacturer ID: %s, data: %s", mfg_data.uuid.to_str(uuid_buf),
|
||||
format_hex_pretty_to(hex_buf, mfg_data.data.data(), mfg_data.data.size()));
|
||||
}
|
||||
}
|
||||
for (auto &svc_data : this->service_datas_) {
|
||||
ESP_LOGVV(TAG, " Service data:");
|
||||
ESP_LOGVV(TAG, " UUID: %s", svc_data.uuid.to_str(uuid_buf));
|
||||
ESP_LOGVV(TAG, " Data: %s", format_hex_pretty_to(hex_buf, svc_data.data.data(), svc_data.data.size()));
|
||||
}
|
||||
ESP_LOGVV(TAG, " Adv data: %s", format_hex_pretty_to(hex_buf, data, data_len));
|
||||
#endif // ESPHOME_LOG_HAS_VERY_VERBOSE
|
||||
}
|
||||
|
||||
std::string ESPBTDevice::address_str() const {
|
||||
char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
|
||||
return std::string(this->address_str_to(buf));
|
||||
}
|
||||
|
||||
const char *ESPBTDevice::address_str_to(char *buf) const {
|
||||
// address_ is stored in printable (MSB-first) order.
|
||||
format_mac_addr_upper(this->address_, buf);
|
||||
return buf;
|
||||
}
|
||||
|
||||
uint64_t ESPBTDevice::address_uint64() const {
|
||||
// address_ is MSB-first; byte 0 of the result is the LSB (esp32 semantics).
|
||||
uint64_t addr = 0;
|
||||
for (int i = 0; i < 6; i++)
|
||||
addr |= static_cast<uint64_t>(this->address_[i]) << ((5 - i) * 8);
|
||||
return addr;
|
||||
}
|
||||
|
||||
bool ESPBTDevice::resolve_irk(const uint8_t *irk) const {
|
||||
#ifdef USE_BLE_DEVICE_IRK
|
||||
// Bluetooth Core 5.x "ah" function: hash = e(IRK, padding | prand)[low 24 bits].
|
||||
// The resolvable private address is prand (top 3 bytes) | hash (bottom 3 bytes).
|
||||
// Uses the portable software AES-128 shared with the CCM decryptor, so IRK
|
||||
// matching behaves identically on every platform (volume is one block per
|
||||
// advertisement from a matching RPA device — software AES is not a cost).
|
||||
uint8_t ecb_plaintext[16] = {0};
|
||||
uint8_t ecb_ciphertext[16];
|
||||
const uint64_t addr64 = this->address_uint64();
|
||||
ecb_plaintext[13] = (addr64 >> 40) & 0xff;
|
||||
ecb_plaintext[14] = (addr64 >> 32) & 0xff;
|
||||
ecb_plaintext[15] = (addr64 >> 24) & 0xff;
|
||||
aes128_encrypt_block(irk, ecb_plaintext, ecb_ciphertext);
|
||||
return ecb_ciphertext[15] == (addr64 & 0xff) && ecb_ciphertext[14] == ((addr64 >> 8) & 0xff) &&
|
||||
ecb_ciphertext[13] == ((addr64 >> 16) & 0xff);
|
||||
#else
|
||||
// No sensor configured an irk: in this build; the AES core is compiled out.
|
||||
(void) irk;
|
||||
return false;
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPBTDevice::parse_adv_(const uint8_t *payload, uint16_t len) {
|
||||
// BLE AD structure TLV: [length][type][value...]
|
||||
// length includes the type byte.
|
||||
uint16_t offset = 0;
|
||||
while (offset < len) {
|
||||
uint8_t ad_len = payload[offset++];
|
||||
if (ad_len == 0)
|
||||
continue; // possible zero-padded advertisement data (esp32_ble_tracker skips these too)
|
||||
if (offset + ad_len > len)
|
||||
break;
|
||||
uint8_t ad_type = payload[offset];
|
||||
const uint8_t *ad_data = &payload[offset + 1];
|
||||
uint8_t ad_data_len = ad_len - 1;
|
||||
offset += ad_len;
|
||||
|
||||
switch (ad_type) {
|
||||
case 0x01: // Flags
|
||||
if (ad_data_len >= 1)
|
||||
this->ad_flag_ = ad_data[0];
|
||||
break;
|
||||
|
||||
case 0x08: // Shortened Local Name
|
||||
case 0x09: // Complete Local Name
|
||||
// Keep the longest name seen — a merged adv + scan-response frame may carry both the
|
||||
// shortened and the complete name, and the shortened form must never replace the
|
||||
// complete one (same rule as esp32_ble_tracker's parse_adv_).
|
||||
if (ad_data_len > this->name_.length())
|
||||
this->name_.assign(reinterpret_cast<const char *>(ad_data), ad_data_len);
|
||||
break;
|
||||
|
||||
case 0x0A: // TX Power Level
|
||||
if (ad_data_len >= 1)
|
||||
this->tx_powers_.push_back(static_cast<int8_t>(ad_data[0]));
|
||||
break;
|
||||
|
||||
case 0x19: // Appearance
|
||||
if (ad_data_len >= 2)
|
||||
this->appearance_ = static_cast<uint16_t>(ad_data[0]) | (static_cast<uint16_t>(ad_data[1]) << 8);
|
||||
break;
|
||||
|
||||
case 0x02: // Incomplete List of 16-bit Service UUIDs
|
||||
case 0x03: // Complete List of 16-bit Service UUIDs
|
||||
for (uint8_t i = 0; (i + 1) < ad_data_len; i += 2) {
|
||||
uint16_t uuid = (static_cast<uint16_t>(ad_data[i + 1]) << 8) | ad_data[i];
|
||||
this->service_uuids_.push_back(ESPBTUUID::from_uint16(uuid));
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x04: // Incomplete List of 32-bit Service UUIDs
|
||||
case 0x05: // Complete List of 32-bit Service UUIDs
|
||||
for (uint8_t i = 0; (i + 3) < ad_data_len; i += 4) {
|
||||
uint32_t uuid = (static_cast<uint32_t>(ad_data[i + 3]) << 24) |
|
||||
(static_cast<uint32_t>(ad_data[i + 2]) << 16) | (static_cast<uint32_t>(ad_data[i + 1]) << 8) |
|
||||
ad_data[i];
|
||||
this->service_uuids_.push_back(ESPBTUUID::from_uint32(uuid));
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x06: // Incomplete List of 128-bit Service UUIDs
|
||||
case 0x07: // Complete List of 128-bit Service UUIDs
|
||||
for (uint8_t i = 0; (i + 15) < ad_data_len; i += 16)
|
||||
this->service_uuids_.push_back(ESPBTUUID::from_raw(&ad_data[i]));
|
||||
break;
|
||||
|
||||
case 0xFF: // Manufacturer Specific Data
|
||||
if (ad_data_len >= 2) {
|
||||
uint16_t company_id = (static_cast<uint16_t>(ad_data[1]) << 8) | ad_data[0];
|
||||
ServiceData sd;
|
||||
sd.uuid = ESPBTUUID::from_uint16(company_id);
|
||||
sd.data.assign(ad_data + 2, ad_data + ad_data_len);
|
||||
this->manufacturer_datas_.push_back(std::move(sd));
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x16: // Service Data — 16-bit UUID
|
||||
if (ad_data_len >= 2) {
|
||||
uint16_t uuid = (static_cast<uint16_t>(ad_data[1]) << 8) | ad_data[0];
|
||||
ServiceData sd;
|
||||
sd.uuid = ESPBTUUID::from_uint16(uuid);
|
||||
sd.data.assign(ad_data + 2, ad_data + ad_data_len);
|
||||
this->service_datas_.push_back(std::move(sd));
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x20: // Service Data — 32-bit UUID
|
||||
if (ad_data_len >= 4) {
|
||||
uint32_t uuid = (static_cast<uint32_t>(ad_data[3]) << 24) | (static_cast<uint32_t>(ad_data[2]) << 16) |
|
||||
(static_cast<uint32_t>(ad_data[1]) << 8) | ad_data[0];
|
||||
ServiceData sd;
|
||||
sd.uuid = ESPBTUUID::from_uint32(uuid);
|
||||
sd.data.assign(ad_data + 4, ad_data + ad_data_len);
|
||||
this->service_datas_.push_back(std::move(sd));
|
||||
}
|
||||
break;
|
||||
|
||||
case 0x21: // Service Data — 128-bit UUID
|
||||
if (ad_data_len >= 16) {
|
||||
ServiceData sd;
|
||||
sd.uuid = ESPBTUUID::from_raw(ad_data);
|
||||
sd.data.assign(ad_data + 16, ad_data + ad_data_len);
|
||||
this->service_datas_.push_back(std::move(sd));
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -0,0 +1,239 @@
|
||||
// ble_device.h
|
||||
//
|
||||
// Platform-neutral BLE advertisement types — the generic base every BLE consumer
|
||||
// (sensor components, bluetooth_proxy, automation triggers) builds against:
|
||||
// ESPBTUUID / ServiceData / ESPBLEiBeacon / ESPBTDevice / ESPBTDeviceListener
|
||||
//
|
||||
// These types are owned here on EVERY platform, with no chip-SDK types in their
|
||||
// public surface. Platform trackers produce them:
|
||||
// - esp32_ble_tracker adapts ESP-IDF scan results into ESPBTDevice and
|
||||
// re-exports these names (esp32 only) for backward compatibility;
|
||||
// - the LibreTiny trackers (bk72xx / ln882h) feed from_scan_result() directly.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <initializer_list>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#if defined(__cpp_lib_span)
|
||||
#include <span>
|
||||
#endif
|
||||
|
||||
#ifdef USE_ESP32
|
||||
// Historical esp32_ble API surface (below, under the same define) uses the
|
||||
// ESP-IDF UUID/address/scan-result types directly; never referenced off-esp32.
|
||||
#include "esphome/components/esp32_ble/ble_scan_result.h"
|
||||
#include <esp_bt_defs.h>
|
||||
#endif
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
using adv_data_t = std::vector<uint8_t>;
|
||||
|
||||
// Bluetooth Core address types (spec values; matches ESP-IDF's esp_ble_addr_type_t).
|
||||
static constexpr uint8_t BLE_ADDR_TYPE_PUBLIC = 0;
|
||||
static constexpr uint8_t BLE_ADDR_TYPE_RANDOM = 1;
|
||||
static constexpr uint8_t BLE_ADDR_TYPE_RPA_PUBLIC = 2;
|
||||
static constexpr uint8_t BLE_ADDR_TYPE_RPA_RANDOM = 3;
|
||||
|
||||
/// Buffer size for UUID string: "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX\0"
|
||||
static constexpr size_t UUID_STR_LEN = 37;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBTUUID — 16/32/128-bit Bluetooth UUID value type.
|
||||
// API-compatible with the historical esp32_ble::ESPBTUUID; the esp_bt_uuid_t
|
||||
// conversions live in esp32_ble (esp32-only adapters), not here.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ESPBTUUID {
|
||||
public:
|
||||
ESPBTUUID() = default;
|
||||
|
||||
static ESPBTUUID from_uint16(uint16_t uuid);
|
||||
static ESPBTUUID from_uint32(uint32_t uuid);
|
||||
/// Construct from raw 16-byte little-endian UUID.
|
||||
static ESPBTUUID from_raw(const uint8_t *data);
|
||||
/// Construct from raw 16-byte big-endian UUID (reversed on store).
|
||||
static ESPBTUUID from_raw_reversed(const uint8_t *data);
|
||||
/// Parse from text: 4 hex chars (16-bit), 8 hex chars (32-bit), 16 raw bytes,
|
||||
/// or the 36-char dashed UUID form. Same semantics as esp32_ble historically.
|
||||
static ESPBTUUID from_raw(const char *data, size_t length);
|
||||
static ESPBTUUID from_raw(const char *data) { return from_raw(data, strlen(data)); }
|
||||
static ESPBTUUID from_raw(const std::string &data) { return from_raw(data.c_str(), data.length()); }
|
||||
static ESPBTUUID from_raw(std::initializer_list<uint8_t> data) {
|
||||
return from_raw(reinterpret_cast<const char *>(data.begin()), data.size());
|
||||
}
|
||||
|
||||
#ifdef USE_ESP32
|
||||
/// Source compatibility with the historical esp32_ble API (esp32 builds only).
|
||||
static ESPBTUUID from_uuid(esp_bt_uuid_t uuid);
|
||||
esp_bt_uuid_t get_uuid() const;
|
||||
#endif
|
||||
|
||||
/// Expand to the 128-bit Bluetooth Base UUID form.
|
||||
ESPBTUUID as_128bit() const;
|
||||
|
||||
/// True if the UUID value contains the adjacent byte pair (data1, data2).
|
||||
bool contains(uint8_t data1, uint8_t data2) const;
|
||||
|
||||
bool operator==(const ESPBTUUID &other) const;
|
||||
bool operator!=(const ESPBTUUID &other) const { return !(*this == other); }
|
||||
|
||||
/// Write "0xABCD" / "0xABCDEF01" / the dashed 128-bit form into buf
|
||||
/// (>= UUID_STR_LEN bytes) and return buf.
|
||||
const char *to_str(char *buf) const;
|
||||
#if defined(__cpp_lib_span)
|
||||
const char *to_str(std::span<char, UUID_STR_LEN> output) const { return this->to_str(output.data()); }
|
||||
#endif
|
||||
enum class Type : uint8_t { UUID16, UUID32, UUID128 };
|
||||
Type type() const { return this->type_; }
|
||||
uint16_t uuid16() const { return this->uuid_.uuid16; }
|
||||
uint32_t uuid32() const { return this->uuid_.uuid32; }
|
||||
const uint8_t *uuid128() const { return this->uuid_.uuid128; }
|
||||
|
||||
protected:
|
||||
// Expand to the 128-bit Bluetooth Base UUID byte form (out is 16 bytes, little-endian).
|
||||
void to_128bit_(uint8_t out[16]) const;
|
||||
|
||||
Type type_{Type::UUID16};
|
||||
union {
|
||||
uint16_t uuid16;
|
||||
uint32_t uuid32;
|
||||
uint8_t uuid128[16];
|
||||
} uuid_{};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServiceData — UUID-tagged advertisement payload (0x16 / 0xFF AD types)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct ServiceData {
|
||||
ESPBTUUID uuid;
|
||||
adv_data_t data;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBLEiBeacon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ESPBLEiBeacon {
|
||||
public:
|
||||
ESPBLEiBeacon() { memset(&this->beacon_data_, 0, sizeof(this->beacon_data_)); }
|
||||
explicit ESPBLEiBeacon(const uint8_t *data);
|
||||
static optional<ESPBLEiBeacon> from_manufacturer_data(const ServiceData &data);
|
||||
|
||||
uint16_t get_major() const { return byteswap(this->beacon_data_.major); }
|
||||
uint16_t get_minor() const { return byteswap(this->beacon_data_.minor); }
|
||||
int8_t get_signal_power() const { return this->beacon_data_.signal_power; }
|
||||
ESPBTUUID get_uuid() const { return ESPBTUUID::from_raw_reversed(this->beacon_data_.proximity_uuid); }
|
||||
|
||||
protected:
|
||||
struct PACKED BeaconData {
|
||||
uint8_t sub_type;
|
||||
uint8_t length;
|
||||
uint8_t proximity_uuid[16];
|
||||
uint16_t major;
|
||||
uint16_t minor;
|
||||
int8_t signal_power;
|
||||
} beacon_data_;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ESPBTDevice — parsed BLE advertisement
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ESPBTDevice {
|
||||
public:
|
||||
/// Populate from a raw scan result delivered by a BLE tracker backend.
|
||||
/// mac is least-significant octet first (BLE controller convention).
|
||||
void from_scan_result(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len);
|
||||
|
||||
// Alias the core constant so the two cannot drift apart.
|
||||
static constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = esphome::MAC_ADDRESS_PRETTY_BUFFER_SIZE;
|
||||
|
||||
/// Return MAC as "XX:XX:XX:XX:XX:XX" string.
|
||||
std::string address_str() const;
|
||||
/// Buffer overload: writes "XX:XX:XX:XX:XX:XX\0" into buf (>= 18 bytes), returns buf.
|
||||
const char *address_str_to(char *buf) const;
|
||||
#if defined(__cpp_lib_span)
|
||||
const char *address_str_to(std::span<char, MAC_ADDRESS_PRETTY_BUFFER_SIZE> buf) const {
|
||||
return this->address_str_to(buf.data());
|
||||
}
|
||||
#endif
|
||||
/// Return MAC as packed uint64 (byte 0 in LSB — matches esp32's address_uint64).
|
||||
uint64_t address_uint64() const;
|
||||
/// Raw MAC bytes in printable (MSB-first) order — matches the historical
|
||||
/// esp32 layout (ESP-IDF bda order).
|
||||
const uint8_t *address() const { return address_; }
|
||||
#ifdef USE_ESP32
|
||||
// Historical esp32 signature: consumers assign the result to esp_ble_addr_type_t.
|
||||
esp_ble_addr_type_t get_address_type() const { return static_cast<esp_ble_addr_type_t>(this->address_type_); }
|
||||
/// Historical esp32 ingest (esp32 builds only): parse an ESP-IDF scan result.
|
||||
void parse_scan_rst(const esp32_ble::BLEScanResult &scan_result);
|
||||
// Exposed through a function for use in lambdas
|
||||
const esp32_ble::BLEScanResult &get_scan_result() const { return *scan_result_; }
|
||||
#else
|
||||
uint8_t get_address_type() const { return this->address_type_; }
|
||||
#endif
|
||||
|
||||
int get_rssi() const { return rssi_; }
|
||||
const std::string &get_name() const { return name_; }
|
||||
|
||||
const std::vector<ESPBTUUID> &get_service_uuids() const { return service_uuids_; }
|
||||
const std::vector<ServiceData> &get_manufacturer_datas() const { return manufacturer_datas_; }
|
||||
const std::vector<ServiceData> &get_service_datas() const { return service_datas_; }
|
||||
const std::vector<int8_t> &get_tx_powers() const { return tx_powers_; }
|
||||
const optional<uint16_t> &get_appearance() const { return appearance_; }
|
||||
const optional<uint8_t> &get_ad_flag() const { return ad_flag_; }
|
||||
|
||||
/// Resolve a Resolvable Private Address against a 16-byte IRK (Bluetooth "ah"
|
||||
/// function, AES-128). Uses the portable software AES shared with the CCM
|
||||
/// decryptor; compiled only when a sensor configures irk: (request_irk_support).
|
||||
bool resolve_irk(const uint8_t *irk) const;
|
||||
|
||||
optional<ESPBLEiBeacon> get_ibeacon() const {
|
||||
for (const auto &it : this->manufacturer_datas_) {
|
||||
auto res = ESPBLEiBeacon::from_manufacturer_data(it);
|
||||
if (res.has_value())
|
||||
return res;
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
protected:
|
||||
void parse_adv_(const uint8_t *payload, uint16_t len);
|
||||
|
||||
uint8_t address_[6]{0};
|
||||
uint8_t address_type_{0};
|
||||
int rssi_{0};
|
||||
std::string name_{};
|
||||
std::vector<ESPBTUUID> service_uuids_{};
|
||||
std::vector<ServiceData> manufacturer_datas_{};
|
||||
std::vector<ServiceData> service_datas_{};
|
||||
#ifdef USE_ESP32
|
||||
const esp32_ble::BLEScanResult *scan_result_{nullptr};
|
||||
#endif
|
||||
std::vector<int8_t> tx_powers_{};
|
||||
optional<uint16_t> appearance_{};
|
||||
optional<uint8_t> ad_flag_{};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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
|
||||
@@ -0,0 +1,63 @@
|
||||
// ble_hub.h
|
||||
//
|
||||
// BLEHub — the platform-neutral BLE tracker contract.
|
||||
//
|
||||
// Every BLE tracker component (esp32_ble_tracker, bk72xx_ble_tracker,
|
||||
// ln882h_ble_tracker, future chips) implements this interface; every BLE
|
||||
// consumer (sensor components, bluetooth_proxy) binds to it — in YAML via
|
||||
// `cv.use_id(BLEHub)`, which resolves whichever tracker the config declares.
|
||||
// Adding a new BLE chip therefore requires only a new tracker component that
|
||||
// implements BLEHub: no consumer, registry, or base changes.
|
||||
//
|
||||
// Chip differences are expressed as data (HubCapabilities), never as
|
||||
// platform conditionals in consumers.
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "ble_device.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
|
||||
namespace esphome::ble_device_base {
|
||||
|
||||
/// Callback for raw advertisements (the bluetooth_proxy path).
|
||||
/// mac[] is least-significant octet first (BLE controller convention);
|
||||
/// the hub delivers on the ESPHome main loop.
|
||||
using RawAdvertisementCallback =
|
||||
std::function<void(const uint8_t *mac, int rssi, uint8_t addr_type, const uint8_t *data, uint16_t data_len)>;
|
||||
|
||||
/// What a tracker's controller/SDK can do — consumers branch on data, not #ifdefs.
|
||||
struct HubCapabilities {
|
||||
/// Controller can send scan requests (active scanning).
|
||||
bool active_scan;
|
||||
/// Controller (or tracker) delivers advertisement + scan response as one merged
|
||||
/// frame. When false, consumers relying on scan-response fields (e.g. names)
|
||||
/// may only see them where the receiver merges per address (Home Assistant does).
|
||||
bool merges_scan_response;
|
||||
/// GATT client connections are available (today: esp32 only, but a chip SDK
|
||||
/// gaining GATT support only has to flip this bit).
|
||||
bool gatt;
|
||||
};
|
||||
|
||||
class BLEHub {
|
||||
public:
|
||||
virtual ~BLEHub() = default;
|
||||
|
||||
/// Register a parsed-advertisement consumer (BLE sensors, automation triggers).
|
||||
virtual void register_listener(ESPBTDeviceListener *listener) = 0;
|
||||
|
||||
/// Wire the raw-advertisement stream (bluetooth_proxy). One consumer at a time.
|
||||
virtual void set_raw_advertisement_callback(RawAdvertisementCallback cb) = 0;
|
||||
|
||||
virtual HubCapabilities get_capabilities() const = 0;
|
||||
|
||||
/// Adapter MAC in printable (MSB-first) order, out[0] = MSB.
|
||||
virtual void get_adapter_mac(uint8_t out[6]) = 0;
|
||||
|
||||
virtual bool scan_running() = 0;
|
||||
/// True when the current/configured scan mode is active (scan requests sent).
|
||||
virtual bool scan_active() = 0;
|
||||
};
|
||||
|
||||
} // namespace esphome::ble_device_base
|
||||
@@ -245,7 +245,9 @@ void BluetoothConnection::send_service_for_discovery_() {
|
||||
service_resp.characteristics.init(total_char_count);
|
||||
uint16_t char_offset = 0;
|
||||
esp_gattc_char_elem_t char_result;
|
||||
while (true) { // characteristics
|
||||
// Bound by total_char_count: the vector is sized for it, and a malicious peripheral
|
||||
// can make enumeration return more entries than the count query reported
|
||||
while (char_offset < total_char_count) { // characteristics
|
||||
uint16_t char_count = 1;
|
||||
esp_gatt_status_t char_status =
|
||||
esp_ble_gattc_get_all_char(this->gattc_if_, this->conn_id_, service_result.start_handle,
|
||||
@@ -287,7 +289,7 @@ void BluetoothConnection::send_service_for_discovery_() {
|
||||
characteristic_resp.descriptors.init(total_desc_count);
|
||||
uint16_t desc_offset = 0;
|
||||
esp_gattc_descr_elem_t desc_result;
|
||||
while (true) { // descriptors
|
||||
while (desc_offset < total_desc_count) { // descriptors
|
||||
uint16_t desc_count = 1;
|
||||
esp_gatt_status_t desc_status = esp_ble_gattc_get_all_descr(
|
||||
this->gattc_if_, this->conn_id_, char_result.char_handle, &desc_result, &desc_count, desc_offset);
|
||||
|
||||
@@ -379,9 +379,17 @@ void BluetoothProxy::bluetooth_set_connection_params(const api::BluetoothSetConn
|
||||
}
|
||||
|
||||
void BluetoothProxy::subscribe_api_connection(api::APIConnection *api_connection, uint32_t flags) {
|
||||
if (this->api_connection_ != nullptr) {
|
||||
ESP_LOGE(TAG, "Only one API subscription is allowed at a time");
|
||||
return;
|
||||
if (this->api_connection_ != nullptr && this->api_connection_ != api_connection) {
|
||||
// A previous subscriber still holds the slot. This is almost always a stale
|
||||
// connection from a client that dropped without a clean disconnect and has
|
||||
// not yet hit the keepalive timeout; rejecting the new subscriber would
|
||||
// silently starve it of advertisements until it reconnects, so the newest
|
||||
// subscriber wins instead.
|
||||
char old_peername[socket::SOCKADDR_STR_LEN];
|
||||
char new_peername[socket::SOCKADDR_STR_LEN];
|
||||
ESP_LOGW(TAG, "Subscription from %s (%s) replaces %s (%s)", api_connection->get_name(),
|
||||
api_connection->get_peername_to(new_peername), this->api_connection_->get_name(),
|
||||
this->api_connection_->get_peername_to(old_peername));
|
||||
}
|
||||
this->api_connection_ = api_connection;
|
||||
this->parent_->recalculate_advertisement_parser_types();
|
||||
|
||||
@@ -50,7 +50,9 @@ _BUTTON_SCHEMA = (
|
||||
.extend(
|
||||
{
|
||||
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(mqtt.MQTTButtonComponent),
|
||||
cv.Optional(CONF_DEVICE_CLASS): validate_device_class,
|
||||
cv.Optional(
|
||||
CONF_DEVICE_CLASS, visibility=cv.Visibility.ADVANCED
|
||||
): validate_device_class,
|
||||
cv.Optional(CONF_ON_PRESS): automation.validate_automation({}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from esphome.const import (
|
||||
PLATFORM_ESP32,
|
||||
PLATFORM_ESP8266,
|
||||
PLATFORM_LN882X,
|
||||
PLATFORM_RP2040,
|
||||
PLATFORM_RP2,
|
||||
PLATFORM_RTL87XX,
|
||||
PlatformFramework,
|
||||
)
|
||||
@@ -54,7 +54,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
PLATFORM_ESP8266,
|
||||
PLATFORM_BK72XX,
|
||||
PLATFORM_LN882X,
|
||||
PLATFORM_RP2040,
|
||||
PLATFORM_RP2,
|
||||
PLATFORM_RTL87XX,
|
||||
]
|
||||
),
|
||||
@@ -105,7 +105,7 @@ async def to_code(config):
|
||||
if config[CONF_COMPRESSION] == "gzip":
|
||||
cg.add_define("USE_CAPTIVE_PORTAL_GZIP")
|
||||
|
||||
if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2040):
|
||||
if CORE.using_arduino and (CORE.is_esp8266 or CORE.is_libretiny or CORE.is_rp2):
|
||||
cg.add_library("DNSServer", None)
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ MULTI_CONF = True
|
||||
|
||||
ns = cg.esphome_ns.namespace("cc1101")
|
||||
CC1101Component = ns.class_("CC1101Component", cg.Component, spi.SPIDevice)
|
||||
CC1101Listener = ns.class_("CC1101Listener")
|
||||
|
||||
# Config keys
|
||||
CONF_RX_ATTENUATION = "rx_attenuation"
|
||||
@@ -48,6 +49,15 @@ CONF_FILTER_LENGTH_FSK_MSK = "filter_length_fsk_msk"
|
||||
CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook"
|
||||
CONF_FREEZE = "freeze"
|
||||
CONF_HYST_LEVEL = "hyst_level"
|
||||
CONF_FOC_BS_CS_GATE = "foc_bs_cs_gate"
|
||||
CONF_FOC_LIMIT = "foc_limit"
|
||||
CONF_FOC_PRE_K = "foc_pre_k"
|
||||
CONF_FOC_POST_K = "foc_post_k"
|
||||
CONF_BS_LIMIT = "bs_limit"
|
||||
CONF_BS_PRE_KI = "bs_pre_ki"
|
||||
CONF_BS_PRE_KP = "bs_pre_kp"
|
||||
CONF_BS_POST_KI = "bs_post_ki"
|
||||
CONF_BS_POST_KP = "bs_post_kp"
|
||||
|
||||
# Packet mode config keys
|
||||
CONF_PACKET_MODE = "packet_mode"
|
||||
@@ -161,6 +171,64 @@ HYST_LEVEL = {
|
||||
"High": HystLevel.HYST_LEVEL_HIGH,
|
||||
}
|
||||
|
||||
FocLimit = ns.enum("FocLimit", True)
|
||||
FOC_LIMIT = {
|
||||
"Disabled": FocLimit.FOC_LIMIT_DISABLED,
|
||||
"BW/8": FocLimit.FOC_LIMIT_BW_8,
|
||||
"BW/4": FocLimit.FOC_LIMIT_BW_4,
|
||||
"BW/2": FocLimit.FOC_LIMIT_BW_2,
|
||||
}
|
||||
|
||||
FocPreK = ns.enum("FocPreK", True)
|
||||
FOC_PRE_K = {
|
||||
"K": FocPreK.FOC_PRE_K_K,
|
||||
"2K": FocPreK.FOC_PRE_K_2K,
|
||||
"3K": FocPreK.FOC_PRE_K_3K,
|
||||
"4K": FocPreK.FOC_PRE_K_4K,
|
||||
}
|
||||
|
||||
FocPostK = ns.enum("FocPostK", True)
|
||||
FOC_POST_K = {
|
||||
"Same": FocPostK.FOC_POST_K_SAME,
|
||||
"K/2": FocPostK.FOC_POST_K_K_2,
|
||||
}
|
||||
|
||||
BsLimit = ns.enum("BsLimit", True)
|
||||
BS_LIMIT = {
|
||||
"Disabled": BsLimit.BS_LIMIT_DISABLED,
|
||||
"3.125%": BsLimit.BS_LIMIT_3P125_PERCENT,
|
||||
"6.25%": BsLimit.BS_LIMIT_6P25_PERCENT,
|
||||
"12.5%": BsLimit.BS_LIMIT_12P5_PERCENT,
|
||||
}
|
||||
|
||||
BsPreKi = ns.enum("BsPreKi", True)
|
||||
BS_PRE_KI = {
|
||||
"KI": BsPreKi.BS_PRE_KI_KI,
|
||||
"2KI": BsPreKi.BS_PRE_KI_2KI,
|
||||
"3KI": BsPreKi.BS_PRE_KI_3KI,
|
||||
"4KI": BsPreKi.BS_PRE_KI_4KI,
|
||||
}
|
||||
|
||||
BsPreKp = ns.enum("BsPreKp", True)
|
||||
BS_PRE_KP = {
|
||||
"KP": BsPreKp.BS_PRE_KP_KP,
|
||||
"2KP": BsPreKp.BS_PRE_KP_2KP,
|
||||
"3KP": BsPreKp.BS_PRE_KP_3KP,
|
||||
"4KP": BsPreKp.BS_PRE_KP_4KP,
|
||||
}
|
||||
|
||||
BsPostKi = ns.enum("BsPostKi", True)
|
||||
BS_POST_KI = {
|
||||
"Same": BsPostKi.BS_POST_KI_SAME,
|
||||
"KI/2": BsPostKi.BS_POST_KI_KI_2,
|
||||
}
|
||||
|
||||
BsPostKp = ns.enum("BsPostKp", True)
|
||||
BS_POST_KP = {
|
||||
"Same": BsPostKp.BS_POST_KP_SAME,
|
||||
"KP": BsPostKp.BS_POST_KP_KP,
|
||||
}
|
||||
|
||||
# Optional settings to generate setter calls for
|
||||
CONFIG_MAP = {
|
||||
cv.Optional(CONF_OUTPUT_POWER, default=10): cv.float_range(min=-30.0, max=11.0),
|
||||
@@ -214,6 +282,15 @@ CONFIG_MAP = {
|
||||
cv.Optional(CONF_FREEZE): cv.enum(FREEZE, upper=False),
|
||||
cv.Optional(CONF_WAIT_TIME, default="32"): cv.enum(WAIT_TIME, upper=False),
|
||||
cv.Optional(CONF_HYST_LEVEL): cv.enum(HYST_LEVEL, upper=False),
|
||||
cv.Optional(CONF_FOC_BS_CS_GATE): cv.boolean,
|
||||
cv.Optional(CONF_FOC_LIMIT): cv.enum(FOC_LIMIT, upper=False),
|
||||
cv.Optional(CONF_FOC_PRE_K): cv.enum(FOC_PRE_K, upper=False),
|
||||
cv.Optional(CONF_FOC_POST_K): cv.enum(FOC_POST_K, upper=False),
|
||||
cv.Optional(CONF_BS_LIMIT): cv.enum(BS_LIMIT, upper=False),
|
||||
cv.Optional(CONF_BS_PRE_KI): cv.enum(BS_PRE_KI, upper=False),
|
||||
cv.Optional(CONF_BS_PRE_KP): cv.enum(BS_PRE_KP, upper=False),
|
||||
cv.Optional(CONF_BS_POST_KI): cv.enum(BS_POST_KI, upper=False),
|
||||
cv.Optional(CONF_BS_POST_KP): cv.enum(BS_POST_KP, upper=False),
|
||||
cv.Optional(CONF_PACKET_MODE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_PACKET_LENGTH): cv.uint8_t,
|
||||
cv.Optional(CONF_CRC_ENABLE, default=False): cv.boolean,
|
||||
|
||||
@@ -672,6 +672,69 @@ void CC1101Component::set_hyst_level(HystLevel value) {
|
||||
}
|
||||
}
|
||||
|
||||
void CC1101Component::set_foc_bs_cs_gate(bool value) {
|
||||
this->state_.FOC_BS_CS_GATE = value ? 1 : 0;
|
||||
if (this->initialized_) {
|
||||
this->write_(Register::FOCCFG);
|
||||
}
|
||||
}
|
||||
|
||||
void CC1101Component::set_foc_limit(FocLimit value) {
|
||||
this->state_.FOC_LIMIT = static_cast<uint8_t>(value);
|
||||
if (this->initialized_) {
|
||||
this->write_(Register::FOCCFG);
|
||||
}
|
||||
}
|
||||
|
||||
void CC1101Component::set_foc_pre_k(FocPreK value) {
|
||||
this->state_.FOC_PRE_K = static_cast<uint8_t>(value);
|
||||
if (this->initialized_) {
|
||||
this->write_(Register::FOCCFG);
|
||||
}
|
||||
}
|
||||
|
||||
void CC1101Component::set_foc_post_k(FocPostK value) {
|
||||
this->state_.FOC_POST_K = static_cast<uint8_t>(value);
|
||||
if (this->initialized_) {
|
||||
this->write_(Register::FOCCFG);
|
||||
}
|
||||
}
|
||||
|
||||
void CC1101Component::set_bs_limit(BsLimit value) {
|
||||
this->state_.BS_LIMIT = static_cast<uint8_t>(value);
|
||||
if (this->initialized_) {
|
||||
this->write_(Register::BSCFG);
|
||||
}
|
||||
}
|
||||
|
||||
void CC1101Component::set_bs_pre_ki(BsPreKi value) {
|
||||
this->state_.BS_PRE_KI = static_cast<uint8_t>(value);
|
||||
if (this->initialized_) {
|
||||
this->write_(Register::BSCFG);
|
||||
}
|
||||
}
|
||||
|
||||
void CC1101Component::set_bs_pre_kp(BsPreKp value) {
|
||||
this->state_.BS_PRE_KP = static_cast<uint8_t>(value);
|
||||
if (this->initialized_) {
|
||||
this->write_(Register::BSCFG);
|
||||
}
|
||||
}
|
||||
|
||||
void CC1101Component::set_bs_post_ki(BsPostKi value) {
|
||||
this->state_.BS_POST_KI = static_cast<uint8_t>(value);
|
||||
if (this->initialized_) {
|
||||
this->write_(Register::BSCFG);
|
||||
}
|
||||
}
|
||||
|
||||
void CC1101Component::set_bs_post_kp(BsPostKp value) {
|
||||
this->state_.BS_POST_KP = static_cast<uint8_t>(value);
|
||||
if (this->initialized_) {
|
||||
this->write_(Register::BSCFG);
|
||||
}
|
||||
}
|
||||
|
||||
void CC1101Component::set_packet_mode(bool value) {
|
||||
this->state_.PKT_FORMAT =
|
||||
static_cast<uint8_t>(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL);
|
||||
|
||||
@@ -71,6 +71,17 @@ class CC1101Component final : public Component,
|
||||
void set_wait_time(WaitTime value);
|
||||
void set_hyst_level(HystLevel value);
|
||||
|
||||
// Frequency offset compensation and bit synchronization settings
|
||||
void set_foc_bs_cs_gate(bool value);
|
||||
void set_foc_limit(FocLimit value);
|
||||
void set_foc_pre_k(FocPreK value);
|
||||
void set_foc_post_k(FocPostK value);
|
||||
void set_bs_limit(BsLimit value);
|
||||
void set_bs_pre_ki(BsPreKi value);
|
||||
void set_bs_pre_kp(BsPreKp value);
|
||||
void set_bs_post_ki(BsPostKi value);
|
||||
void set_bs_post_kp(BsPostKp value);
|
||||
|
||||
// Packet mode settings
|
||||
void set_packet_mode(bool value);
|
||||
void set_packet_length(uint8_t value);
|
||||
|
||||
@@ -231,6 +231,56 @@ enum class HystLevel : uint8_t {
|
||||
HYST_LEVEL_HIGH,
|
||||
};
|
||||
|
||||
enum class FocLimit : uint8_t {
|
||||
FOC_LIMIT_DISABLED,
|
||||
FOC_LIMIT_BW_8,
|
||||
FOC_LIMIT_BW_4,
|
||||
FOC_LIMIT_BW_2,
|
||||
};
|
||||
|
||||
enum class FocPreK : uint8_t {
|
||||
FOC_PRE_K_K,
|
||||
FOC_PRE_K_2K,
|
||||
FOC_PRE_K_3K,
|
||||
FOC_PRE_K_4K,
|
||||
};
|
||||
|
||||
enum class FocPostK : uint8_t {
|
||||
FOC_POST_K_SAME,
|
||||
FOC_POST_K_K_2,
|
||||
};
|
||||
|
||||
enum class BsLimit : uint8_t {
|
||||
BS_LIMIT_DISABLED,
|
||||
BS_LIMIT_3P125_PERCENT,
|
||||
BS_LIMIT_6P25_PERCENT,
|
||||
BS_LIMIT_12P5_PERCENT,
|
||||
};
|
||||
|
||||
enum class BsPreKi : uint8_t {
|
||||
BS_PRE_KI_KI,
|
||||
BS_PRE_KI_2KI,
|
||||
BS_PRE_KI_3KI,
|
||||
BS_PRE_KI_4KI,
|
||||
};
|
||||
|
||||
enum class BsPreKp : uint8_t {
|
||||
BS_PRE_KP_KP,
|
||||
BS_PRE_KP_2KP,
|
||||
BS_PRE_KP_3KP,
|
||||
BS_PRE_KP_4KP,
|
||||
};
|
||||
|
||||
enum class BsPostKi : uint8_t {
|
||||
BS_POST_KI_SAME,
|
||||
BS_POST_KI_KI_2,
|
||||
};
|
||||
|
||||
enum class BsPostKp : uint8_t {
|
||||
BS_POST_KP_SAME,
|
||||
BS_POST_KP_KP,
|
||||
};
|
||||
|
||||
enum class PacketFormat : uint8_t {
|
||||
PACKET_FORMAT_FIFO,
|
||||
PACKET_FORMAT_SYNC_SERIAL,
|
||||
|
||||
@@ -274,7 +274,7 @@ def climate_schema(
|
||||
|
||||
@setup_entity("climate")
|
||||
async def setup_climate_core_(var, config):
|
||||
visual = config[CONF_VISUAL]
|
||||
visual = config.get(CONF_VISUAL, {})
|
||||
if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None:
|
||||
cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES")
|
||||
cg.add(var.set_visual_min_temperature_override(min_temp))
|
||||
|
||||
@@ -205,6 +205,9 @@ class ClimateTraits {
|
||||
float get_visual_max_humidity() const { return this->visual_max_humidity_; }
|
||||
void set_visual_max_humidity(float visual_max_humidity) { this->visual_max_humidity_ = visual_max_humidity; }
|
||||
|
||||
TemperatureUnit get_temperature_unit() const { return this->temperature_unit_; }
|
||||
void set_temperature_unit(TemperatureUnit unit) { this->temperature_unit_ = unit; }
|
||||
|
||||
protected:
|
||||
void set_mode_support_(climate::ClimateMode mode, bool supported) {
|
||||
if (supported) {
|
||||
@@ -274,6 +277,7 @@ class ClimateTraits {
|
||||
climate::ClimateFanModeMask supported_fan_modes_;
|
||||
climate::ClimateSwingModeMask supported_swing_modes_;
|
||||
climate::ClimatePresetMask supported_presets_;
|
||||
TemperatureUnit temperature_unit_{TemperatureUnit::CELSIUS};
|
||||
|
||||
/** Custom mode storage - pointers to vectors owned by the Climate base class.
|
||||
*
|
||||
|
||||
@@ -16,6 +16,7 @@ CONF_COLOR_DEPTH = "color_depth"
|
||||
CONF_CRC_ENABLE = "crc_enable"
|
||||
CONF_DATA_BITS = "data_bits"
|
||||
CONF_DRAW_ROUNDING = "draw_rounding"
|
||||
CONF_ENABLE_OTA_DOWNGRADE_PROTECTION = "enable_ota_downgrade_protection"
|
||||
CONF_ENABLED = "enabled"
|
||||
CONF_GYROSCOPE_ODR = "gyroscope_odr"
|
||||
CONF_GYROSCOPE_RANGE = "gyroscope_range"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user