diff --git a/.clang-tidy b/.clang-tidy
index 5878028f4..6dab84fbd 100644
--- a/.clang-tidy
+++ b/.clang-tidy
@@ -5,24 +5,30 @@ Checks: >-
-altera-*,
-android-*,
-boost-*,
+ -bugprone-derived-method-shadowing-base-method,
-bugprone-easily-swappable-parameters,
-bugprone-implicit-widening-of-multiplication-result,
+ -bugprone-invalid-enum-default-initialization,
-bugprone-multi-level-implicit-pointer-conversion,
-bugprone-narrowing-conversions,
+ -bugprone-tagged-union-member-count,
-bugprone-signed-char-misuse,
-bugprone-switch-missing-default-case,
-cert-dcl50-cpp,
-cert-err33-c,
-cert-err58-cpp,
+ -cert-int09-c,
-cert-oop57-cpp,
-cert-str34-c,
-clang-analyzer-optin.core.EnumCastOutOfRange,
-clang-analyzer-optin.cplusplus.UninitializedObject,
-clang-analyzer-osx.*,
+ -clang-analyzer-security.ArrayBound,
-clang-diagnostic-delete-abstract-non-virtual-dtor,
-clang-diagnostic-delete-non-abstract-non-virtual-dtor,
-clang-diagnostic-deprecated-declarations,
-clang-diagnostic-ignored-optimization-argument,
+ -clang-diagnostic-missing-designated-field-initializers,
-clang-diagnostic-missing-field-initializers,
-clang-diagnostic-shadow-field,
-clang-diagnostic-unused-const-variable,
@@ -42,6 +48,7 @@ Checks: >-
-cppcoreguidelines-owning-memory,
-cppcoreguidelines-prefer-member-initializer,
-cppcoreguidelines-pro-bounds-array-to-pointer-decay,
+ -cppcoreguidelines-pro-bounds-avoid-unchecked-container-access,
-cppcoreguidelines-pro-bounds-constant-array-index,
-cppcoreguidelines-pro-bounds-pointer-arithmetic,
-cppcoreguidelines-pro-type-const-cast,
@@ -54,12 +61,13 @@ Checks: >-
-cppcoreguidelines-rvalue-reference-param-not-moved,
-cppcoreguidelines-special-member-functions,
-cppcoreguidelines-use-default-member-init,
+ -cppcoreguidelines-use-enum-class,
-cppcoreguidelines-virtual-class-destructor,
+ -fuchsia-default-arguments-calls,
+ -fuchsia-default-arguments-declarations,
-fuchsia-multiple-inheritance,
-fuchsia-overloaded-operator,
-fuchsia-statically-constructed-objects,
- -fuchsia-default-arguments-declarations,
- -fuchsia-default-arguments-calls,
-google-build-using-namespace,
-google-explicit-constructor,
-google-readability-braces-around-statements,
@@ -71,49 +79,63 @@ Checks: >-
-llvm-else-after-return,
-llvm-header-guard,
-llvm-include-order,
+ -llvm-prefer-static-over-anonymous-namespace,
-llvm-qualified-auto,
+ -llvm-use-ranges,
-llvmlibc-*,
-misc-const-correctness,
-misc-include-cleaner,
+ -misc-multiple-inheritance,
-misc-no-recursion,
-misc-non-private-member-variables-in-classes,
+ -misc-override-with-different-visibility,
-misc-unused-parameters,
-misc-use-anonymous-namespace,
+ -misc-use-internal-linkage,
-modernize-avoid-bind,
+ -modernize-avoid-variadic-functions,
-modernize-avoid-c-arrays,
- -modernize-concat-nested-namespaces,
+ -modernize-avoid-c-style-cast,
-modernize-macro-to-enum,
-modernize-return-braced-init-list,
-modernize-type-traits,
-modernize-use-auto,
-modernize-use-constraints,
-modernize-use-default-member-init,
+ -modernize-use-designated-initializers,
-modernize-use-equals-default,
+ -modernize-use-integer-sign-comparison,
-modernize-use-nodiscard,
-modernize-use-nullptr,
- -modernize-use-nodiscard,
- -modernize-use-nullptr,
+ -modernize-use-ranges,
-modernize-use-trailing-return-type,
-mpi-*,
-objc-*,
-performance-enum-size,
+ -portability-avoid-pragma-once,
+ -portability-template-virtual-member-function,
+ -readability-ambiguous-smartptr-reset-call,
-readability-avoid-nested-conditional-operator,
- -readability-container-contains,
-readability-container-data-pointer,
-readability-convert-member-functions-to-static,
-readability-else-after-return,
+ -readability-enum-initial-value,
-readability-function-cognitive-complexity,
-readability-implicit-bool-conversion,
-readability-isolate-declaration,
-readability-magic-numbers,
-readability-make-member-function-const,
+ -readability-math-missing-parentheses,
-readability-named-parameter,
-readability-redundant-casting,
-readability-redundant-inline-specifier,
-readability-redundant-member-init,
- -readability-redundant-string-init,
+ -readability-redundant-parentheses,
+ -readability-redundant-typename,
-readability-uppercase-literal-suffix,
-readability-use-anyofallof,
+ -readability-use-std-min-max,
+ -readability-use-concise-preprocessor-directives,
WarningsAsErrors: '*'
FormatStyle: google
CheckOptions:
diff --git a/.clang-tidy.hash b/.clang-tidy.hash
index 41e1b7bd2..52d75d160 100644
--- a/.clang-tidy.hash
+++ b/.clang-tidy.hash
@@ -1 +1 @@
-1b1ce6324c50c4595703c7df0a8a479b4fe84b71ff1a8793cce1a16f17a33324
+27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f
diff --git a/.claude/skills/pr-workflow/SKILL.md b/.claude/skills/pr-workflow/SKILL.md
index 4ec255180..2c529dcd0 100644
--- a/.claude/skills/pr-workflow/SKILL.md
+++ b/.claude/skills/pr-workflow/SKILL.md
@@ -29,7 +29,7 @@ Required fields:
- **What does this implement/fix?**: Brief description of changes
- **Types of changes**: Check ONE appropriate box (Bugfix, New feature, Breaking change, etc.)
- **Related issue**: Use `fixes ` syntax if applicable
-- **Pull request in esphome-docs**: Link if docs are needed
+- **Pull request in esphome.io**: Link if docs are needed
- **Test Environment**: Check platforms you tested on
- **Example config.yaml**: Include working example YAML
- **Checklist**: Verify code is tested and tests added
@@ -54,9 +54,9 @@ Required fields:
- fixes https://github.com/esphome/esphome/issues/XXX
-**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):**
+**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):**
-- esphome/esphome-docs#XXX
+- esphome/esphome.io#XXX
## Test Environment
@@ -83,7 +83,7 @@ component_name:
- [x] Tests have been added to verify that the new code works (under `tests/` folder).
If user exposed functionality or configuration variables are added/changed:
- - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs).
+ - [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io).
```
## 5. Push and Create PR
diff --git a/.dockerignore b/.dockerignore
index ccd466d8c..d6fb5e82a 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -115,4 +115,4 @@ examples/
Dockerfile
.git/
tests/
-.*
+.?*
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 19f52349a..3b39d519c 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -2,7 +2,7 @@
blank_issues_enabled: false
contact_links:
- name: Report an issue with the ESPHome documentation
- url: https://github.com/esphome/esphome-docs/issues/new/choose
+ url: https://github.com/esphome/esphome.io/issues/new/choose
about: Report an issue with the ESPHome documentation.
- name: Report an issue with the ESPHome web server
url: https://github.com/esphome/esphome-webserver/issues/new/choose
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 72013e411..08def8857 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -16,9 +16,9 @@
- fixes
-**Pull request in [esphome-docs](https://github.com/esphome/esphome-docs) with documentation (if applicable):**
+**Pull request in [esphome.io](https://github.com/esphome/esphome.io) with documentation (if applicable):**
-- esphome/esphome-docs#
+- esphome/esphome.io#
## Test Environment
@@ -43,4 +43,4 @@
- [ ] Tests have been added to verify that the new code works (under `tests/` folder).
If user exposed functionality or configuration variables are added/changed:
- - [ ] Documentation added/updated in [esphome-docs](https://github.com/esphome/esphome-docs).
+ - [ ] Documentation added/updated in [esphome.io](https://github.com/esphome/esphome.io).
diff --git a/.github/actions/build-image/action.yaml b/.github/actions/build-image/action.yaml
index 52d72544d..2081264b9 100644
--- a/.github/actions/build-image/action.yaml
+++ b/.github/actions/build-image/action.yaml
@@ -47,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -73,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml
index 21393f2ab..03b480386 100644
--- a/.github/actions/restore-python/action.yml
+++ b/.github/actions/restore-python/action.yml
@@ -27,6 +27,18 @@ runs:
path: venv
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }}
+ - name: Set up uv
+ # Only needed on cache miss to populate the venv. ``uv pip install``
+ # 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
shell: bash
@@ -34,8 +46,8 @@ runs:
python -m venv venv
source venv/bin/activate
python --version
- pip install -r requirements.txt -r requirements_test.txt
- pip install -e .
+ uv pip install -r requirements.txt -r requirements_test.txt
+ uv pip install -e .
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
shell: bash
@@ -43,5 +55,5 @@ runs:
python -m venv venv
source ./venv/Scripts/activate
python --version
- pip install -r requirements.txt -r requirements_test.txt
- pip install -e .
+ uv pip install -r requirements.txt -r requirements_test.txt
+ uv pip install -e .
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index a4b2fa310..be77ac83a 120000
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -1 +1 @@
-../.ai/instructions.md
\ No newline at end of file
+../AGENTS.md
\ No newline at end of file
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 528e69c47..e87939f82 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,6 +5,7 @@ updates:
directory: "/"
schedule:
interval: daily
+ open-pull-requests-limit: 10
ignore:
# Hypotehsis is only used for testing and is updated quite often
- dependency-name: hypothesis
diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js
index e02b450bf..2938fd923 100644
--- a/.github/scripts/auto-label-pr/constants.js
+++ b/.github/scripts/auto-label-pr/constants.js
@@ -35,6 +35,9 @@ module.exports = {
],
DOCS_PR_PATTERNS: [
+ /https:\/\/github\.com\/esphome\/esphome\.io\/pull\/\d+/,
+ /esphome\/esphome\.io#\d+/,
+ // Keep matching the old esphome-docs name during the transition period
/https:\/\/github\.com\/esphome\/esphome-docs\/pull\/\d+/,
/esphome\/esphome-docs#\d+/
]
diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js
index 25c0ba49a..81bb77843 100644
--- a/.github/scripts/auto-label-pr/detectors.js
+++ b/.github/scripts/auto-label-pr/detectors.js
@@ -1,4 +1,3 @@
-const fs = require('fs');
const { DOCS_PR_PATTERNS } = require('./constants');
const {
COMPONENT_REGEX,
@@ -9,6 +8,31 @@ const {
} = require('../detect-tags');
const { loadCodeowners, getEffectiveOwners } = require('../codeowners');
+// Top-level `CONFIG_SCHEMA = ...` (assignment) or `CONFIG_SCHEMA: ConfigType = ...` (annotation).
+// Ruff/Black enforce exactly one space around `=` and no space before `:`,
+// so we can match strictly: `CONFIG_SCHEMA ` or `CONFIG_SCHEMA:`.
+const CONFIG_SCHEMA_REGEX = /^CONFIG_SCHEMA[ :]/m;
+
+// Fetch a file's contents from the PR head SHA via the GitHub API.
+// The auto-label workflow runs on `pull_request_target`, which checks out the
+// base branch — files added by the PR don't exist in the workspace, so we have
+// to fetch them from the head SHA. Returns null if the file can't be fetched.
+async function fetchPrFileContent(github, context, path) {
+ try {
+ const { owner, repo } = context.repo;
+ const { data } = await github.rest.repos.getContent({
+ owner,
+ repo,
+ path,
+ ref: context.payload.pull_request.head.sha,
+ });
+ return Buffer.from(data.content, 'base64').toString('utf8');
+ } catch (error) {
+ console.log(`Failed to fetch ${path} from PR head:`, error.message);
+ return null;
+ }
+}
+
// Strategy: Merge branch detection
async function detectMergeBranch(context) {
const labels = new Set();
@@ -45,52 +69,72 @@ async function detectComponentPlatforms(changedFiles, apiData) {
}
// Strategy: New component detection
-async function detectNewComponents(prFiles) {
+async function detectNewComponents(github, context, prFiles) {
const labels = new Set();
+ let hasYamlLoadable = false;
const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename);
for (const file of addedFiles) {
const componentMatch = file.match(/^esphome\/components\/([^\/]+)\/__init__\.py$/);
- if (componentMatch) {
- try {
- const content = fs.readFileSync(file, 'utf8');
- if (content.includes('IS_TARGET_PLATFORM = True')) {
- labels.add('new-target-platform');
- }
- } catch (error) {
- console.log(`Failed to read content of ${file}:`, error.message);
- }
- labels.add('new-component');
+ if (!componentMatch) continue;
+
+ labels.add('new-component');
+ const content = await fetchPrFileContent(github, context, file);
+ if (content === null) {
+ // Safe default: assume YAML-loadable so needs-docs behaviour is unchanged on fetch failure
+ hasYamlLoadable = true;
+ continue;
+ }
+ if (content.includes('IS_TARGET_PLATFORM = True')) {
+ labels.add('new-target-platform');
+ }
+ if (CONFIG_SCHEMA_REGEX.test(content)) {
+ hasYamlLoadable = true;
}
}
- return labels;
+ return { labels, hasYamlLoadable };
}
// Strategy: New platform detection
-async function detectNewPlatforms(prFiles, apiData) {
+async function detectNewPlatforms(github, context, prFiles, apiData) {
const labels = new Set();
+ let hasYamlLoadable = false;
const addedFiles = prFiles.filter(file => file.status === 'added').map(file => file.filename);
- for (const file of addedFiles) {
- const platformFileMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/);
- if (platformFileMatch) {
- const [, component, platform] = platformFileMatch;
- if (apiData.platformComponents.includes(platform)) {
- labels.add('new-platform');
- }
- }
+ const platformPathPatterns = [
+ /^esphome\/components\/([^\/]+)\/([^\/]+)\.py$/,
+ /^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/,
+ ];
- const platformDirMatch = file.match(/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/);
- if (platformDirMatch) {
- const [, component, platform] = platformDirMatch;
- if (apiData.platformComponents.includes(platform)) {
- labels.add('new-platform');
+ const removedFiles = new Set(prFiles.filter(file => file.status === 'removed').map(file => file.filename));
+
+ for (const file of addedFiles) {
+ for (const re of platformPathPatterns) {
+ const match = file.match(re);
+ if (!match) continue;
+ const platform = match[2];
+ if (!apiData.platformComponents.includes(platform)) break;
+
+ // Skip if this is a restructure between flat and subdirectory forms (either direction):
+ // /.py <-> //__init__.py
+ const flatEquivalent = `esphome/components/${match[1]}/${platform}.py`;
+ const subdirEquivalent = `esphome/components/${match[1]}/${platform}/__init__.py`;
+ if (removedFiles.has(flatEquivalent) || removedFiles.has(subdirEquivalent)) break;
+
+ labels.add('new-platform');
+ const content = await fetchPrFileContent(github, context, file);
+ if (content === null) {
+ // Safe default: assume YAML-loadable so needs-docs behaviour is unchanged on fetch failure
+ hasYamlLoadable = true;
+ } else if (CONFIG_SCHEMA_REGEX.test(content)) {
+ hasYamlLoadable = true;
}
+ break;
}
}
- return labels;
+ return { labels, hasYamlLoadable };
}
// Strategy: Core files detection
@@ -300,7 +344,7 @@ function detectMaintainerAccess(context) {
}
// Strategy: Requirements detection
-async function detectRequirements(allLabels, prFiles, context) {
+async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable) {
const labels = new Set();
// Check for missing tests
@@ -308,8 +352,15 @@ async function detectRequirements(allLabels, prFiles, context) {
labels.add('needs-tests');
}
- // Check for missing docs
- if (allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) {
+ // Check for missing docs.
+ // `new-feature` (PR-body checkbox) always counts. `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 =
+ allLabels.has('new-feature') ||
+ ((allLabels.has('new-component') || allLabels.has('new-platform')) && hasYamlLoadable);
+
+ if (docsEligible) {
const prBody = context.payload.pull_request.body || '';
const hasDocsLink = DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody));
diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js
index 021e91a9e..9769cd806 100644
--- a/.github/scripts/auto-label-pr/index.js
+++ b/.github/scripts/auto-label-pr/index.js
@@ -106,8 +106,8 @@ module.exports = async ({ github, context }) => {
const [
branchLabels,
componentLabels,
- newComponentLabels,
- newPlatformLabels,
+ newComponentResult,
+ newPlatformResult,
coreLabels,
sizeLabels,
dashboardLabels,
@@ -120,8 +120,8 @@ module.exports = async ({ github, context }) => {
] = await Promise.all([
detectMergeBranch(context),
detectComponentPlatforms(changedFiles, apiData),
- detectNewComponents(prFiles),
- detectNewPlatforms(prFiles, apiData),
+ detectNewComponents(github, context, prFiles),
+ detectNewPlatforms(github, context, prFiles, apiData),
detectCoreChanges(changedFiles),
detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD),
detectDashboardChanges(changedFiles),
@@ -133,6 +133,13 @@ module.exports = async ({ github, context }) => {
detectMaintainerAccess(context)
]);
+ // Extract new-component / new-platform results
+ const newComponentLabels = newComponentResult.labels;
+ const newPlatformLabels = newPlatformResult.labels;
+ // Eligible for needs-docs only if any newly added component or platform file
+ // defines a top-level CONFIG_SCHEMA (i.e. is actually loadable from YAML).
+ const hasYamlLoadable = newComponentResult.hasYamlLoadable || newPlatformResult.hasYamlLoadable;
+
// Extract deprecated component info
const deprecatedLabels = deprecatedResult.labels;
const deprecatedInfo = deprecatedResult.deprecatedInfo;
@@ -154,7 +161,7 @@ module.exports = async ({ github, context }) => {
]);
// Detect requirements based on all other labels
- const requirementLabels = await detectRequirements(allLabels, prFiles, context);
+ const requirementLabels = await detectRequirements(allLabels, prFiles, context, hasYamlLoadable);
for (const label of requirementLabels) {
allLabels.add(label);
}
diff --git a/.github/scripts/auto-label-pr/package.json b/.github/scripts/auto-label-pr/package.json
new file mode 100644
index 000000000..401b376db
--- /dev/null
+++ b/.github/scripts/auto-label-pr/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "auto-label-pr",
+ "private": true,
+ "scripts": {
+ "test": "node --test tests/*.test.js"
+ }
+}
diff --git a/.github/scripts/auto-label-pr/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js
new file mode 100644
index 000000000..02d69ca95
--- /dev/null
+++ b/.github/scripts/auto-label-pr/tests/detectors.test.js
@@ -0,0 +1,147 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const { detectNewPlatforms, detectNewComponents } = require('../detectors');
+
+// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
+// to check for CONFIG_SCHEMA in newly added files.
+function makeGithub(content = '') {
+ return {
+ rest: {
+ repos: {
+ getContent: async () => ({
+ data: { content: Buffer.from(content).toString('base64') }
+ })
+ }
+ }
+ };
+}
+
+const CONTEXT = {
+ repo: { owner: 'esphome', repo: 'esphome' },
+ payload: { pull_request: { head: { sha: 'abc123' }, base: { ref: 'dev' } } }
+};
+
+const API_DATA = {
+ targetPlatforms: ['esp32', 'esp8266', 'rp2040'],
+ platformComponents: ['cover', 'sensor', 'binary_sensor', 'switch', 'light', 'fan', 'climate', 'valve']
+};
+
+const WITH_SCHEMA = 'CONFIG_SCHEMA = cv.Schema({})';
+const WITHOUT_SCHEMA = 'CODEOWNERS = ["@esphome/core"]';
+
+// ---------------------------------------------------------------------------
+// detectNewPlatforms
+// ---------------------------------------------------------------------------
+
+describe('detectNewPlatforms', () => {
+ describe('restructure detection (no false positives)', () => {
+ it('flat .py -> subdir __init__.py is not a new platform', async () => {
+ const prFiles = [
+ { filename: 'esphome/components/endstop/cover.py', status: 'removed' },
+ { filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' },
+ ];
+ const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
+ assert.equal(result.labels.size, 0);
+ assert.equal(result.hasYamlLoadable, false);
+ });
+
+ it('subdir __init__.py -> flat .py is not a new platform', async () => {
+ const prFiles = [
+ { filename: 'esphome/components/endstop/cover/__init__.py', status: 'removed' },
+ { filename: 'esphome/components/endstop/cover.py', status: 'added' },
+ ];
+ const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
+ assert.equal(result.labels.size, 0);
+ assert.equal(result.hasYamlLoadable, false);
+ });
+ });
+
+ describe('genuine new platforms', () => {
+ it('new subdir platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => {
+ const prFiles = [
+ { filename: 'esphome/components/my_sensor/cover/__init__.py', status: 'added' },
+ ];
+ const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
+ assert.ok(result.labels.has('new-platform'));
+ assert.equal(result.hasYamlLoadable, true);
+ });
+
+ it('new flat platform with CONFIG_SCHEMA sets new-platform and hasYamlLoadable', async () => {
+ const prFiles = [
+ { filename: 'esphome/components/my_sensor/cover.py', status: 'added' },
+ ];
+ const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, API_DATA);
+ assert.ok(result.labels.has('new-platform'));
+ assert.equal(result.hasYamlLoadable, true);
+ });
+
+ it('new platform without CONFIG_SCHEMA sets new-platform but not hasYamlLoadable', async () => {
+ const prFiles = [
+ { filename: 'esphome/components/my_sensor/cover.py', status: 'added' },
+ ];
+ const result = await detectNewPlatforms(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles, API_DATA);
+ assert.ok(result.labels.has('new-platform'));
+ assert.equal(result.hasYamlLoadable, false);
+ });
+
+ it('non-platform file addition produces no labels', async () => {
+ const prFiles = [
+ { filename: 'esphome/components/my_sensor/sensor.py', status: 'added' },
+ ];
+ // Override platformComponents so 'sensor' is not a recognized platform -> no label expected.
+ const nonPlatformApiData = { ...API_DATA, platformComponents: ['cover'] };
+ const result = await detectNewPlatforms(makeGithub(WITH_SCHEMA), CONTEXT, prFiles, nonPlatformApiData);
+ assert.equal(result.labels.size, 0);
+ assert.equal(result.hasYamlLoadable, false);
+ });
+ });
+});
+
+// ---------------------------------------------------------------------------
+// detectNewComponents
+// ---------------------------------------------------------------------------
+
+describe('detectNewComponents', () => {
+ it('new top-level __init__.py sets new-component', async () => {
+ const prFiles = [
+ { filename: 'esphome/components/actuator/__init__.py', status: 'added', },
+ ];
+ const result = await detectNewComponents(makeGithub(WITHOUT_SCHEMA), CONTEXT, prFiles);
+ assert.ok(result.labels.has('new-component'));
+ assert.equal(result.hasYamlLoadable, false);
+ });
+
+ it('new top-level __init__.py with CONFIG_SCHEMA sets hasYamlLoadable', async () => {
+ const prFiles = [
+ { filename: 'esphome/components/my_component/__init__.py', status: 'added' },
+ ];
+ const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles);
+ assert.ok(result.labels.has('new-component'));
+ assert.equal(result.hasYamlLoadable, true);
+ });
+
+ it('new top-level __init__.py with IS_TARGET_PLATFORM sets new-target-platform', async () => {
+ const prFiles = [
+ { filename: 'esphome/components/my_platform/__init__.py', status: 'added' },
+ ];
+ const result = await detectNewComponents(makeGithub('IS_TARGET_PLATFORM = True'), CONTEXT, prFiles);
+ assert.ok(result.labels.has('new-component'));
+ assert.ok(result.labels.has('new-target-platform'));
+ });
+
+ it('modified __init__.py does not set new-component', async () => {
+ const prFiles = [
+ { filename: 'esphome/components/existing/__init__.py', status: 'modified' },
+ ];
+ const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles);
+ assert.equal(result.labels.size, 0);
+ });
+
+ it('nested __init__.py does not set new-component', async () => {
+ const prFiles = [
+ { filename: 'esphome/components/endstop/cover/__init__.py', status: 'added' },
+ ];
+ const result = await detectNewComponents(makeGithub(WITH_SCHEMA), CONTEXT, prFiles);
+ assert.equal(result.labels.size, 0);
+ });
+});
diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml
index 0e5ceb934..6c80d36d2 100644
--- a/.github/workflows/auto-label-pr.yml
+++ b/.github/workflows/auto-label-pr.yml
@@ -6,9 +6,10 @@ on:
pull_request_target:
types: [labeled, opened, reopened, synchronize, edited]
+# All PR/label/review writes are performed with the App token minted below,
+# so the workflow's GITHUB_TOKEN only needs read access for checkout.
permissions:
- pull-requests: write
- contents: read
+ contents: read # actions/checkout reads the workflow source
env:
SMALL_PR_THRESHOLD: 30
@@ -27,10 +28,14 @@ jobs:
- name: Generate a token
id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
- app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
+ client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+ # Scope the minted App token to the minimum needed by auto-label-pr/*.js.
+ permission-contents: read # repos.getContent for CODEOWNERS and file lookups in detectors.js
+ permission-issues: write # listLabelsOnIssue, addLabels, removeLabel, list/createComment
+ permission-pull-requests: write # pulls.listFiles, list/create/update/dismissReview
- name: Auto Label PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml
index e5143911d..675bbe9d2 100644
--- a/.github/workflows/ci-api-proto.yml
+++ b/.github/workflows/ci-api-proto.yml
@@ -12,8 +12,8 @@ on:
- ".github/workflows/ci-api-proto.yml"
permissions:
- contents: read
- pull-requests: write
+ contents: read # actions/checkout for the PR head
+ pull-requests: write # pulls.createReview / listReviews / dismissReview when generated proto files are stale
jobs:
check:
@@ -26,6 +26,16 @@ jobs:
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
+ - 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Install apt dependencies
run: |
@@ -34,7 +44,7 @@ jobs:
sudo apt install -y protobuf-compiler
protoc --version
- name: Install python dependencies
- run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt
+ run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
- name: Generate files
run: script/api_protobuf/api_protobuf.py
- name: Check for changes
diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml
index 40cdff0cb..d9148fb06 100644
--- a/.github/workflows/ci-clang-tidy-hash.yml
+++ b/.github/workflows/ci-clang-tidy-hash.yml
@@ -12,8 +12,8 @@ on:
- ".github/workflows/ci-clang-tidy-hash.yml"
permissions:
- contents: read
- pull-requests: write
+ contents: read # actions/checkout for the PR head
+ pull-requests: write # pulls.createReview / listReviews / dismissReview when the clang-tidy hash is out of date
jobs:
verify-hash:
diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml
index 4009ac1e1..89fbec542 100644
--- a/.github/workflows/ci-docker.yml
+++ b/.github/workflows/ci-docker.yml
@@ -22,8 +22,7 @@ on:
- "script/platformio_install_deps.py"
permissions:
- contents: read
- packages: read
+ contents: read # actions/checkout only; the build does not push images
concurrency:
# yamllint disable-line rule:line-length
@@ -49,7 +48,7 @@ jobs:
with:
python-version: "3.11"
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Set TAG
run: |
diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml
new file mode 100644
index 000000000..6713fcc45
--- /dev/null
+++ b/.github/workflows/ci-github-scripts.yml
@@ -0,0 +1,27 @@
+name: CI - GitHub Scripts
+
+on:
+ push:
+ branches: [dev, beta, release]
+ paths:
+ - ".github/scripts/**"
+ - ".github/workflows/ci-github-scripts.yml"
+ pull_request:
+ paths:
+ - ".github/scripts/**"
+ - ".github/workflows/ci-github-scripts.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ test-auto-label-pr:
+ name: Test auto-label-pr scripts
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Run tests
+ working-directory: .github/scripts/auto-label-pr
+ run: npm test
diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml
index fbcf5ea58..025b96098 100644
--- a/.github/workflows/ci-memory-impact-comment.yml
+++ b/.github/workflows/ci-memory-impact-comment.yml
@@ -7,9 +7,9 @@ on:
types: [completed]
permissions:
- contents: read
- pull-requests: write
- actions: read
+ contents: read # actions/checkout of the base repo at the PR's target branch
+ pull-requests: write # gh api to look up the PR by head SHA and post/update the memory-impact comment
+ actions: read # gh run download for the memory-analysis artifacts produced by the CI workflow run
jobs:
memory-impact-comment:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 20c349ac0..63efff1b3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,18 +6,10 @@ on:
branches: [dev, beta, release]
pull_request:
- paths:
- - "**"
- - "!.github/workflows/*.yml"
- - "!.github/actions/build-image/*"
- - ".github/workflows/ci.yml"
- - "!.yamllint"
- - "!.github/dependabot.yml"
- - "!docker/**"
merge_group:
permissions:
- contents: read
+ contents: read # actions/checkout for all jobs; individual jobs add their own scopes when they need to write
env:
DEFAULT_PYTHON: "3.11"
@@ -39,7 +31,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate cache-key
id: cache-key
- run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
+ 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -52,14 +44,26 @@ jobs:
path: venv
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }}
+ - name: Set up uv
+ # Only needed on cache miss to populate the venv. ``uv pip install``
+ # 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
python -m venv venv
. venv/bin/activate
python --version
- pip install -r requirements.txt -r requirements_test.txt pre-commit
- pip install -e .
+ uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
+ uv pip install -e .
pylint:
name: Check pylint
@@ -89,6 +93,8 @@ jobs:
runs-on: ubuntu-24.04
needs:
- common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -108,6 +114,85 @@ jobs:
script/generate-esp32-boards.py --check
script/generate-rp2040-boards.py --check
+ import-time:
+ name: Check import esphome.__main__ time
+ runs-on: ubuntu-24.04
+ needs:
+ - common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.import-time == 'true'
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+ - name: Check import time against budget and write waterfall HAR
+ run: |
+ . venv/bin/activate
+ script/check_import_time.py --check --har importtime.har
+ - name: Upload waterfall HAR
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: import-time-waterfall
+ path: importtime.har
+ if-no-files-found: ignore
+ retention-days: 14
+
+ device-builder:
+ name: Test downstream esphome/device-builder
+ runs-on: ubuntu-24.04
+ needs:
+ - common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.device-builder == 'true'
+ steps:
+ - name: Check out esphome (this PR)
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ path: esphome
+ - name: Check out esphome/device-builder
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ repository: esphome/device-builder
+ ref: main
+ path: device-builder
+ - name: Set up Python
+ uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ with:
+ python-version: "3.13"
+ - name: Set up uv
+ # Mirrors the install shape device-builder's own CI uses
+ # (esphome/device-builder#192): uv replaces pip for the
+ # install step (order-of-magnitude faster on cold boots,
+ # with its own wheel cache). actions/setup-python still
+ # provides the interpreter.
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
+ - name: Install device-builder + esphome from PR
+ # Install device-builder with its esphome + test extras
+ # first so its pinned versions of pytest/etc. land, then
+ # overlay the PR's esphome so the downstream tests run
+ # against this PR's Python code. ``--system`` installs into
+ # the runner's Python instead of a venv.
+ run: |
+ uv pip install --system -e './device-builder[esphome,test]'
+ uv pip install --system -e ./esphome
+ - name: Run device-builder pytest
+ # ``-n auto`` runs under pytest-xdist (matches device-builder's
+ # own CI). No ``--cov`` here -- this is purely a downstream
+ # smoke check against this PR's esphome code.
+ working-directory: device-builder
+ run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks
+
pytest:
name: Run pytest
strategy:
@@ -132,6 +217,8 @@ jobs:
runs-on: ${{ matrix.os }}
needs:
- common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -147,14 +234,14 @@ jobs:
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
- pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
+ pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
- pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
+ pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
+ uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
@@ -170,12 +257,17 @@ jobs:
needs:
- common
outputs:
+ core-ci: ${{ steps.determine.outputs.core-ci }}
integration-tests: ${{ steps.determine.outputs.integration-tests }}
- integration-tests-run-all: ${{ steps.determine.outputs.integration-tests-run-all }}
- integration-test-files: ${{ steps.determine.outputs.integration-test-files }}
+ integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
+ clang-tidy-full-scan: ${{ steps.determine.outputs.clang-tidy-full-scan }}
python-linters: ${{ steps.determine.outputs.python-linters }}
+ import-time: ${{ steps.determine.outputs.import-time }}
+ device-builder: ${{ steps.determine.outputs.device-builder }}
+ native-idf: ${{ steps.determine.outputs.native-idf }}
+ native-idf-components: ${{ steps.determine.outputs.native-idf-components }}
changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
@@ -185,6 +277,7 @@ jobs:
cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }}
cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }}
component-test-batches: ${{ steps.determine.outputs.component-test-batches }}
+ validate-only-components: ${{ steps.determine.outputs.validate-only-components }}
benchmarks: ${{ steps.determine.outputs.benchmarks }}
steps:
- name: Check out code from GitHub
@@ -208,17 +301,27 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
. venv/bin/activate
- output=$(python script/determine-jobs.py)
+ EXTRA_ARGS=""
+ if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-run-all') }}" == "true" ]]; then
+ EXTRA_ARGS="--force-all"
+ echo "::notice::ci-run-all label detected -- forcing every CI job to run"
+ fi
+ output=$(python script/determine-jobs.py $EXTRA_ARGS)
echo "Test determination output:"
echo "$output" | jq
# Extract individual fields
+ echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
- echo "integration-tests-run-all=$(echo "$output" | jq -r '.integration_tests_run_all')" >> $GITHUB_OUTPUT
- echo "integration-test-files=$(echo "$output" | jq -c '.integration_test_files')" >> $GITHUB_OUTPUT
+ echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
+ echo "clang-tidy-full-scan=$(echo "$output" | jq -r '.clang_tidy_full_scan')" >> $GITHUB_OUTPUT
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
+ echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
+ echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
+ echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT
+ echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
@@ -228,6 +331,7 @@ jobs:
echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT
echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT
echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT
+ echo "validate-only-components=$(echo "$output" | jq -c '.validate_only_components')" >> $GITHUB_OUTPUT
echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
- name: Save components graph cache
if: github.ref == 'refs/heads/dev'
@@ -237,12 +341,16 @@ jobs:
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
integration-tests:
- name: Run integration tests
+ name: Run integration tests (${{ matrix.bucket.name }})
runs-on: ubuntu-latest
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.integration-tests == 'true'
+ strategy:
+ fail-fast: false
+ matrix:
+ bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -257,31 +365,36 @@ jobs:
with:
path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
+ - 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
python -m venv venv
. venv/bin/activate
python --version
- pip install -r requirements.txt -r requirements_test.txt
- pip install -e .
+ uv pip install -r requirements.txt -r requirements_test.txt
+ uv pip install -e .
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run integration tests
env:
- INTEGRATION_TEST_FILES: ${{ needs.determine-jobs.outputs.integration-test-files }}
- INTEGRATION_TESTS_RUN_ALL: ${{ needs.determine-jobs.outputs.integration-tests-run-all }}
+ # JSON array of test paths; parsed into a bash array below to avoid
+ # shell word-splitting / glob hazards.
+ BUCKET_TESTS: ${{ toJson(matrix.bucket.tests) }}
run: |
. venv/bin/activate
- if [[ "$INTEGRATION_TESTS_RUN_ALL" == "true" ]]; then
- echo "Running all integration tests"
- pytest -vv --no-cov --tb=native -n auto tests/integration/
- else
- # Parse JSON array into bash array to avoid shell expansion issues
- mapfile -t test_files < <(echo "$INTEGRATION_TEST_FILES" | jq -r '.[]')
- echo "Running ${#test_files[@]} specific integration tests"
- pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
- fi
+ mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
+ echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
+ pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
cpp-unit-tests:
name: Run C++ unit tests
@@ -339,9 +452,12 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
- uses: CodSpeedHQ/action@658a901452bb54c799643e060733b7afe9121b8d # v4.14.0
+ uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0
with:
- run: ${{ steps.build.outputs.binary }}
+ run: |
+ . venv/bin/activate
+ ${{ steps.build.outputs.binary }}
+ pytest tests/benchmarks/python/ --codspeed --no-cov
mode: simulation
clang-tidy-single:
@@ -415,7 +531,13 @@ jobs:
id: check_full_scan
run: |
. venv/bin/activate
- if python script/clang_tidy_hash.py --check; then
+ # determine-jobs.clang-tidy-full-scan is true when core C++ changed
+ # OR the ci-run-all label forced --force-all. Independent of the
+ # hash check, both must produce a full scan in the job itself.
+ if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
+ echo "full_scan=true" >> $GITHUB_OUTPUT
+ echo "reason=determine_jobs" >> $GITHUB_OUTPUT
+ elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -427,7 +549,7 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
- echo "Running FULL clang-tidy scan (hash changed)"
+ 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' || '' }}
else
echo "Running clang-tidy on changed files only"
@@ -487,7 +609,13 @@ jobs:
id: check_full_scan
run: |
. venv/bin/activate
- if python script/clang_tidy_hash.py --check; then
+ # determine-jobs.clang-tidy-full-scan is true when core C++ changed
+ # OR the ci-run-all label forced --force-all. Independent of the
+ # hash check, both must produce a full scan in the job itself.
+ if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
+ echo "full_scan=true" >> $GITHUB_OUTPUT
+ echo "reason=determine_jobs" >> $GITHUB_OUTPUT
+ elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -499,7 +627,7 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
- echo "Running FULL clang-tidy scan (hash changed)"
+ echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy
else
echo "Running clang-tidy on changed files only"
@@ -576,7 +704,13 @@ jobs:
id: check_full_scan
run: |
. venv/bin/activate
- if python script/clang_tidy_hash.py --check; then
+ # determine-jobs.clang-tidy-full-scan is true when core C++ changed
+ # OR the ci-run-all label forced --force-all. Independent of the
+ # hash check, both must produce a full scan in the job itself.
+ if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
+ echo "full_scan=true" >> $GITHUB_OUTPUT
+ echo "reason=determine_jobs" >> $GITHUB_OUTPUT
+ elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -588,7 +722,7 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
- echo "Running FULL clang-tidy scan (hash changed)"
+ echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --all-headers --fix ${{ matrix.options }}
else
echo "Running clang-tidy on changed files only"
@@ -699,20 +833,142 @@ jobs:
echo "Config validation passed! Starting compilation..."
echo ""
+ # Compute the compile-stage component list. Components whose only
+ # changes are validate.*.yaml files are config-only -- their source
+ # and test fixtures didn't move, so rebuilding firmware adds no
+ # signal. Subtract them from this batch before invoking compile.
+ validate_only_json='${{ needs.determine-jobs.outputs.validate-only-components }}'
+ if [ -z "$validate_only_json" ]; then
+ validate_only_json='[]'
+ fi
+ if ! validate_only_csv=$(echo "$validate_only_json" | jq -r 'join(",")'); then
+ echo "::error::Failed to render validate-only-components as CSV from: $validate_only_json"
+ exit 1
+ fi
+ if [ -z "$validate_only_csv" ]; then
+ compile_csv="$components_csv"
+ else
+ components_sorted=$(echo "$components_csv" | tr ',' '\n' | sort -u)
+ validate_sorted=$(echo "$validate_only_csv" | tr ',' '\n' | sort -u)
+ if ! diff_out=$(comm -23 <(echo "$components_sorted") <(echo "$validate_sorted")); then
+ echo "::error::Failed to compute compile component subset."
+ exit 1
+ fi
+ compile_csv=$(echo "$diff_out" | paste -sd ',' -)
+ skipped=$(comm -12 <(echo "$components_sorted") <(echo "$validate_sorted") | paste -sd ',' -)
+ if [ -n "$skipped" ]; then
+ echo "Validate-only components in this batch (skipping compile): $skipped"
+ fi
+ fi
+
# Show disk space before compilation
echo "Disk space before compilation:"
df -h
echo ""
- # Run compilation with grouping and isolation
- python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv"
+ if [ -n "$compile_csv" ]; then
+ # Run compilation with grouping and isolation
+ python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv"
+ else
+ echo "All components in this batch are validate-only -- skipping compile stage."
+ fi
+
+ test-native-idf:
+ name: Test components with native ESP-IDF
+ runs-on: ubuntu-24.04
+ needs:
+ - common
+ - determine-jobs
+ if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true'
+ env:
+ ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
+ # Comma-joined subset of the native-IDF representative component list,
+ # computed by script/determine-jobs.py (native_idf_components_to_test).
+ # Single source of truth -- the full list lives in
+ # script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS.
+ TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }}
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+
+ - name: Cache ESPHome
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ~/.esphome-idf
+ key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
+
+ - name: Run native ESP-IDF compile test
+ run: |
+ . venv/bin/activate
+
+ # Check if /mnt has more free space than / before bind mounting
+ # Extract available space in KB for comparison
+ root_avail=$(df -k / | awk 'NR==2 {print $4}')
+ mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}')
+
+ echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB"
+
+ # Only use /mnt if it has more space than /
+ if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then
+ echo "Using /mnt for build files (more space available)"
+ # Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there)
+ sudo mkdir -p /mnt/esphome-idf
+ sudo chown $USER:$USER /mnt/esphome-idf
+ mkdir -p ~/.esphome-idf
+ sudo mount --bind /mnt/esphome-idf ~/.esphome-idf
+
+ # Bind mount test build directory to /mnt
+ sudo mkdir -p /mnt/test_build_components_build
+ sudo chown $USER:$USER /mnt/test_build_components_build
+ mkdir -p tests/test_build_components/build
+ sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build
+ else
+ echo "Using / for build files (more space available than /mnt or /mnt unavailable)"
+ fi
+
+ echo "Testing components: $TEST_COMPONENTS"
+ echo ""
+
+ # Show disk space before validation (after bind mounts setup)
+ echo "Disk space before config validation:"
+ df -h
+ echo ""
+
+ # Run config validation (auto-grouped by test_build_components.py)
+ python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf
+
+ echo ""
+ echo "Config validation passed! Starting compilation..."
+ echo ""
+
+ # Show disk space before compilation
+ echo "Disk space before compilation:"
+ df -h
+ echo ""
+
+ # Run compilation (auto-grouped by test_build_components.py)
+ python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf
+
+ - name: Save ESPHome cache
+ if: github.ref == 'refs/heads/dev'
+ uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ~/.esphome-idf
+ key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
pre-commit-ci-lite:
name: pre-commit.ci lite
runs-on: ubuntu-latest
needs:
- common
- if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
+ - determine-jobs
+ if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -986,8 +1242,8 @@ jobs:
- memory-impact-pr-branch
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && fromJSON(needs.determine-jobs.outputs.memory_impact).should_run == 'true' && needs.memory-impact-target-branch.outputs.skip != 'true'
permissions:
- contents: read
- pull-requests: write
+ contents: read # actions/checkout to load the comment-posting script
+ pull-requests: write # ci_memory_impact_comment.py posts/updates the memory-impact comment on the PR
env:
GH_TOKEN: ${{ github.token }}
steps:
@@ -1036,7 +1292,9 @@ jobs:
- clang-tidy-nosplit
- clang-tidy-split
- determine-jobs
+ - device-builder
- test-build-components-split
+ - test-native-idf
- pre-commit-ci-lite
- memory-impact-target-branch
- memory-impact-pr-branch
diff --git a/.github/workflows/close-pr-from-fork-default-branch.yml b/.github/workflows/close-pr-from-fork-default-branch.yml
index 1cd70f5ef..5180a0718 100644
--- a/.github/workflows/close-pr-from-fork-default-branch.yml
+++ b/.github/workflows/close-pr-from-fork-default-branch.yml
@@ -6,8 +6,8 @@ on:
types: [opened, reopened]
permissions:
- pull-requests: write
- issues: write
+ pull-requests: write # pulls.update to close the PR opened from a fork's default branch
+ issues: write # issues.createComment to explain to the contributor why the PR was closed
jobs:
close:
diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml
index 49653b6fb..013517bde 100644
--- a/.github/workflows/codeowner-approved-label-update.yml
+++ b/.github/workflows/codeowner-approved-label-update.yml
@@ -15,9 +15,9 @@ on:
- beta
permissions:
- issues: write
- pull-requests: read
- contents: read
+ issues: write # issues.addLabels / removeLabel to manage the 'code-owner-approved' label on the PR
+ pull-requests: read # listReviews to determine whether a codeowner has approved
+ contents: read # actions/checkout to read CODEOWNERS and the shared codeowners.js helper
jobs:
codeowner-approved:
diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml
index 76be6ecd7..7cdbfcf32 100644
--- a/.github/workflows/codeowner-review-request.yml
+++ b/.github/workflows/codeowner-review-request.yml
@@ -17,9 +17,10 @@ on:
- release
- beta
+# PR/review writes (requestReviewers, issues.createComment) are performed with the App token minted below,
+# so the workflow's GITHUB_TOKEN only needs read access for checkout.
permissions:
- pull-requests: write
- contents: read
+ contents: read # actions/checkout to read CODEOWNERS and the shared codeowners.js helper
jobs:
request-codeowner-reviews:
@@ -32,9 +33,20 @@ jobs:
with:
ref: ${{ github.event.pull_request.base.sha }}
+ - name: Generate a token
+ id: generate-token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+ with:
+ client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
+ private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+ # Scope the minted App token to the minimum needed by the github-script step below.
+ permission-pull-requests: write # pulls.listFiles, pulls.get, pulls.listReviews, pulls.requestReviewers
+ permission-issues: write # issues.listComments and issues.createComment (PR comments use the issues API)
+
- name: Request reviews from component codeowners
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+ github-token: ${{ steps.generate-token.outputs.token }}
script: |
const { loadCodeowners, getEffectiveOwners } = require('./.github/scripts/codeowners.js');
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 246a86569..dfc0e08bf 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -16,6 +16,9 @@ on:
schedule:
- cron: "30 18 * * 4"
+# Deny by default; the analyze job opts in to exactly what it needs.
+permissions: {}
+
jobs:
analyze:
name: Analyze (${{ matrix.language }})
@@ -26,15 +29,10 @@ jobs:
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
- # required for all workflows
- security-events: write
-
- # required to fetch internal or private CodeQL packs
- packages: read
-
- # only required for workflows in private repositories
- actions: read
- contents: read
+ security-events: write # upload CodeQL SARIF results to the Code Scanning API
+ packages: read # fetch internal or private CodeQL query packs
+ actions: read # required by codeql-action when run from a private repo
+ contents: read # actions/checkout to scan the repository
strategy:
fail-fast: false
@@ -58,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+ uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -86,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+ uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml
new file mode 100644
index 000000000..ffd5ec7bd
--- /dev/null
+++ b/.github/workflows/dashboard-deprecation-comment.yml
@@ -0,0 +1,119 @@
+name: Add Dashboard Deprecation Comment
+
+on:
+ pull_request_target:
+ types: [opened, synchronize]
+
+# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with
+# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes.
+permissions: {}
+
+jobs:
+ dashboard-deprecation-comment:
+ name: Dashboard deprecation comment
+ runs-on: ubuntu-latest
+ # Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably
+ # roll up everything merged into dev since the last cut, which can
+ # include dashboard changes that have already been reviewed once.
+ # The bot's purpose is to warn new contributors before they invest
+ # time -- that only applies to PRs entering dev.
+ if: github.event.pull_request.base.ref == 'dev'
+ steps:
+ - name: Generate a token
+ id: generate-token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+ with:
+ client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
+ private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+ # pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources
+ # the issues.*Comment APIs require the pull-requests scope, not issues.
+ permission-pull-requests: write
+
+ - name: Add dashboard deprecation comment
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ github-token: ${{ steps.generate-token.outputs.token }}
+ script: |
+ const commentMarker = "";
+
+ const commentBody = `Thanks for opening this PR!
+
+ Heads up: the legacy ESPHome dashboard (\`esphome/dashboard/\` and \`tests/dashboard/\`) is **deprecated** and is being replaced by [ESPHome Device Builder](https://github.com/esphome/device-builder). We are not adding new features to the legacy dashboard and it will eventually be removed from this repository.
+
+ What this means for your PR:
+
+ - **New features / enhancements**: please port the change to [esphome/device-builder](https://github.com/esphome/device-builder) instead. We are unlikely to review or merge new dashboard features here.
+ - **Bug fixes**: small fixes may still be considered, but please check first whether the same issue exists in Device Builder, where the fix will have a longer life.
+ - **Security issues**: please do not file a public PR. Report privately via [GitHub security advisories](https://github.com/esphome/esphome/security/advisories/new) so we can coordinate a fix.
+
+ We appreciate the contribution and apologize for the friction; flagging this early so your time isn't spent on a change that may not land.
+
+ ---
+ (Added by the PR bot)
+
+ ${commentMarker}`;
+
+ async function getDashboardChanges(github, owner, repo, prNumber) {
+ const changedFiles = await github.paginate(
+ github.rest.pulls.listFiles,
+ {
+ owner: owner,
+ repo: repo,
+ pull_number: prNumber,
+ per_page: 100,
+ }
+ );
+
+ return changedFiles.filter(file =>
+ file.filename.startsWith('esphome/dashboard/') ||
+ file.filename.startsWith('tests/dashboard/')
+ );
+ }
+
+ async function findBotComment(github, owner, repo, prNumber) {
+ const comments = await github.paginate(
+ github.rest.issues.listComments,
+ {
+ owner: owner,
+ repo: repo,
+ issue_number: prNumber,
+ per_page: 100,
+ }
+ );
+
+ return comments.find(comment =>
+ comment.body.includes(commentMarker) && comment.user.type === "Bot"
+ );
+ }
+
+ const prNumber = context.payload.pull_request.number;
+ const { owner, repo } = context.repo;
+
+ const dashboardChanges = await getDashboardChanges(github, owner, repo, prNumber);
+ const existingComment = await findBotComment(github, owner, repo, prNumber);
+
+ if (dashboardChanges.length === 0) {
+ // PR doesn't (or no longer) touches the legacy dashboard. If we previously
+ // commented (e.g. files were removed in a later push), leave the comment in
+ // place for history rather than thrash on edit/delete.
+ return;
+ }
+
+ if (existingComment) {
+ if (existingComment.body === commentBody) {
+ return;
+ }
+ await github.rest.issues.updateComment({
+ owner: owner,
+ repo: repo,
+ comment_id: existingComment.id,
+ body: commentBody,
+ });
+ } else {
+ await github.rest.issues.createComment({
+ owner: owner,
+ repo: repo,
+ issue_number: prNumber,
+ body: commentBody,
+ });
+ }
diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml
index 3165b1707..104988d7a 100644
--- a/.github/workflows/external-component-bot.yml
+++ b/.github/workflows/external-component-bot.yml
@@ -4,20 +4,29 @@ on:
pull_request_target:
types: [opened, synchronize]
-permissions:
- contents: read # Needed to fetch PR details
- issues: write # Needed to create and update comments (PR comments are managed via the issues REST API)
- pull-requests: write # also needed?
+# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with
+# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes.
+permissions: {}
jobs:
external-comment:
name: External component comment
runs-on: ubuntu-latest
steps:
+ - name: Generate a token
+ id: generate-token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+ with:
+ client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
+ private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+ # pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources
+ # the issues.*Comment APIs require the pull-requests scope, not issues.
+ permission-pull-requests: write
+
- name: Add external component comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
+ github-token: ${{ steps.generate-token.outputs.token }}
script: |
// Generate external component usage instructions
function generateExternalComponentInstructions(prNumber, componentNames, owner, repo) {
diff --git a/.github/workflows/issue-codeowner-notify.yml b/.github/workflows/issue-codeowner-notify.yml
index b211c1398..bc892b64e 100644
--- a/.github/workflows/issue-codeowner-notify.yml
+++ b/.github/workflows/issue-codeowner-notify.yml
@@ -9,8 +9,8 @@ on:
types: [labeled]
permissions:
- issues: write
- contents: read
+ issues: write # issues.createComment to mention component codeowners on the newly labelled issue
+ contents: read # repos.getContent to fetch CODEOWNERS from the default branch
jobs:
notify-codeowners:
diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml
index 20f9a74ea..5e7011765 100644
--- a/.github/workflows/lock.yml
+++ b/.github/workflows/lock.yml
@@ -6,6 +6,12 @@ on:
- cron: "30 0 * * *" # Run daily at 00:30 UTC
workflow_dispatch:
+# Deny by default; the lock job opts in to exactly what the reusable workflow needs.
+permissions: {}
+
jobs:
lock:
- uses: esphome/workflows/.github/workflows/lock.yml@3c4e8446aa1029f1c346a482034b3ee1489077ca # 2026.4.0
+ 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
diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml
index 870099627..e8320672d 100644
--- a/.github/workflows/pr-title-check.yml
+++ b/.github/workflows/pr-title-check.yml
@@ -8,8 +8,8 @@ on:
- beta
permissions:
- contents: read
- pull-requests: read
+ contents: read # actions/checkout to load detect-tags.js
+ pull-requests: read # pulls.listFiles to map changed files to component/core/dashboard/ci tags
jobs:
check:
@@ -29,10 +29,11 @@ jobs:
} = require('./.github/scripts/detect-tags.js');
const title = context.payload.pull_request.title;
- const author = context.payload.pull_request.user.login;
+ const user = context.payload.pull_request.user;
- // Skip bot PRs (e.g. dependabot) - they have their own title format
- if (author === 'dependabot[bot]') {
+ // Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) -
+ // they have their own title formats.
+ if (user.type === 'Bot') {
return;
}
@@ -68,14 +69,15 @@ jobs:
return;
}
- // Check for angle brackets not wrapped in backticks.
- // Astro docs MDX treats bare < as JSX component opening tags.
+ // Check for MDX syntax characters not wrapped in backticks.
+ // Astro docs MDX treats bare `<` as JSX component opening tags and
+ // bare `{` as JS expressions, so both must be escaped in changelog entries.
const stripped = title.replace(/`[^`]*`/g, '');
- if (/[<>]/.test(stripped)) {
+ if (/[<>{}]/.test(stripped)) {
core.setFailed(
- 'PR title contains `<` or `>` not wrapped in backticks.\n' +
- 'Astro docs MDX interprets bare `<` as JSX components.\n' +
- 'Please wrap angle brackets with backticks, e.g.: [component] Add `` support'
+ 'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' +
+ 'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' +
+ 'Please wrap these characters with backticks, e.g.: [component] Add `` support'
);
return;
}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 35b9e065e..344bd416c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -9,7 +9,7 @@ on:
- cron: "0 2 * * *"
permissions:
- contents: read
+ contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write
jobs:
init:
@@ -57,8 +57,8 @@ jobs:
if: github.repository == 'esphome/esphome' && github.event_name == 'release'
runs-on: ubuntu-latest
permissions:
- contents: read
- id-token: write
+ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python
@@ -78,8 +78,8 @@ jobs:
name: Build ESPHome ${{ matrix.platform.arch }}
if: github.repository == 'esphome/esphome'
permissions:
- contents: read
- packages: write
+ contents: read # actions/checkout to load Dockerfile and build context
+ packages: write # docker/login-action + build-push-action push image digests to ghcr.io
runs-on: ${{ matrix.platform.os }}
needs: [init]
strategy:
@@ -99,15 +99,15 @@ jobs:
python-version: "3.11"
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to docker hub
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -152,8 +152,8 @@ jobs:
- deploy-docker
if: github.repository == 'esphome/esphome'
permissions:
- contents: read
- packages: write
+ contents: read # actions/checkout to load Dockerfile and build context
+ packages: write # docker/login-action + build-push-action push image digests to ghcr.io
strategy:
fail-fast: false
matrix:
@@ -178,17 +178,17 @@ jobs:
merge-multiple: true
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+ uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr'
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -212,72 +212,6 @@ jobs:
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
$(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *)
- deploy-ha-addon-repo:
- if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
- runs-on: ubuntu-latest
- needs:
- - init
- - deploy-manifest
- steps:
- - name: Generate a token
- id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
- with:
- app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
- private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
- owner: esphome
- repositories: home-assistant-addon
-
- - name: Trigger Workflow
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- github-token: ${{ steps.generate-token.outputs.token }}
- script: |
- let description = "ESPHome";
- if (context.eventName == "release") {
- description = ${{ toJSON(github.event.release.body) }};
- }
- github.rest.actions.createWorkflowDispatch({
- owner: "esphome",
- repo: "home-assistant-addon",
- workflow_id: "bump-version.yml",
- ref: "main",
- inputs: {
- version: "${{ needs.init.outputs.tag }}",
- content: description
- }
- })
-
- deploy-esphome-schema:
- if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
- runs-on: ubuntu-latest
- needs: [init]
- environment: ${{ needs.init.outputs.deploy_env }}
- steps:
- - name: Generate a token
- id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
- with:
- app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
- private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
- owner: esphome
- repositories: esphome-schema
-
- - name: Trigger Workflow
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- github-token: ${{ steps.generate-token.outputs.token }}
- script: |
- github.rest.actions.createWorkflowDispatch({
- owner: "esphome",
- repo: "esphome-schema",
- workflow_id: "generate-schemas.yml",
- ref: "main",
- inputs: {
- version: "${{ needs.init.outputs.tag }}",
- }
- })
-
version-notifier:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
@@ -287,19 +221,20 @@ jobs:
steps:
- name: Generate a token
id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
- app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
+ client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: version-notifier
+ permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
- github.rest.actions.createWorkflowDispatch({
+ await github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "version-notifier",
workflow_id: "notify.yml",
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index ba5c32e01..7003f6c48 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -7,8 +7,8 @@ on:
workflow_dispatch:
permissions:
- issues: write
- pull-requests: write
+ issues: write # actions/stale labels, comments on, and closes stale issues
+ pull-requests: write # actions/stale labels, comments on, and closes stale pull requests
concurrency:
group: lock
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Stale
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
+ uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml
index 709342e5a..d27cc0cbe 100644
--- a/.github/workflows/status-check-labels.yml
+++ b/.github/workflows/status-check-labels.yml
@@ -4,6 +4,9 @@ on:
pull_request:
types: [opened, reopened, labeled, unlabeled, synchronize]
+permissions:
+ pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr)
+
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml
index be1457387..84be3c8e2 100644
--- a/.github/workflows/sync-device-classes.yml
+++ b/.github/workflows/sync-device-classes.yml
@@ -6,12 +6,27 @@ on:
schedule:
- cron: "45 6 * * *"
+# Repo writes (branch push, PR open) happen via the App token minted below,
+# so the workflow's GITHUB_TOKEN does not need any write scopes.
+permissions:
+ contents: read # actions/checkout for this repo and home-assistant/core
+
jobs:
sync:
name: Sync Device Classes
runs-on: ubuntu-latest
if: github.repository == 'esphome/esphome'
steps:
+ - name: Generate a token
+ id: generate-token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
+ with:
+ client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
+ private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+ # Scope the minted App token to the minimum needed by peter-evans/create-pull-request.
+ permission-contents: write # git.createCommit + refs.create/update to push the sync/device-classes branch
+ permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
+
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -26,19 +41,56 @@ jobs:
with:
python-version: "3.14"
+ - name: Set up uv
+ # An order of magnitude faster than pip on cold boots, with its
+ # own wheel cache. ``--system`` (below) installs into the
+ # setup-python interpreter so subsequent ``pre-commit`` /
+ # ``script/run-in-env.py`` steps find the deps without a
+ # ``uv run`` prefix.
+ uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
+
- name: Install Home Assistant
run: |
- python -m pip install --upgrade pip
- pip install -e lib/home-assistant
- pip install -r requirements_test.txt pre-commit
+ uv pip install --system -e lib/home-assistant
+ uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
- name: Sync
run: |
python ./script/sync-device_class.py
- - name: Run pre-commit hooks
- run: |
- python script/run-in-env.py pre-commit run --all-files
+ - name: Apply pre-commit auto-fixes
+ # First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
+ # files. pre-commit exits non-zero whenever a hook touches anything,
+ # which would otherwise abort the workflow before the auto-fixes
+ # can flow into the sync PR.
+ #
+ # SKIP:
+ # - no-commit-to-branch is a local guard against committing on
+ # dev/release/beta; CI runs on dev by definition, and
+ # peter-evans/create-pull-request creates the branch itself.
+ # - pylint surfaces import-error / relative-beyond-top-level
+ # noise here because this workflow installs only a subset of
+ # the runtime deps (HA + requirements*.txt); main CI already
+ # gates pylint on real PRs.
+ env:
+ SKIP: pylint,no-commit-to-branch
+ run: python script/run-in-env.py pre-commit run --all-files || true
+
+ - name: Verify pre-commit clean
+ # Second pass: re-run all hooks against the now-fixed tree.
+ # Auto-fixers exit 0 (nothing to change); any remaining failure
+ # from a check-only hook (flake8 / yamllint / ci-custom) is a
+ # real issue and fails the workflow loudly. Same SKIP list as
+ # above for the same reasons.
+ env:
+ SKIP: pylint,no-commit-to-branch
+ run: python script/run-in-env.py pre-commit run --all-files
- name: Commit changes
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
@@ -50,4 +102,4 @@ jobs:
delete-branch: true
title: "Synchronise Device Classes from Home Assistant"
body-path: .github/PULL_REQUEST_TEMPLATE.md
- token: ${{ secrets.DEVICE_CLASS_SYNC_TOKEN }}
+ token: ${{ steps.generate-token.outputs.token }}
diff --git a/.gitignore b/.gitignore
index da568d9b8..4a4a88fd4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -146,5 +146,6 @@ sdkconfig.*
/components
/managed_components
+/dependencies.lock
api-docs/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ad82bd8e5..a07612897 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
- rev: v0.15.12
+ rev: v0.15.15
hooks:
# Run the linter.
- id: ruff
@@ -55,7 +55,7 @@ repos:
hooks:
- id: pylint
name: pylint
- entry: python3 script/run-in-env.py pylint
+ entry: python script/run-in-env.py pylint
language: system
types: [python]
files: ^esphome/.+\.py$
@@ -68,5 +68,5 @@ repos:
additional_dependencies: []
- id: ci-custom
name: ci-custom
- entry: python3 script/run-in-env.py script/ci-custom.py
+ entry: python script/run-in-env.py script/ci-custom.py
language: system
diff --git a/.ai/instructions.md b/AGENTS.md
similarity index 95%
rename from .ai/instructions.md
rename to AGENTS.md
index 86f554e9c..4adc53cae 100644
--- a/.ai/instructions.md
+++ b/AGENTS.md
@@ -398,13 +398,23 @@ This document provides essential context for AI models interacting with this pro
│ ├── i2c/ # I2C bus
│ └── spi/ # SPI bus
└── components/[component]/
- ├── common.yaml # Component-only config (no bus definitions)
- ├── test.esp32-idf.yaml
- ├── test.esp8266-ard.yaml
- └── test.rp2040-ard.yaml
+ ├── common.yaml # Component-only config (no bus definitions)
+ ├── test.esp32-idf.yaml # config + compile
+ ├── test.esp8266-ard.yaml # config + compile
+ ├── test-variant.esp32-idf.yaml # variant test, config + compile
+ ├── validate.esp32-idf.yaml # config-only (never compiled)
+ └── validate-legacy.esp32-idf.yaml # config-only variant
```
Run them using `script/test_build_components`. Use `-c ` to test specific components and `-t ` for specific platforms.
+ * **Config-only test files (`validate.*.yaml`):** Use this prefix when a YAML file only needs to exercise schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with `esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
+ - `validate..yaml` — base config-only test
+ - `validate-..yaml` — config-only variant
+
+ Use this for things like deprecated-syntax migration tests, schema edge cases, or platform-specific validation branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and `validate.*.yaml` files. Validate files never participate in bus-grouping; each one runs as its own `esphome config` invocation.
+
+ 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/`:
```yaml
# test.esp32-idf.yaml — use packages for buses
@@ -452,7 +462,7 @@ This document provides essential context for AI models interacting with this pro
6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
* **Documentation Contributions:**
- * Documentation is hosted in the separate `esphome/esphome-docs` repository.
+ * Documentation is hosted in the separate `esphome/esphome.io` repository.
* The contribution workflow is the same as for the codebase.
* When editing a component's documentation page, also update the corresponding component index page to ensure both pages remain in sync.
@@ -671,7 +681,7 @@ This document provides essential context for AI models interacting with this pro
- [ ] Explored non-breaking alternatives
- [ ] Added deprecation warnings if possible (use `ESPDEPRECATED` macro for C++)
- [ ] Documented migration path in PR description with before/after examples
- - [ ] Updated all internal usage and esphome-docs
+ - [ ] Updated all internal usage and esphome.io
- [ ] Tested backward compatibility during deprecation period
* **Deprecation Pattern (C++):**
diff --git a/CLAUDE.md b/CLAUDE.md
index 49e811ff0..47dc3e3d8 120000
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1 +1 @@
-.ai/instructions.md
\ No newline at end of file
+AGENTS.md
\ No newline at end of file
diff --git a/CODEOWNERS b/CODEOWNERS
index 20c19a7df..3c3e50205 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -347,6 +347,7 @@ esphome/components/modbus_controller/select/* @martgras @stegm
esphome/components/modbus_controller/sensor/* @martgras
esphome/components/modbus_controller/switch/* @martgras
esphome/components/modbus_controller/text_sensor/* @martgras
+esphome/components/modbus_server/* @exciton
esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan
esphome/components/mopeka_pro_check/* @spbrogan
esphome/components/mopeka_std_check/* @Fabian-Schmidt
@@ -415,6 +416,8 @@ esphome/components/resampler/speaker/* @kahrendt
esphome/components/restart/* @esphome/core
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/rp2040_ble/* @bdraco
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
diff --git a/Doxyfile b/Doxyfile
index 7fce941c9..353751699 100644
--- a/Doxyfile
+++ b/Doxyfile
@@ -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.5.0-dev
+PROJECT_NUMBER = 2026.6.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
diff --git a/GEMINI.md b/GEMINI.md
index 49e811ff0..47dc3e3d8 120000
--- a/GEMINI.md
+++ b/GEMINI.md
@@ -1 +1 @@
-.ai/instructions.md
\ No newline at end of file
+AGENTS.md
\ No newline at end of file
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 540d28be7..25de9472b 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -13,12 +13,16 @@ RUN git config --system --add safe.directory "*" \
&& git config --system advice.detachedHead false
# Install build tools for Python packages that require compilation
-# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager)
+# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager).
+# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can
+# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without
+# it idf_tools.py rejects the openocd install with exit 127 and aborts
+# the whole framework setup.
RUN if command -v apk > /dev/null; then \
- apk add --no-cache build-base; \
+ apk add --no-cache build-base libusb; \
else \
apt-get update \
- && apt-get install -y --no-install-recommends build-essential \
+ && apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
&& rm -rf /var/lib/apt/lists/*; \
fi
diff --git a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh
new file mode 100755
index 000000000..b99046976
--- /dev/null
+++ b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh
@@ -0,0 +1,22 @@
+#!/usr/bin/with-contenv bashio
+# ==============================================================================
+# Installs the latest prerelease of esphome-device-builder when the
+# `use_new_device_builder` config option is enabled.
+# This is a temporary install-on-boot step until esphome-device-builder
+# becomes a direct dependency of esphome.
+# ==============================================================================
+
+if ! bashio::config.true 'use_new_device_builder'; then
+ exit 0
+fi
+
+bashio::log.info "Installing latest prerelease of esphome-device-builder..."
+if command -v uv > /dev/null; then
+ uv pip install --system --no-cache-dir --prerelease=allow --upgrade \
+ esphome-device-builder ||
+ bashio::exit.nok "Failed installing esphome-device-builder."
+else
+ pip install --no-cache-dir --pre --upgrade esphome-device-builder ||
+ bashio::exit.nok "Failed installing esphome-device-builder."
+fi
+bashio::log.info "Installed esphome-device-builder."
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run
index cdbaff6c0..64ac0b18d 100755
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run
+++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/run
@@ -49,5 +49,12 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then
rm -rf /config/esphome/.esphome
fi
+if bashio::config.true 'use_new_device_builder'; then
+ bashio::log.info "Starting ESPHome Device Builder..."
+ exec esphome-device-builder /config/esphome \
+ --ha-addon \
+ --ingress-port "$(bashio::addon.ingress_port)"
+fi
+
bashio::log.info "Starting ESPHome dashboard..."
exec esphome dashboard /config/esphome --socket /var/run/esphome.sock --ha-addon
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run
index 2725f5667..18c75898e 100755
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run
+++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/init-nginx/run
@@ -4,6 +4,14 @@
# Community Hass.io Add-ons: ESPHome
# Configures NGINX for use with ESPHome
# ==============================================================================
+
+# When the new device builder is enabled it serves HA ingress directly,
+# so nginx is not used at all -- skip configuration.
+if bashio::config.true 'use_new_device_builder'; then
+ bashio::log.info "Skipping NGINX setup: new device builder serves ingress directly."
+ bashio::exit.ok
+fi
+
mkdir -p /var/log/nginx
# Generate Ingress configuration
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run
index e96991cdd..bb5f52e10 100755
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run
+++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run
@@ -5,6 +5,14 @@
# Runs the NGINX proxy
# ==============================================================================
+# The new device builder handles HA ingress itself, so nginx is bypassed.
+# Block the longrun forever so s6 keeps the dependency satisfied and does
+# not respawn it.
+if bashio::config.true 'use_new_device_builder'; then
+ bashio::log.info "NGINX bypassed: new device builder serves ingress directly."
+ exec sleep infinity
+fi
+
bashio::log.info "Waiting for ESPHome dashboard to come up..."
while [[ ! -S /var/run/esphome.sock ]]; do
diff --git a/esphome/__main__.py b/esphome/__main__.py
index 8c80dab90..7c4028da4 100644
--- a/esphome/__main__.py
+++ b/esphome/__main__.py
@@ -21,13 +21,14 @@ 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, writer, yaml_util
+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,
CONF_API,
+ CONF_AUTH,
CONF_BAUD_RATE,
CONF_BROKER,
CONF_DEASSERT_RTS_DTR,
@@ -47,22 +48,27 @@ from esphome.const import (
CONF_PORT,
CONF_SUBSTITUTIONS,
CONF_TOPIC,
+ CONF_USERNAME,
+ CONF_WEB_SERVER,
+ CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
- KEY_NATIVE_IDF,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
SECRETS_FILES,
+ Toolchain,
)
from esphome.core import CORE, EsphomeError, coroutine
from esphome.enum import StrEnum
from esphome.helpers import get_bool_env, indent, is_ip_address
from esphome.log import AnsiFore, color, setup_log
from esphome.types import ConfigType
+from esphome.upload_targets import PortType, get_port_type
from esphome.util import (
PICOTOOL_PACKAGE,
+ FlashImage,
detect_rp2040_bootsel,
get_picotool_path,
get_serial_ports,
@@ -72,7 +78,12 @@ from esphome.util import (
run_external_process,
safe_print,
)
-from esphome.zeroconf import discover_mdns_devices
+
+# Keep expensive imports (zeroconf, writer, yaml_util, etc.) out of this
+# module's top level. Every `esphome` invocation — including fast paths
+# like `esphome version` — pays the cost of what's imported here before
+# any command runs. Import inside the function that needs it instead.
+# `script/check_import_time.py` enforces a budget in CI.
_LOGGER = logging.getLogger(__name__)
@@ -146,7 +157,6 @@ class ArgsProtocol(Protocol):
configuration: str
name: str
upload_speed: str | None
- native_idf: bool
def choose_prompt(options, purpose: str = None):
@@ -186,14 +196,6 @@ class Purpose(StrEnum):
LOGGING = "logging"
-class PortType(StrEnum):
- SERIAL = "SERIAL"
- NETWORK = "NETWORK"
- MQTT = "MQTT"
- MQTTIP = "MQTTIP"
- BOOTSEL = "BOOTSEL"
-
-
# Magic MQTT port types that require special handling
_MQTT_PORT_TYPES = frozenset({PortType.MQTT, PortType.MQTTIP})
@@ -241,6 +243,8 @@ def _discover_mac_suffix_devices() -> list[str] | None:
"""
if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
return None
+ from esphome.zeroconf import discover_mdns_devices
+
_LOGGER.info("Discovering devices...")
if not (discovered := discover_mdns_devices(CORE.name)):
_LOGGER.warning(
@@ -341,6 +345,17 @@ def choose_upload_log_host(
elif bootsel.permission_error:
bootsel_permission_error = True
+ # Annotate the OTA chooser entry only in the non-default case: when the
+ # config has web_server OTA but no native API OTA, the upload will fall
+ # through to the HTTP path and the user benefits from seeing that
+ # explicitly. The native-API path is the default and gets a plain label
+ # to avoid noise on the most common scenario. For LOGGING the OTA
+ # transport doesn't apply, so always leave the label plain.
+ if purpose == Purpose.UPLOADING and not has_native_ota() and has_web_server_ota():
+ ota_suffix = " via web_server"
+ else:
+ ota_suffix = ""
+
def add_ota_options() -> None:
"""Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled."""
if (discovered := _discover_mac_suffix_devices()) is not None:
@@ -348,11 +363,11 @@ def choose_upload_log_host(
# intentionally skip the base-name fallback since with
# name_add_mac_suffix on, the base name doesn't exist on the net.
for host in discovered:
- options.append((f"Over The Air ({host})", host))
+ options.append((f"Over The Air{ota_suffix} ({host})", host))
elif has_resolvable_address():
- options.append((f"Over The Air ({CORE.address})", CORE.address))
+ options.append((f"Over The Air{ota_suffix} ({CORE.address})", CORE.address))
if has_mqtt_ip_lookup():
- options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
+ options.append((f"Over The Air{ota_suffix} (MQTT IP lookup)", "MQTTIP"))
if purpose == Purpose.LOGGING:
if has_mqtt_logging():
@@ -421,7 +436,19 @@ def has_api() -> bool:
def has_ota() -> bool:
- """Check if OTA upload is available (requires platform: esphome)."""
+ """Check if any network OTA upload is available.
+
+ True if the config exposes either ``platform: esphome`` (native API
+ OTA) or ``platform: web_server`` (HTTP OTA). Both reach the device
+ over the same network stack, so the OTA discovery path treats them
+ interchangeably; ``upload_program`` picks the actual transport based
+ on ``--ota-platform`` and what's configured.
+ """
+ return has_native_ota() or has_web_server_ota()
+
+
+def has_native_ota() -> bool:
+ """Check if native API OTA upload is available (``platform: esphome``)."""
if CONF_OTA not in CORE.config:
return False
return any(
@@ -430,6 +457,16 @@ def has_ota() -> bool:
)
+def has_web_server_ota() -> bool:
+ """Check if web_server OTA upload is available (``platform: web_server``)."""
+ if CONF_OTA not in CORE.config:
+ return False
+ return any(
+ ota_item.get(CONF_PLATFORM) == CONF_WEB_SERVER
+ for ota_item in CORE.config[CONF_OTA]
+ )
+
+
def has_mqtt_ip_lookup() -> bool:
"""Check if MQTT is available and IP lookup is supported."""
from esphome.components.mqtt import CONF_DISCOVER_IP
@@ -554,33 +591,10 @@ def _resolve_network_devices(
return network_devices
-def get_port_type(port: str) -> PortType:
- """Determine the type of port/device identifier.
-
- Returns:
- PortType.SERIAL for serial ports (/dev/ttyUSB0, COM1, etc.)
- PortType.BOOTSEL for RP2040 BOOTSEL upload via picotool
- PortType.MQTT for MQTT logging
- PortType.MQTTIP for MQTT IP lookup
- PortType.NETWORK for IP addresses, hostnames, or mDNS names
- """
- if port == "BOOTSEL":
- return PortType.BOOTSEL
- if port.startswith("/") or port.startswith("COM"):
- return PortType.SERIAL
- if port == "MQTT":
- return PortType.MQTT
- if port == "MQTTIP":
- return PortType.MQTTIP
- return PortType.NETWORK
-
-
def run_miniterm(config: ConfigType, port: str, args) -> int:
from aioesphomeapi import LogParser
import serial
- from esphome import platformio_api
-
if CONF_LOGGER not in config:
_LOGGER.info("Logger is not enabled. Not starting UART logs.")
return 1
@@ -594,9 +608,12 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
- process_stacktrace = getattr(module, "process_stacktrace")
- except AttributeError:
- pass
+ process_stacktrace = module.process_stacktrace
+ except (AttributeError, ImportError):
+ _LOGGER.info(
+ 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
+ CORE.target_platform,
+ )
backtrace_state = False
ser = serial.Serial()
@@ -622,7 +639,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
chunk = ser.read(ser.in_waiting or 1)
if not chunk:
continue
- time_ = datetime.now()
+ time_ = datetime.now().astimezone()
milliseconds = time_.microsecond // 1000
time_str = f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{milliseconds:03}]"
@@ -639,14 +656,10 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
)
safe_print(parser.parse_line(line, time_str))
- if process_stacktrace:
+ if process_stacktrace is not None:
backtrace_state = process_stacktrace(
config, line, backtrace_state
)
- else:
- backtrace_state = platformio_api.process_stacktrace(
- config, line, backtrace_state=backtrace_state
- )
except serial.SerialException:
_LOGGER.error("Serial port closed!")
return 0
@@ -660,7 +673,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
return 0
-def wrap_to_code(name, comp):
+def _wrap_to_code(name, comp, yaml_util):
coro = coroutine(comp.to_code)
@functools.wraps(comp.to_code)
@@ -679,33 +692,41 @@ def wrap_to_code(name, comp):
return wrapped
-def write_cpp(config: ConfigType, native_idf: bool = False) -> int:
+def write_cpp(config: ConfigType) -> int:
+ from esphome import writer
+
+ # Refresh the storage sidecar and clean an incompatible previous build
+ # before regenerating any sources. This may full-wipe the build dir, so it
+ # has to run before write_cpp_file writes src/.
+ writer.update_storage_json()
+
if not get_bool_env(ENV_NOGITIGNORE):
writer.write_gitignore()
- # Store native_idf flag so esp32 component can check it
- CORE.data[KEY_NATIVE_IDF] = native_idf
-
generate_cpp_contents(config)
- return write_cpp_file(native_idf=native_idf)
+ return write_cpp_file()
def generate_cpp_contents(config: ConfigType) -> None:
+ from esphome import yaml_util
+
_LOGGER.info("Generating C++ source...")
for name, component, conf in iter_component_configs(CORE.config):
if component.to_code is not None:
- coro = wrap_to_code(name, component)
+ coro = _wrap_to_code(name, component, yaml_util)
CORE.add_job(coro, conf)
CORE.flush_tasks()
-def write_cpp_file(native_idf: bool = False) -> int:
+def write_cpp_file() -> int:
+ from esphome import writer
+
code_s = indent(CORE.cpp_main_section)
writer.write_cpp(code_s)
- if native_idf and CORE.is_esp32 and CORE.target_framework == "esp-idf":
+ if CORE.using_toolchain_esp_idf:
from esphome.build_gen import espidf
espidf.write_project()
@@ -718,30 +739,41 @@ def write_cpp_file(native_idf: bool = False) -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
- native_idf = getattr(args, "native_idf", False)
+ # Keep this gate here, NOT in config validation: device-builder needs
+ # `esphome config` to keep succeeding with placeholders so onboarding can run.
+ if CONF_WIFI in config:
+ from esphome.components.wifi import check_placeholder_credentials
+
+ check_placeholder_credentials(config)
# 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)
- if native_idf and CORE.is_esp32 and CORE.target_framework == "esp-idf":
- from esphome import espidf_api
+ module = importlib.import_module("esphome.components." + CORE.target_platform)
+ platform_run_compile = getattr(module, "run_compile", None)
+ if platform_run_compile is not None and platform_run_compile(args, config):
+ pass
+ elif CORE.using_toolchain_esp_idf:
+ from esphome.espidf import toolchain
- rc = espidf_api.run_compile(config, CORE.verbose)
+ rc = toolchain.run_compile(config, CORE.verbose)
if rc != 0:
return rc
- # Create factory.bin and ota.bin
- espidf_api.create_factory_bin()
- espidf_api.create_ota_bin()
+ # Create factory.bin, ota.bin, and firmware.elf copy
+ toolchain.create_factory_bin()
+ toolchain.create_ota_bin()
+ toolchain.create_elf_copy()
+ toolchain.get_idedata()
else:
- from esphome import platformio_api
+ from esphome.platformio import toolchain
- rc = platformio_api.run_compile(config, CORE.verbose)
+ rc = toolchain.run_compile(config, CORE.verbose)
if rc != 0:
return rc
- idedata = platformio_api.get_idedata(config)
+ idedata = toolchain.get_idedata(config)
if idedata is None:
return 1
@@ -768,7 +800,7 @@ def _check_and_emit_build_info() -> None:
# Read build_info from JSON
try:
- with open(build_info_json_path, encoding="utf-8") as f:
+ with build_info_json_path.open(encoding="utf-8") as f:
build_info = json.load(f)
except (OSError, json.JSONDecodeError) as e:
_LOGGER.debug("Failed to read build_info: %s", e)
@@ -830,22 +862,26 @@ def _make_crystal_freq_callback(
def upload_using_esptool(
config: ConfigType, port: str, file: str, speed: int
) -> str | int:
- from esphome import platformio_api
-
first_baudrate = speed or config[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS].get(
"upload_speed", os.getenv("ESPHOME_UPLOAD_SPEED", "460800")
)
if file is not None:
- flash_images = [platformio_api.FlashImage(path=file, offset="0x0")]
+ flash_images = [FlashImage(path=file, offset="0x0")]
+ elif CORE.using_toolchain_esp_idf:
+ from esphome.espidf import toolchain
+
+ flash_images = [
+ FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
+ ]
else:
- idedata = platformio_api.get_idedata(config)
+ from esphome.platformio import toolchain
+
+ idedata = toolchain.get_idedata(config)
firmware_offset = "0x10000" if CORE.is_esp32 else "0x0"
flash_images = [
- platformio_api.FlashImage(
- path=idedata.firmware_bin_path, offset=firmware_offset
- ),
+ FlashImage(path=idedata.firmware_bin_path, offset=firmware_offset),
]
for image in idedata.extra_flash_images:
if not image.path.is_file():
@@ -914,13 +950,13 @@ def upload_using_esptool(
def upload_using_platformio(config: ConfigType, port: str) -> int:
- from esphome import platformio_api
+ from esphome.platformio import toolchain
# 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.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040:
- idedata = platformio_api.get_idedata(config)
+ idedata = toolchain.get_idedata(config)
build_dir = Path(idedata.firmware_elf_path).parent
firmware_bin = build_dir / "firmware.bin"
signed_bin = build_dir / "firmware.bin.signed"
@@ -930,15 +966,15 @@ def upload_using_platformio(config: ConfigType, port: str) -> int:
upload_args = ["-t", "upload", "-t", "nobuild"]
if port is not None:
upload_args += ["--upload-port", port]
- return platformio_api.run_platformio_cli_run(config, CORE.verbose, *upload_args)
+ return toolchain.run_platformio_cli_run(config, CORE.verbose, *upload_args)
def _find_picotool() -> Path | None:
"""Find the picotool binary from PlatformIO packages."""
- from esphome import platformio_api
+ from esphome.platformio import toolchain
try:
- idedata = platformio_api.get_idedata(CORE.config)
+ idedata = toolchain.get_idedata(CORE.config)
except Exception: # noqa: BLE001 # pylint: disable=broad-except
return None
return get_picotool_path(idedata.cc_path)
@@ -951,9 +987,9 @@ def upload_using_picotool(config: ConfigType) -> int:
the mass storage copy approach that causes "disk not ejected properly"
warnings on macOS.
"""
- from esphome import platformio_api
+ from esphome.platformio import toolchain
- idedata = platformio_api.get_idedata(config)
+ idedata = toolchain.get_idedata(config)
firmware_elf = Path(idedata.firmware_elf_path)
if not firmware_elf.is_file():
@@ -1026,7 +1062,7 @@ def _wait_for_serial_port(
def _port_found() -> bool:
if port is not None:
if os.name == "posix":
- return os.path.exists(port)
+ return Path(port).exists()
return any(p.path == port for p in get_serial_ports())
ports = get_serial_ports()
if known_ports is not None:
@@ -1071,13 +1107,29 @@ def upload_program(
host = devices[0]
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
- if getattr(module, "upload_program")(config, args, host):
+ if module.upload_program(config, args, host):
return 0, host
except AttributeError:
pass
port_type = get_port_type(host)
+ # MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by
+ # _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths.
+ is_partition_table = getattr(args, "partition_table", False)
+ is_bootloader = getattr(args, "bootloader", False)
+ if is_partition_table and is_bootloader:
+ raise EsphomeError(
+ "The options --partition-table and --bootloader can't be used together."
+ )
+ option_string = "--partition-table" if is_partition_table else "--bootloader"
+ if port_type in (PortType.SERIAL, PortType.BOOTSEL) and (
+ is_partition_table or is_bootloader
+ ):
+ raise EsphomeError(
+ f"The option {option_string} can only be used for Over The Air updates."
+ )
+
if port_type == PortType.BOOTSEL:
exit_code = upload_using_picotool(config)
# Return None for device - BOOTSEL can't be used for logging,
@@ -1097,36 +1149,230 @@ def upload_program(
return exit_code, host if exit_code == 0 else None
- ota_conf = {}
+ requested_platform = getattr(args, "ota_platform", None)
+ chosen_platform = _choose_ota_platform(config, requested_platform)
+
+ # Resolve MQTT magic strings to actual IP addresses
+ network_devices = _resolve_network_devices(devices, config, args)
+
+ if chosen_platform == CONF_WEB_SERVER:
+ if is_partition_table or is_bootloader:
+ raise EsphomeError(
+ f"{option_string} is only supported with the esphome OTA platform; "
+ "the web_server OTA path can only update the firmware image."
+ )
+ binary = CORE.firmware_bin
+ if getattr(args, "file", None) is not None:
+ binary = Path(args.file)
+ return _upload_via_web_server(config, network_devices, binary)
+
+ return _upload_via_native_api(config, network_devices, args)
+
+
+def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
+ """Pick the OTA platform to use, optionally honoring ``--ota-platform``.
+
+ Default behavior prefers ``esphome`` (native API) when it is configured.
+ The native API uses challenge-response auth with MD5/SHA256 hashing of a
+ server-issued nonce, so the password is never sent over the wire; the
+ ``web_server`` path uses HTTP Basic auth which transmits credentials in
+ cleartext over the LAN. (The native path also supports gzip compression
+ on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the
+ backend reports ``supports_compression() == false`` and the firmware is
+ sent uncompressed regardless of which platform is used.) Falls back to
+ ``web_server`` only when that is the only available platform.
+ """
+ # Use a dict (insertion-ordered) instead of a list so error messages and
+ # membership checks see one entry per platform even if the user has
+ # multiple ``ota:`` items of the same platform; the web_server OTA
+ # platform's final-validate hook merges duplicates anyway.
+ available: dict[str, None] = {}
for ota_item in config.get(CONF_OTA, []):
- if ota_item[CONF_PLATFORM] == CONF_ESPHOME:
+ platform = ota_item.get(CONF_PLATFORM)
+ if platform in (CONF_ESPHOME, CONF_WEB_SERVER):
+ available[platform] = None
+
+ if not available:
+ raise EsphomeError(
+ f"Cannot upload Over the Air as the {CONF_OTA} configuration is not "
+ f"present or does not include {CONF_PLATFORM}: {CONF_ESPHOME} or "
+ f"{CONF_PLATFORM}: {CONF_WEB_SERVER}"
+ )
+
+ if requested is not None:
+ if requested not in available:
+ raise EsphomeError(
+ f"--ota-platform {requested} was requested but the configuration "
+ f"only provides: {', '.join(available)}"
+ )
+ return requested
+
+ if CONF_ESPHOME in available:
+ return CONF_ESPHOME
+ return CONF_WEB_SERVER
+
+
+def _upload_via_native_api(
+ config: ConfigType, network_devices: list[str], args: ArgsProtocol
+) -> tuple[int, str | None]:
+ ota_conf: ConfigType = {}
+ for ota_item in config.get(CONF_OTA, []):
+ if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME:
ota_conf = ota_item
break
- if not ota_conf:
- raise EsphomeError(
- f"Cannot upload Over the Air as the {CONF_OTA} configuration is not present or does not include {CONF_PLATFORM}: {CONF_ESPHOME}"
- )
-
from esphome import espota2
remote_port = int(ota_conf[CONF_PORT])
password = ota_conf.get(CONF_PASSWORD)
+
+ def check_partition_access(option_string: str) -> None:
+ if not ota_conf.get("allow_partition_access"):
+ raise EsphomeError(
+ f"The option {option_string} requires 'allow_partition_access: true' on the "
+ "esphome OTA platform in the device's YAML configuration. Add it, recompile, "
+ f"flash a build with the option enabled, and then retry {option_string}."
+ )
+
+ binary = CORE.firmware_bin
+ ota_type = espota2.OTA_TYPE_UPDATE_APP
+ if getattr(args, "partition_table", False):
+ # Fail fast if the resolved ESPHome OTA config does not enable allow_partition_access.
+ # The device-side handshake also rejects this with "Device only supports app updates",
+ # but checking here surfaces the misconfiguration before opening a network connection.
+ check_partition_access("--partition-table")
+ binary = CORE.partition_table_bin
+ ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE
+ elif getattr(args, "bootloader", False):
+ check_partition_access("--bootloader")
+ binary = CORE.bootloader_bin
+ ota_type = espota2.OTA_TYPE_UPDATE_BOOTLOADER
if getattr(args, "file", None) is not None:
binary = Path(args.file)
- else:
- binary = CORE.firmware_bin
- # Resolve MQTT magic strings to actual IP addresses
- network_devices = _resolve_network_devices(devices, config, args)
+ if ota_type == espota2.OTA_TYPE_UPDATE_PARTITION_TABLE:
+ _validate_partition_table_binary(binary)
+ if ota_type == espota2.OTA_TYPE_UPDATE_BOOTLOADER:
+ _validate_bootloader_binary(binary)
- return espota2.run_ota(network_devices, remote_port, password, binary)
+ return espota2.run_ota(network_devices, remote_port, password, binary, ota_type)
+
+
+def _upload_via_web_server(
+ config: ConfigType, network_devices: list[str], binary: Path
+) -> tuple[int, str | None]:
+ web_conf = config.get(CONF_WEB_SERVER)
+ if not web_conf:
+ raise EsphomeError(
+ f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
+ f"is not configured."
+ )
+
+ remote_port = int(web_conf[CONF_PORT])
+ auth = web_conf.get(CONF_AUTH) or {}
+ username = auth.get(CONF_USERNAME)
+ password = auth.get(CONF_PASSWORD)
+
+ from esphome import web_server_ota
+
+ return web_server_ota.run_ota(
+ network_devices, remote_port, username, password, binary
+ )
+
+
+# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
+# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
+# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
+# trailing checksum entry. Padding past the last entry is 0xFF. The full table is
+# exactly ESP_PARTITION_TABLE_MAX_LEN bytes.
+_PARTITION_TABLE_MAX_LEN = 0xC00
+_ESP_PARTITION_MAGIC = 0x50AA
+_ESP_PARTITION_MAGIC_MD5 = 0xEBEB
+_ESP_IMAGE_HEADER_MAGIC = 0xE9
+
+
+def _validate_partition_table_binary(binary: Path) -> None:
+ """Validate that ``binary`` looks like an ESP32 partition table image.
+
+ Catches common mistakes (wrong file, truncated build output, swapped --file path)
+ before opening a network connection so the failure mode is a clear local error
+ instead of a post-handshake device rejection.
+ """
+ try:
+ data = binary.read_bytes()
+ except OSError as err:
+ raise EsphomeError(
+ f"Cannot read partition table file '{binary}': {err}"
+ ) from err
+
+ if len(data) != _PARTITION_TABLE_MAX_LEN:
+ raise EsphomeError(
+ f"Partition table file '{binary}' has wrong size: expected "
+ f"{_PARTITION_TABLE_MAX_LEN} bytes, got {len(data)}. "
+ "Pass the partition table image (e.g. partitions.bin / partition-table.bin), "
+ "not the firmware image."
+ )
+
+ first_magic = data[0] | (data[1] << 8)
+ if first_magic != _ESP_PARTITION_MAGIC:
+ raise EsphomeError(
+ f"Partition table file '{binary}' does not start with the expected "
+ f"partition magic 0x{_ESP_PARTITION_MAGIC:04X} (got 0x{first_magic:04X}). "
+ "This file does not look like an ESP32 partition table."
+ )
+
+ # The MD5 checksum entry is required: without it the device-side
+ # esp_partition_table_verify will accept the table but the bootloader will
+ # refuse to boot from it. Scan the 32-byte entries for the MD5 magic.
+ if not any(
+ (data[off] | (data[off + 1] << 8)) == _ESP_PARTITION_MAGIC_MD5
+ for off in range(0, _PARTITION_TABLE_MAX_LEN, 32)
+ ):
+ raise EsphomeError(
+ f"Partition table file '{binary}' is missing the MD5 checksum entry. "
+ "Regenerate the partition table with gen_esp32part.py or rebuild the project."
+ )
+
+
+def _validate_bootloader_binary(binary: Path) -> None:
+ """Validate that ``binary`` looks like an ESP32 bootloader image."""
+ try:
+ data = binary.read_bytes()
+ except OSError as err:
+ raise EsphomeError(f"Cannot read bootloader file '{binary}': {err}") from err
+
+ if not data:
+ raise EsphomeError(
+ f"Bootloader file '{binary}' is empty. "
+ "This file does not look like an ESP32 bootloader."
+ )
+
+ first_magic = data[0]
+ if first_magic != _ESP_IMAGE_HEADER_MAGIC:
+ raise EsphomeError(
+ f"Bootloader file '{binary}' does not start with the expected "
+ f"image header magic 0x{_ESP_IMAGE_HEADER_MAGIC:02X} (got 0x{first_magic:02X}). "
+ "This file does not look like an ESP32 bootloader."
+ )
+
+
+def _should_subscribe_states(args: ArgsProtocol) -> bool:
+ """Determine whether entity state changes should be shown in log output.
+
+ The ``--states``/``--no-states`` command line flags take precedence. When
+ neither is given, the ``ESPHOME_LOG_STATES`` environment variable controls
+ the behavior, defaulting to showing states.
+ """
+ states = getattr(args, "states", None)
+ if states is not None:
+ return states
+ return get_bool_env("ESPHOME_LOG_STATES", True)
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
- if getattr(module, "show_logs")(config, args, devices):
+ if module.show_logs(config, args, devices):
return 0
except AttributeError:
pass
@@ -1152,7 +1398,7 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
return run_logs(
config,
network_devices,
- subscribe_states=not getattr(args, "no_states", False),
+ subscribe_states=_should_subscribe_states(args),
)
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
@@ -1180,20 +1426,61 @@ def command_wizard(args: ArgsProtocol) -> int | None:
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
+ from esphome import yaml_util
+
if not CORE.verbose:
config = strip_default_ids(config)
output = yaml_util.dump(config, args.show_secrets)
- # add the console decoration so the front-end can hide the secrets
if not args.show_secrets:
- output = re.sub(
- r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output
- )
+ output = _redact_with_legacy_fallback(output)
if not CORE.quiet:
safe_print(output)
_LOGGER.info("Configuration is valid!")
return 0
+# Legacy substring redaction fallback for unmigrated schemas; removed in
+# 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips
+# values that already render themselves: ``\033[8m`` (SensitiveStr wrap),
+# ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line
+# block; first line is structural). The fragment must either start the
+# field name or follow ``_`` so the warning names a real field; this avoids
+# false positives like ``monkey:`` matching the ``key`` fragment.
+_LEGACY_REDACTION_RE = re.compile(
+ r"(?P\b(?:\w+_)?(?:password|key|psk|ssid))\: "
+ r"(?!\\033\[8m|!secret\b|!lambda\b)(?P.+)"
+)
+_LEGACY_REDACTION_REMOVAL = "2026.12.0"
+
+
+def _redact_with_legacy_fallback(output: str) -> str:
+ unmarked: set[str] = set()
+
+ def _replace(m: re.Match[str]) -> str:
+ unmarked.add(m.group("key"))
+ return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m"
+
+ output = _LEGACY_REDACTION_RE.sub(_replace, output)
+ for key in sorted(unmarked):
+ _LOGGER.warning(
+ "Field '%s' is being redacted by a legacy substring heuristic. "
+ "Mark this field's schema validator with cv.sensitive(...) for "
+ "deterministic redaction; the heuristic will be removed in %s.",
+ key,
+ _LEGACY_REDACTION_REMOVAL,
+ )
+ return output
+
+
+def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None:
+ # generating code might modify config, so it must be done in order to generate
+ # a hash that will match what was generated when compiling and then running
+ # on the device
+ generate_cpp_contents(config)
+ safe_print(f"0x{CORE.config_hash:08x}")
+ return 0
+
+
def command_vscode(args: ArgsProtocol) -> int | None:
from esphome import vscode
@@ -1203,8 +1490,7 @@ def command_vscode(args: ArgsProtocol) -> int | None:
def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
- native_idf = getattr(args, "native_idf", False)
- exit_code = write_cpp(config, native_idf=native_idf)
+ exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
if args.only_generate:
@@ -1214,9 +1500,14 @@ def command_compile(args: ArgsProtocol, config: ConfigType) -> int | None:
if exit_code != 0:
return exit_code
if CORE.is_host:
- from esphome.platformio_api import get_idedata
+ if CORE.using_toolchain_esp_idf:
+ from esphome.espidf import toolchain
- program_path = str(get_idedata(config).firmware_elf_path)
+ program_path = str(toolchain.get_elf_path())
+ else:
+ from esphome.platformio.toolchain import get_idedata
+
+ program_path = str(get_idedata(config).firmware_elf_path)
_LOGGER.info("Successfully compiled program to path '%s'", program_path)
else:
_LOGGER.info("Successfully compiled program.")
@@ -1259,8 +1550,7 @@ def command_logs(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
- native_idf = getattr(args, "native_idf", False)
- exit_code = write_cpp(config, native_idf=native_idf)
+ exit_code = write_cpp(config)
if exit_code != 0:
return exit_code
exit_code = compile_program(args, config)
@@ -1268,9 +1558,14 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
return exit_code
_LOGGER.info("Successfully compiled program.")
if CORE.is_host:
- from esphome.platformio_api import get_idedata
+ if CORE.using_toolchain_esp_idf:
+ from esphome.espidf import toolchain
- program_path = str(get_idedata(config).firmware_elf_path)
+ program_path = str(toolchain.get_elf_path())
+ else:
+ from esphome.platformio.toolchain import get_idedata
+
+ program_path = str(get_idedata(config).firmware_elf_path)
_LOGGER.info("Running program from path '%s'", program_path)
return run_external_process(program_path)
@@ -1321,6 +1616,8 @@ def command_clean_mqtt(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_clean_all(args: ArgsProtocol) -> int | None:
+ from esphome import writer
+
try:
writer.clean_all(args.configuration)
except OSError as err:
@@ -1336,8 +1633,10 @@ def command_version(args: ArgsProtocol) -> int | None:
def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
+ from esphome import writer
+
try:
- writer.clean_build()
+ writer.clean_build(full=True)
except OSError as err:
_LOGGER.error("Error deleting build files: %s", err)
return 1
@@ -1457,12 +1756,19 @@ def command_update_all(args: ArgsProtocol) -> int | None:
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json
- from esphome import platformio_api
+ if not CORE.using_toolchain_platformio:
+ _LOGGER.error(
+ "The idedata command is not compatible with %s toolchain",
+ CORE.toolchain.value,
+ )
+ return 1
+
+ from esphome.platformio import toolchain
logging.disable(logging.INFO)
logging.disable(logging.WARNING)
- idedata = platformio_api.get_idedata(config)
+ idedata = toolchain.get_idedata(config)
if idedata is None:
return 1
@@ -1476,7 +1782,6 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
This command compiles the configuration and performs memory analysis.
Compilation is fast if sources haven't changed (just relinking).
"""
- from esphome import platformio_api
from esphome.analyze_memory.cli import MemoryAnalyzerCLI
from esphome.analyze_memory.ram_strings import RamStringsAnalyzer
@@ -1490,12 +1795,25 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
_LOGGER.info("Successfully compiled program.")
# Get idedata for analysis
- idedata = platformio_api.get_idedata(config)
- if idedata is None:
- _LOGGER.error("Failed to get IDE data for memory analysis")
- return 1
+ idedata = None
+ if CORE.using_toolchain_esp_idf:
+ from esphome.espidf import toolchain
- firmware_elf = Path(idedata.firmware_elf_path)
+ objdump_path = str(toolchain.get_objdump_path())
+ readelf_path = str(toolchain.get_readelf_path())
+
+ firmware_elf = toolchain.get_elf_path()
+ else:
+ from esphome.platformio import toolchain
+
+ idedata = toolchain.get_idedata(config)
+ if idedata is None:
+ _LOGGER.error("Failed to get IDE data for memory analysis")
+ return 1
+ objdump_path = idedata.objdump_path
+ readelf_path = idedata.readelf_path
+
+ firmware_elf = Path(idedata.firmware_elf_path)
# Extract external components from config
external_components = detect_external_components(config)
@@ -1505,8 +1823,8 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
_LOGGER.info("Analyzing memory usage...")
analyzer = MemoryAnalyzerCLI(
str(firmware_elf),
- idedata.objdump_path,
- idedata.readelf_path,
+ objdump_path,
+ readelf_path,
external_components,
idedata=idedata,
)
@@ -1522,7 +1840,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
try:
ram_analyzer = RamStringsAnalyzer(
str(firmware_elf),
- objdump_path=idedata.objdump_path,
+ objdump_path=objdump_path,
platform=CORE.target_platform,
)
ram_analyzer.analyze()
@@ -1531,13 +1849,15 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int:
ram_report = ram_analyzer.generate_report()
print()
print(ram_report)
- except Exception as e: # pylint: disable=broad-except
+ except Exception as e: # noqa: BLE001 # pylint: disable=broad-except
_LOGGER.warning("RAM strings analysis failed: %s", e)
return 0
def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
+ from esphome import yaml_util
+
new_name = args.name
for c in new_name:
if c not in ALLOWED_NAME_CHARS:
@@ -1563,11 +1883,32 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
old_name = yaml[CONF_ESPHOME][CONF_NAME]
match = re.match(r"^\$\{?([a-zA-Z0-9_]+)\}?$", old_name)
if match is None:
- new_raw = re.sub(
- rf"name:\s+[\"']?{old_name}[\"']?",
- f'name: "{new_name}"',
- raw_contents,
+ # Only swap the ``name:`` line that sits directly under the
+ # top-level ``esphome:`` block. A naked ``re.sub`` would
+ # also clobber any other ``name:`` line whose value happens
+ # to match (e.g. a sensor / output / wifi entry sharing the
+ # device's hostname), silently rewriting unrelated user
+ # configuration. The pattern anchors:
+ # - at the start of the line so ``friendly_name:``,
+ # ``device_name:`` etc. don't match the trailing ``name:``
+ # substring; and
+ # - at the end of the value (lookahead for whitespace +
+ # comment + EOL) so ``old_name`` doesn't match as a
+ # prefix of a longer value (``kitchen`` vs ``kitchen2``).
+ name_pattern = re.compile(
+ rf"^(\s*)name:\s+[\"']?{re.escape(old_name)}[\"']?(?=\s*(?:#|$))"
)
+ out_lines: list[str] = []
+ in_esphome_block = False
+ for line in raw_contents.splitlines(keepends=True):
+ if line and not line[0].isspace() and line.strip():
+ in_esphome_block = line.lstrip().startswith("esphome:")
+ out_lines.append(line)
+ continue
+ if in_esphome_block:
+ line = name_pattern.sub(rf'\1name: "{new_name}"', line, count=1)
+ out_lines.append(line)
+ new_raw = "".join(out_lines)
else:
old_name = yaml[CONF_SUBSTITUTIONS][match.group(1)]
if (
@@ -1590,7 +1931,40 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
flags=re.MULTILINE,
)
+ # ``new_name == old_name`` (after substitution resolution) is
+ # a no-op rewrite that would still queue a pointless re-flash.
+ # Catch it before the path-equality check below — covers the
+ # case where the config filename doesn't match the device name
+ # (e.g. ``weird-file.yaml`` whose ``esphome.name`` is
+ # ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
+ # would otherwise just re-flash the same hostname).
+ if new_name == old_name:
+ print(
+ color(
+ AnsiFore.BOLD_RED,
+ f"'{new_name}' is already the device's name.",
+ )
+ )
+ return 1
+
new_path: Path = CORE.config_dir / (new_name + ".yaml")
+ if new_path.resolve() == CORE.config_path.resolve():
+ print(
+ color(
+ AnsiFore.BOLD_RED,
+ f"'{new_name}' is already the device's name.",
+ )
+ )
+ return 1
+ if new_path.exists():
+ print(
+ color(
+ AnsiFore.BOLD_RED,
+ f"Cannot rename: {new_path} already exists. "
+ "Refusing to overwrite an existing configuration.",
+ )
+ )
+ return 1
print(
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
)
@@ -1642,6 +2016,7 @@ PRE_CONFIG_ACTIONS = {
POST_CONFIG_ACTIONS = {
"config": command_config,
+ "config-hash": command_config_hash,
"compile": command_compile,
"upload": command_upload,
"logs": command_logs,
@@ -1662,6 +2037,29 @@ SIMPLE_CONFIG_ACTIONS = [
]
+def _add_states_args(parser: argparse.ArgumentParser) -> None:
+ """Add mutually exclusive ``--states``/``--no-states`` flags to a parser.
+
+ When neither flag is given, the ``ESPHOME_LOG_STATES`` environment variable
+ controls whether entity state changes are shown (defaulting to showing them).
+ """
+ states_group = parser.add_mutually_exclusive_group()
+ states_group.add_argument(
+ "--states",
+ dest="states",
+ action="store_true",
+ default=None,
+ help="Show entity state changes in log output (overrides ESPHOME_LOG_STATES).",
+ )
+ states_group.add_argument(
+ "--no-states",
+ dest="states",
+ action="store_false",
+ default=None,
+ help="Do not show entity state changes in log output.",
+ )
+
+
def parse_args(argv):
options_parser = argparse.ArgumentParser(add_help=False)
options_parser.add_argument(
@@ -1711,6 +2109,17 @@ def parse_args(argv):
action="store_true",
default=False,
)
+ options_parser.add_argument(
+ "--toolchain",
+ type=Toolchain,
+ default=None,
+ choices=list(Toolchain),
+ metavar="{" + ",".join(t.value for t in Toolchain) + "}",
+ help=(
+ "Select toolchain for compiling. Overrides '.toolchain' in YAML. "
+ f"Default: {Toolchain.PLATFORMIO.value}."
+ ),
+ )
parser = argparse.ArgumentParser(
description=f"ESPHome {const.__version__}", parents=[options_parser]
@@ -1744,6 +2153,13 @@ def parse_args(argv):
"--show-secrets", help="Show secrets in output.", action="store_true"
)
+ parser_config_hash = subparsers.add_parser(
+ "config-hash", help="Calculate the hash of the configuration."
+ )
+ parser_config_hash.add_argument(
+ "configuration", help="Your YAML configuration file(s).", nargs="+"
+ )
+
parser_compile = subparsers.add_parser(
"compile", help="Read the configuration and compile a program."
)
@@ -1755,11 +2171,6 @@ def parse_args(argv):
help="Only generate source code, do not compile.",
action="store_true",
)
- parser_compile.add_argument(
- "--native-idf",
- help="Build with native ESP-IDF instead of PlatformIO (ESP32 esp-idf framework only).",
- action="store_true",
- )
parser_upload = subparsers.add_parser(
"upload",
@@ -1782,6 +2193,27 @@ def parse_args(argv):
"--file",
help="Manually specify the binary file to upload.",
)
+ parser_upload.add_argument(
+ "--ota-platform",
+ choices=[CONF_ESPHOME, CONF_WEB_SERVER],
+ help=(
+ "OTA platform to use for network uploads. Defaults to "
+ f"'{CONF_ESPHOME}' (native API) when configured because it uses "
+ "challenge-response auth so the password is never sent in "
+ f"cleartext on the wire. Falls back to '{CONF_WEB_SERVER}' "
+ "(HTTP Basic auth) when that is the only configured platform."
+ ),
+ )
+ parser_upload.add_argument(
+ "--partition-table",
+ help="Upload as partition table (OTA).",
+ action="store_true",
+ )
+ parser_upload.add_argument(
+ "--bootloader",
+ help="Upload as bootloader (OTA).",
+ action="store_true",
+ )
parser_logs = subparsers.add_parser(
"logs",
@@ -1804,11 +2236,7 @@ def parse_args(argv):
help="Reset the device before starting serial logs.",
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
)
- parser_logs.add_argument(
- "--no-states",
- action="store_true",
- help="Do not show entity state changes in log output.",
- )
+ _add_states_args(parser_logs)
parser_discover = subparsers.add_parser(
"discover",
@@ -1839,6 +2267,9 @@ def parse_args(argv):
parser_run.add_argument(
"--no-logs", help="Disable starting logs.", action="store_true"
)
+
+ _add_states_args(parser_run)
+
parser_run.add_argument(
"--reset",
"-r",
@@ -1847,9 +2278,15 @@ def parse_args(argv):
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
)
parser_run.add_argument(
- "--native-idf",
- help="Build with native ESP-IDF instead of PlatformIO (ESP32 esp-idf framework only).",
- action="store_true",
+ "--ota-platform",
+ choices=[CONF_ESPHOME, CONF_WEB_SERVER],
+ help=(
+ "OTA platform to use for network uploads. Defaults to "
+ f"'{CONF_ESPHOME}' (native API) when configured because it uses "
+ "challenge-response auth so the password is never sent in "
+ f"cleartext on the wire. Falls back to '{CONF_WEB_SERVER}' "
+ "(HTTP Basic auth) when that is the only configured platform."
+ ),
)
parser_clean = subparsers.add_parser(
@@ -2062,17 +2499,58 @@ def run_esphome(argv):
CORE.config_path = conf_path
CORE.dashboard = args.dashboard
+ if args.toolchain is not None:
+ # CLI toolchain wins over esp32.toolchain in YAML.
+ CORE.toolchain = args.toolchain
- # For logs command, skip updating external components
- skip_external = args.command == "logs"
- config = read_config(
- dict(args.substitution) if args.substitution else {},
- skip_external_update=skip_external,
+ # Commands that don't need fresh external components: logs just connects
+ # to the device, and clean is about to delete the build directory.
+ skip_external = args.command in ("logs", "clean")
+ command_line_substitutions = dict(args.substitution) if args.substitution else {}
+
+ # Fast path for upload/logs: reuse the validated-config cache the
+ # last compile wrote. Falls back to read_config when missing/stale.
+ # Skipped when -s overrides are passed, since the cache was written
+ # against the previous substitution set.
+ config: ConfigType | None = None
+ cache_eligible = (
+ args.command in ("upload", "logs") and not command_line_substitutions
)
+ if cache_eligible:
+ from esphome.compiled_config import load_compiled_config
+
+ config = load_compiled_config(conf_path)
+ if config is not None:
+ _LOGGER.info(
+ "Loaded validated config cache for %s, skipping validation.",
+ conf_path.name,
+ )
+
+ if config is None:
+ config = read_config(
+ command_line_substitutions,
+ skip_external_update=skip_external,
+ )
+ # Refresh the cache so the next upload/logs hits the fast path
+ # instead of re-running read_config. Skip when the storage
+ # sidecar is absent (no compile has run): the cache would
+ # never be loaded back, so writing secrets to disk is wasted.
+ if cache_eligible and config is not None:
+ from esphome.compiled_config import save_compiled_config
+ from esphome.storage_json import ext_storage_path
+
+ if ext_storage_path(conf_path.name).exists():
+ save_compiled_config(config)
if config is None:
return 2
CORE.config = config
+ # Fallback for platforms whose validators didn't set the toolchain
+ # (only the esp32 component reads esp32.framework.toolchain). All
+ # other platforms only support PlatformIO today.
+ if CORE.toolchain is None:
+ CORE.toolchain = Toolchain.PLATFORMIO
+
if args.command not in POST_CONFIG_ACTIONS:
safe_print(f"Unknown command {args.command}")
return 1
diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py
index f56d720ec..119856221 100644
--- a/esphome/analyze_memory/__init__.py
+++ b/esphome/analyze_memory/__init__.py
@@ -24,7 +24,7 @@ from .helpers import (
from .toolchain import find_tool, resolve_tool_path, run_tool
if TYPE_CHECKING:
- from esphome.platformio_api import IDEData
+ from esphome.platformio.toolchain import IDEData
_LOGGER = logging.getLogger(__name__)
@@ -793,8 +793,11 @@ class MemoryAnalyzer:
"""Scan ESPHome source object files to map extern "C" symbols to components.
When no linker map file is available, this uses ``nm`` to scan ``.o`` files
- under ``src/esphome/`` and build a symbol-to-component mapping. This catches
- ``extern "C"`` functions and other symbols that lack C++ namespace prefixes.
+ under ``src/`` (including ``src/main.cpp.o`` and everything beneath
+ ``src/esphome/``) and build a symbol-to-component mapping. This catches
+ ``extern "C"`` functions, the ESPHome-generated ``setup()``/``loop()``
+ entry points in ``main.cpp``, and other symbols that lack C++ namespace
+ prefixes.
Skips scanning if ``_source_symbol_map`` was already populated by
``_parse_map_file()``.
@@ -806,12 +809,12 @@ class MemoryAnalyzer:
if obj_dir is None:
return
- # Find ESPHome source object files
- esphome_src_dir = obj_dir / "src" / "esphome"
- if not esphome_src_dir.is_dir():
+ # Scan all ESPHome-owned source object files: src/main.cpp.o and src/esphome/...
+ src_dir = obj_dir / "src"
+ if not src_dir.is_dir():
return
- obj_files = sorted(esphome_src_dir.rglob("*.o"))
+ obj_files = sorted(src_dir.rglob("*.o"))
if not obj_files:
return
@@ -1064,6 +1067,10 @@ class MemoryAnalyzer:
if component_name in self.external_components:
return f"{_COMPONENT_PREFIX_EXTERNAL}{component_name}"
+ # ESPHome-generated entry point: src/main.cpp.o (contains setup()/loop())
+ if len(parts) >= 2 and parts[-2:] == ("src", "main.cpp.o"):
+ return _COMPONENT_CORE
+
# ESPHome core: src/esphome/core/... or src/esphome/...
if "core" in parts and "esphome" in parts:
return _COMPONENT_CORE
diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py
index b7561e8ff..4fbceb7e5 100644
--- a/esphome/analyze_memory/cli.py
+++ b/esphome/analyze_memory/cli.py
@@ -6,6 +6,7 @@ from collections import defaultdict
from collections.abc import Callable
import heapq
from operator import itemgetter
+from pathlib import Path
import sys
from typing import TYPE_CHECKING
@@ -509,7 +510,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(
f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):"
)
- for i, (symbol, demangled, size) in enumerate(large_core_symbols):
+ for i, (_symbol, demangled, size) in enumerate(large_core_symbols):
# Core symbols only track (symbol, demangled, size) without section info,
# so we don't show section labels here
lines.append(
@@ -601,7 +602,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(
f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):"
)
- for i, (symbol, demangled, size, section) in enumerate(large_symbols):
+ for i, (_symbol, demangled, size, section) in enumerate(large_symbols):
lines.append(
f"{i + 1}. {self._format_symbol_with_section(demangled, size, section)}"
)
@@ -640,7 +641,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(
f" Symbols > {self.RAM_SYMBOL_SIZE_THRESHOLD} B ({len(large_ram_syms)}):"
)
- for symbol, demangled, size, section in large_ram_syms[:10]:
+ for _symbol, demangled, size, section in large_ram_syms[:10]:
# Format section label consistently by stripping leading dot
section_label = section.lstrip(".") if section else ""
display_name = _format_pstorage_name(demangled)
@@ -699,7 +700,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
content = "\n".join(lines)
if output_file:
- with open(output_file, "w", encoding="utf-8") as f:
+ with Path(output_file).open("w", encoding="utf-8") as f:
f.write(content)
else:
print(content)
@@ -737,9 +738,8 @@ def main():
# Load build directory
import json
- from pathlib import Path
- from esphome.platformio_api import IDEData
+ from esphome.platformio.toolchain import IDEData
build_path = Path(build_dir)
@@ -785,7 +785,7 @@ def main():
if not idedata_path.exists():
continue
try:
- with open(idedata_path, encoding="utf-8") as f:
+ 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)
diff --git a/esphome/analyze_memory/demangle.py b/esphome/analyze_memory/demangle.py
index 8999108b5..7dbd6d4f6 100644
--- a/esphome/analyze_memory/demangle.py
+++ b/esphome/analyze_memory/demangle.py
@@ -154,7 +154,7 @@ def batch_demangle(
failed_count = 0
for original, stripped, prefix, demangled in zip(
- symbols, symbols_stripped, symbols_prefixes, demangled_lines
+ symbols, symbols_stripped, symbols_prefixes, demangled_lines, strict=True
):
# Add back any prefix that was removed
demangled = _restore_symbol_prefix(prefix, stripped, demangled)
diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py
index 3a8a5f7be..a724d52f2 100644
--- a/esphome/analyze_memory/toolchain.py
+++ b/esphome/analyze_memory/toolchain.py
@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
-import os
from pathlib import Path
import subprocess
from typing import TYPE_CHECKING
@@ -37,7 +36,7 @@ def _find_in_platformio_packages(tool_name: str) -> str | None:
Full path to the tool or None if not found
"""
# Get PlatformIO packages directory
- platformio_home = Path(os.path.expanduser("~/.platformio/packages"))
+ platformio_home = Path("~/.platformio/packages").expanduser()
if not platformio_home.exists():
return None
diff --git a/esphome/async_thread.py b/esphome/async_thread.py
index 7be3c83a9..c5225a7a1 100644
--- a/esphome/async_thread.py
+++ b/esphome/async_thread.py
@@ -45,7 +45,7 @@ class AsyncThreadRunner(threading.Thread, Generic[_T]):
async def _runner(self) -> None:
try:
self.result = await self._coro_factory()
- except Exception as exc: # pylint: disable=broad-except
+ except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
# Capture all exceptions so ``event`` is always set — otherwise a
# crash would hang the waiter forever.
self.exception = exc
diff --git a/esphome/automation.py b/esphome/automation.py
index 97d9a0a47..1689d29c4 100644
--- a/esphome/automation.py
+++ b/esphome/automation.py
@@ -127,7 +127,7 @@ def validate_potentially_or_condition(value):
return validate_condition(value)
-DelayAction = cg.esphome_ns.class_("DelayAction", Action, cg.Component)
+DelayAction = cg.esphome_ns.class_("DelayAction", Action)
LambdaAction = cg.esphome_ns.class_("LambdaAction", Action)
StatelessLambdaAction = cg.esphome_ns.class_("StatelessLambdaAction", Action)
IfAction = cg.esphome_ns.class_("IfAction", Action)
@@ -396,7 +396,6 @@ async def delay_action_to_code(
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
- await cg.register_component(var, {})
template_ = await cg.templatable(config, args, cg.uint32)
cg.add(var.set_delay(template_))
return var
@@ -597,7 +596,7 @@ async def component_resume_action_to_code(
comp = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, comp)
if CONF_UPDATE_INTERVAL in config:
- template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, int)
+ template_ = await cg.templatable(config[CONF_UPDATE_INTERVAL], args, cg.uint32)
cg.add(var.set_update_interval(template_))
return var
diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py
index 01923baaa..9cc7a7ff1 100644
--- a/esphome/build_gen/espidf.py
+++ b/esphome/build_gen/espidf.py
@@ -3,23 +3,27 @@
import json
from pathlib import Path
-from esphome.components.esp32 import get_esp32_variant
+from esphome.components.esp32 import get_esp32_variant, idf_version
+import esphome.config_validation as cv
from esphome.core import CORE
from esphome.helpers import mkdir_p, write_file_if_changed
def get_available_components() -> list[str] | None:
- """Get list of available ESP-IDF components from project_description.json.
+ """Get list of built-in ESP-IDF components from project_description.json.
- Returns only internal ESP-IDF components, excluding external/managed
- components (from idf_component.yml).
+ Excludes ``src``, IDF-managed components (``managed_components/``), and
+ converted PIO libs (``pio_components/``). Returns ``None`` if the build
+ dir or ``project_description.json`` isn't ready yet.
"""
+ if CORE.build_path is None:
+ return None
project_desc = Path(CORE.build_path) / "build" / "project_description.json"
if not project_desc.exists():
return None
try:
- with open(project_desc, encoding="utf-8") as f:
+ with project_desc.open(encoding="utf-8") as f:
data = json.load(f)
component_info = data.get("build_component_info", {})
@@ -30,9 +34,9 @@ def get_available_components() -> list[str] | None:
if name == "src":
continue
- # Exclude managed/external components
+ # Exclude IDF-managed and converted-PIO components (external).
comp_dir = info.get("dir", "")
- if "managed_components" in comp_dir:
+ if "managed_components" in comp_dir or "pio_components" in comp_dir:
continue
result.append(name)
@@ -47,23 +51,90 @@ def has_discovered_components() -> bool:
return get_available_components() is not None
-def get_project_cmakelists() -> str:
- """Generate the top-level CMakeLists.txt for ESP-IDF project."""
+def get_project_cmakelists(minimal: bool = False) -> str:
+ """Generate the top-level CMakeLists.txt for ESP-IDF project.
+
+ When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
+ since ``project_description.json`` may be stale on the first write.
+ """
# Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
variant = get_esp32_variant()
idf_target = variant.lower().replace("-", "")
- # Extract compile definitions from build flags (-DXXX -> XXX)
- compile_defs = [flag for flag in CORE.build_flags if flag.startswith("-D")]
+ # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
+ # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
+ # --format=raw because the legacy mode doesn't support it.
+ size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
+
+ # Project-wide compile options: -D defines and -W warning flags (skip
+ # -Wl, linker flags — those go on the src component via
+ # target_link_options below). Emitted via idf_build_set_property so the
+ # flags propagate to every IDF component (including managed ones like
+ # esphome__micro-mp3) rather than just src/. Required so suppressions
+ # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
+ # third-party components we don't author.
+ project_compile_opts = [
+ flag
+ for flag in sorted(CORE.build_flags)
+ if flag.startswith("-D")
+ or (flag.startswith("-W") and not flag.startswith("-Wl,"))
+ ]
extra_compile_options = "\n".join(
- f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)'
- for compile_def in compile_defs
+ f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
+ for flag in project_compile_opts
+ )
+
+ # Per-project list exposed as a CMake variable so converted PIO libs
+ # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
+ # project-specific names into their cached CMakeLists.
+ #
+ # Emit via idf_build_set_property (not plain set()) so the value is
+ # serialised into build_properties.temp.cmake and visible to IDF's
+ # early requirements-expansion pass (component_get_requirements.cmake
+ # runs as a separate CMake script invocation that doesn't load the
+ # project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
+ # MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
+ from esphome.components.esp32 import get_managed_component_require_names
+
+ managed_components_property = "\n".join(
+ f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
+ for name in get_managed_component_require_names()
+ )
+
+ # Built-in IDF components exposed via our own property (not IDF's
+ # __COMPONENT_REQUIRES_COMMON, which would append them to every
+ # component's REQUIRES including real IDF components). Referenced by
+ # src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
+ # on minimal writes because project_description.json may be stale.
+ builtin_components_property = (
+ ""
+ if minimal
+ else "\n".join(
+ f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
+ for name in sorted(get_available_components() or [])
+ )
)
return f"""\
# Auto-generated by ESPHome
cmake_minimum_required(VERSION 3.16)
+# On Windows, Ninja can fail with:
+# "CreateProcess: The parameter is incorrect (is the command line too long?)"
+# when compiler/linker command lines exceed the OS length limit.
+#
+# The following settings force CMake/Ninja to use *response files* (@file.rsp)
+# to pass long lists of includes, objects, and other arguments indirectly,
+# avoiding command-line length limits and fixing the build failure.
+#
+# This is especially useful for large ESP-IDF / ESPHome projects with many
+# source files or include directories.
+set(CMAKE_C_USE_RESPONSE_FILE_FOR_INCLUDES 1)
+set(CMAKE_CXX_USE_RESPONSE_FILE_FOR_INCLUDES 1)
+set(CMAKE_C_USE_RESPONSE_FILE_FOR_OBJECTS 1)
+set(CMAKE_CXX_USE_RESPONSE_FILE_FOR_OBJECTS 1)
+set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1)
+
set(IDF_TARGET {idf_target})
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
@@ -71,50 +142,67 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{extra_compile_options}
+{managed_components_property}
+
+{builtin_components_property}
+
project({CORE.name})
+
+# Emit raw JSON size data for ESPHome to read post-build.
+add_custom_command(
+ TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
+ COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
+ -o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
+ ${{CMAKE_PROJECT_NAME}}.map
+ WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
+ VERBATIM
+)
"""
-def get_component_cmakelists(minimal: bool = False) -> str:
- """Generate the main component CMakeLists.txt."""
- idf_requires = [] if minimal else (get_available_components() or [])
- requires_str = " ".join(idf_requires)
+def get_component_cmakelists() -> str:
+ """Generate the main component CMakeLists.txt.
- # Extract compile options (-W flags, excluding linker flags)
- compile_opts = [
- flag
- for flag in CORE.build_flags
- if flag.startswith("-W") and not flag.startswith("-Wl,")
- ]
- compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else ""
-
- # Extract linker options (-Wl, flags)
+ REQUIRES pulls in the discovered built-in IDF components via the
+ project-level variables set in the top-level CMakeLists.
+ """
+ # Extract linker options (-Wl, flags). Compile flags (-D, -W) are
+ # emitted project-wide via idf_build_set_property in
+ # get_project_cmakelists so they reach every component, not just src/.
link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")]
link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else ""
return f"""\
# Auto-generated by ESPHome
-file(GLOB_RECURSE app_sources
- "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
- "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
- "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
- "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
-)
+# CONFIGURE_DEPENDS asks CMake to re-check the glob each build so test
+# runs that reuse the build dir don't compile stale source paths. It's
+# invalid in script mode (cmake -P), which is how IDF's
+# component_get_requirements.cmake includes us, so skip it there.
+if(CMAKE_SCRIPT_MODE_FILE)
+ file(GLOB_RECURSE app_sources
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
+ )
+else()
+ file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
+ )
+endif()
idf_component_register(
SRCS ${{app_sources}}
INCLUDE_DIRS "." "esphome"
- REQUIRES {requires_str}
+ REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
)
# Apply C++ standard
target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
-# ESPHome compile options
-target_compile_options(${{COMPONENT_LIB}} PUBLIC
- {compile_opts_str}
-)
-
# ESPHome linker options
target_link_options(${{COMPONENT_LIB}} PUBLIC
{link_opts_str}
@@ -130,11 +218,11 @@ def write_project(minimal: bool = False) -> None:
# Write top-level CMakeLists.txt
write_file_if_changed(
CORE.relative_build_path("CMakeLists.txt"),
- get_project_cmakelists(),
+ get_project_cmakelists(minimal=minimal),
)
# Write component CMakeLists.txt in src/
write_file_if_changed(
CORE.relative_src_path("CMakeLists.txt"),
- get_component_cmakelists(minimal=minimal),
+ get_component_cmakelists(),
)
diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py
index 30dbb69d8..16c1597cc 100644
--- a/esphome/build_gen/platformio.py
+++ b/esphome/build_gen/platformio.py
@@ -1,7 +1,7 @@
from esphome.const import __version__
from esphome.core import CORE
from esphome.helpers import mkdir_p, read_file, write_file_if_changed
-from esphome.writer import find_begin_end, update_storage_json
+from esphome.writer import find_begin_end
INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ==========="
INI_AUTO_GENERATE_END = "; =========== AUTO GENERATED CODE END ============"
@@ -58,7 +58,6 @@ def get_ini_content():
def write_ini(content):
- update_storage_json()
path = CORE.relative_build_path("platformio.ini")
if path.is_file():
diff --git a/esphome/bundle.py b/esphome/bundle.py
index efa80acc8..d38f68ebf 100644
--- a/esphome/bundle.py
+++ b/esphome/bundle.py
@@ -98,11 +98,13 @@ _KNOWN_FILE_EXTENSIONS = frozenset(
)
-# Matches !secret references in YAML text. This is intentionally a simple
-# regex scan rather than a YAML parse — it may match inside comments or
-# multi-line strings, which is the conservative direction (include more
-# secrets rather than fewer).
-_SECRET_RE = re.compile(r"!secret\s+(\S+)")
+# Matches !secret references in YAML text. An optional surrounding
+# quote pair around the key is allowed and ignored: YAML treats
+# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is
+# intentionally a simple regex scan rather than a YAML parse — it may
+# match inside comments or multi-line strings, which is the conservative
+# direction (include more secrets rather than fewer).
+_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""")
def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
@@ -258,42 +260,20 @@ class ConfigBundleCreator:
def _discover_yaml_includes(self) -> None:
"""Discover YAML files loaded during config parsing.
- Deliberately uses a fresh re-parse and force-loads every deferred
- ``IncludeFile`` to include *all* potentially-reachable includes,
- even branches not selected by the local substitutions. Bundles are
- meant to be compiled on another system where command-line
- substitution overrides may choose a different branch — e.g.
- ``!include network/${eth_model}/config.yaml`` must ship every
- candidate so the remote build can pick any one.
-
- Entries with unresolved substitution variables in the filename
- path are skipped with a warning (they cannot be resolved without
- the substitution pass).
-
- Secrets files are tracked separately so we can filter them to
- only include the keys this config actually references.
+ Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a
+ fresh re-parse and force-loads every deferred ``IncludeFile`` so that
+ *all* potentially-reachable includes are captured (even branches not
+ selected by local substitutions). Bundles are meant to be compiled on
+ another system where command-line substitution overrides may choose a
+ different branch — e.g. ``!include network/${eth_model}/config.yaml``
+ must ship every candidate so the remote build can pick any one.
"""
- # Must be a fresh parse: IncludeFile.load() caches its result in
- # _content, and we discover files by listening for loader calls. On
- # an already-parsed tree the cache is populated, .load() returns
- # without calling the loader, the listener never fires, and the
- # referenced files would be silently dropped from the bundle.
- with yaml_util.track_yaml_loads() as loaded_files:
- try:
- data = yaml_util.load_yaml(self._config_path)
- except EsphomeError:
- _LOGGER.debug(
- "Bundle: re-loading YAML for include discovery failed, "
- "proceeding with partial file list"
- )
- else:
- _force_load_include_files(data)
-
- for fpath in loaded_files:
- if fpath == self._config_path.resolve():
+ discovered = yaml_util.discover_user_yaml_files(self._config_path)
+ self._secrets_paths.update(discovered.secrets)
+ config_resolved = self._config_path.resolve()
+ for fpath in discovered.files:
+ if fpath == config_resolved:
continue # Already added as config
- if fpath.name in const.SECRETS_FILES:
- self._secrets_paths.add(fpath)
self._add_file(fpath)
def _discover_component_files(self) -> None:
@@ -432,7 +412,7 @@ class ConfigBundleCreator:
@staticmethod
def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None:
"""Add a BundleFile to the tar archive with deterministic metadata."""
- with open(bf.source, "rb") as f:
+ with bf.source.open("rb") as f:
_add_bytes_to_tar(tar, bf.path, f.read())
@@ -623,57 +603,6 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
tar.addfile(info, io.BytesIO(data))
-def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
- """Recursively resolve any ``IncludeFile`` instances in a YAML tree.
-
- Nested ``!include`` returns a deferred ``IncludeFile`` that is only
- resolved during the substitution pass. During bundle discovery we need
- the referenced files to actually load so the ``track_yaml_loads``
- listener fires for them.
-
- ``IncludeFile`` instances with unresolved substitution variables in the
- filename cannot be loaded — we skip and warn about those.
- """
- if _seen is None:
- _seen = set()
-
- if isinstance(obj, yaml_util.IncludeFile):
- if id(obj) in _seen:
- return
- _seen.add(id(obj))
- if obj.has_unresolved_expressions():
- _LOGGER.warning(
- "Bundle: cannot resolve !include %s (referenced from %s) "
- "with substitutions in path",
- obj.file,
- obj.parent_file,
- )
- return
- try:
- loaded = obj.load()
- except EsphomeError as err:
- _LOGGER.warning(
- "Bundle: failed to load !include %s (referenced from %s): %s",
- obj.file,
- obj.parent_file,
- err,
- )
- return
- _force_load_include_files(loaded, _seen)
- elif isinstance(obj, dict):
- if id(obj) in _seen:
- return
- _seen.add(id(obj))
- for value in obj.values():
- _force_load_include_files(value, _seen)
- elif isinstance(obj, (list, tuple)):
- if id(obj) in _seen:
- return
- _seen.add(id(obj))
- for item in obj:
- _force_load_include_files(item, _seen)
-
-
def _resolve_include_path(include_path: Any) -> Path | None:
"""Resolve an include path to absolute, skipping system includes."""
if isinstance(include_path, str) and include_path.startswith("<"):
diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py
new file mode 100644
index 000000000..f4fd20528
--- /dev/null
+++ b/esphome/compiled_config.py
@@ -0,0 +1,76 @@
+"""Validated-config cache for the upload/logs fast path.
+
+compile dumps the validated config to /storage/.validated.yaml;
+the next upload/logs for that YAML reuses it instead of running the full
+read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
+!lambda/!include/IDs/paths intact; mtime gates staleness.
+"""
+
+from __future__ import annotations
+
+import logging
+from pathlib import Path
+
+from esphome.core import CORE
+from esphome.helpers import write_file
+from esphome.storage_json import StorageJSON, ext_storage_path
+from esphome.types import ConfigType
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def compiled_config_path(config_filename: str) -> Path:
+ """Path to the cached validated config alongside the storage sidecar."""
+ return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
+
+
+def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
+ """True iff the cache file exists and isn't older than the source."""
+ try:
+ return cache_path.stat().st_mtime >= source_path.stat().st_mtime
+ except OSError:
+ return False
+
+
+def save_compiled_config(config: ConfigType) -> None:
+ """Write the validated-config cache. Always-write so mtime stays fresh.
+
+ Mode 0600 because show_secrets=True resolves !secret inline.
+ Failures are non-fatal: the fast path falls back to read_config.
+ """
+ from esphome import yaml_util
+
+ try:
+ rendered = yaml_util.dump(config, show_secrets=True)
+ write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
+ except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
+ _LOGGER.debug("Skipping compiled config cache write: %s", err)
+
+
+def load_compiled_config(conf_path: Path) -> ConfigType | None:
+ """Load the cached validated config and apply storage metadata to CORE.
+
+ Returns None (caller falls back to read_config) when the cache is
+ missing, older than the source YAML, unparseable, or the sidecar
+ is incomplete.
+ """
+ cache_path = compiled_config_path(conf_path.name)
+ if not _cache_is_fresh(cache_path, conf_path):
+ return None
+
+ from esphome import yaml_util
+
+ try:
+ config = yaml_util.load_yaml(cache_path, clear_secrets=False)
+ except Exception: # noqa: BLE001 # pylint: disable=broad-except
+ return None
+
+ storage = StorageJSON.load(ext_storage_path(conf_path.name))
+ if storage is None:
+ return None
+ # apply_to_core assumes a real compile wrote the sidecar; wizard-only
+ # sidecars leave both of these unset and can't drive upload/logs.
+ if not storage.core_platform and not storage.target_platform:
+ return None
+ storage.apply_to_core()
+ return config
diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp
index 210c3557b..344456854 100644
--- a/esphome/components/a01nyub/a01nyub.cpp
+++ b/esphome/components/a01nyub/a01nyub.cpp
@@ -4,8 +4,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace a01nyub {
+namespace esphome::a01nyub {
static const char *const TAG = "a01nyub.sensor";
@@ -42,5 +41,4 @@ void A01nyubComponent::check_buffer_() {
void A01nyubComponent::dump_config() { LOG_SENSOR("", "A01nyub Sensor", this); }
-} // namespace a01nyub
-} // namespace esphome
+} // namespace esphome::a01nyub
diff --git a/esphome/components/a01nyub/a01nyub.h b/esphome/components/a01nyub/a01nyub.h
index 6b22e9bca..5c0d20bd3 100644
--- a/esphome/components/a01nyub/a01nyub.h
+++ b/esphome/components/a01nyub/a01nyub.h
@@ -6,8 +6,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
-namespace esphome {
-namespace a01nyub {
+namespace esphome::a01nyub {
class A01nyubComponent : public sensor::Sensor, public Component, public uart::UARTDevice {
public:
@@ -23,5 +22,4 @@ class A01nyubComponent : public sensor::Sensor, public Component, public uart::U
std::vector buffer_;
};
-} // namespace a01nyub
-} // namespace esphome
+} // namespace esphome::a01nyub
diff --git a/esphome/components/a02yyuw/a02yyuw.cpp b/esphome/components/a02yyuw/a02yyuw.cpp
index a2aad0cef..2832334ef 100644
--- a/esphome/components/a02yyuw/a02yyuw.cpp
+++ b/esphome/components/a02yyuw/a02yyuw.cpp
@@ -4,8 +4,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace a02yyuw {
+namespace esphome::a02yyuw {
static const char *const TAG = "a02yyuw.sensor";
@@ -41,5 +40,4 @@ void A02yyuwComponent::check_buffer_() {
void A02yyuwComponent::dump_config() { LOG_SENSOR("", "A02yyuw Sensor", this); }
-} // namespace a02yyuw
-} // namespace esphome
+} // namespace esphome::a02yyuw
diff --git a/esphome/components/a02yyuw/a02yyuw.h b/esphome/components/a02yyuw/a02yyuw.h
index 6ff370fdc..693bcfd03 100644
--- a/esphome/components/a02yyuw/a02yyuw.h
+++ b/esphome/components/a02yyuw/a02yyuw.h
@@ -6,8 +6,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
-namespace esphome {
-namespace a02yyuw {
+namespace esphome::a02yyuw {
class A02yyuwComponent : public sensor::Sensor, public Component, public uart::UARTDevice {
public:
@@ -23,5 +22,4 @@ class A02yyuwComponent : public sensor::Sensor, public Component, public uart::U
std::vector buffer_;
};
-} // namespace a02yyuw
-} // namespace esphome
+} // namespace esphome::a02yyuw
diff --git a/esphome/components/a4988/a4988.cpp b/esphome/components/a4988/a4988.cpp
index b9efb4ea4..d8fc6752f 100644
--- a/esphome/components/a4988/a4988.cpp
+++ b/esphome/components/a4988/a4988.cpp
@@ -1,8 +1,7 @@
#include "a4988.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace a4988 {
+namespace esphome::a4988 {
static const char *const TAG = "a4988.stepper";
@@ -51,5 +50,4 @@ void A4988::loop() {
this->step_pin_->digital_write(false);
}
-} // namespace a4988
-} // namespace esphome
+} // namespace esphome::a4988
diff --git a/esphome/components/a4988/a4988.h b/esphome/components/a4988/a4988.h
index 0fe789111..04040241c 100644
--- a/esphome/components/a4988/a4988.h
+++ b/esphome/components/a4988/a4988.h
@@ -4,8 +4,7 @@
#include "esphome/core/hal.h"
#include "esphome/components/stepper/stepper.h"
-namespace esphome {
-namespace a4988 {
+namespace esphome::a4988 {
class A4988 : public stepper::Stepper, public Component {
public:
@@ -25,5 +24,4 @@ class A4988 : public stepper::Stepper, public Component {
HighFrequencyLoopRequester high_freq_;
};
-} // namespace a4988
-} // namespace esphome
+} // namespace esphome::a4988
diff --git a/esphome/components/adalight/adalight_light_effect.cpp b/esphome/components/adalight/adalight_light_effect.cpp
index 4cf639a01..bf6849aca 100644
--- a/esphome/components/adalight/adalight_light_effect.cpp
+++ b/esphome/components/adalight/adalight_light_effect.cpp
@@ -1,8 +1,7 @@
#include "adalight_light_effect.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace adalight {
+namespace esphome::adalight {
static const char *const TAG = "adalight_light_effect";
@@ -129,7 +128,7 @@ AdalightLightEffect::Frame AdalightLightEffect::parse_frame_(light::AddressableL
uint8_t *led_data = &frame_[6];
for (int led = 0; led < accepted_led_count; led++, led_data += 3) {
- auto white = std::min(std::min(led_data[0], led_data[1]), led_data[2]);
+ auto white = std::min({led_data[0], led_data[1], led_data[2]});
it[led].set(Color(led_data[0], led_data[1], led_data[2], white));
}
@@ -138,5 +137,4 @@ AdalightLightEffect::Frame AdalightLightEffect::parse_frame_(light::AddressableL
return CONSUMED;
}
-} // namespace adalight
-} // namespace esphome
+} // namespace esphome::adalight
diff --git a/esphome/components/adalight/adalight_light_effect.h b/esphome/components/adalight/adalight_light_effect.h
index bb7319c99..c30e84677 100644
--- a/esphome/components/adalight/adalight_light_effect.h
+++ b/esphome/components/adalight/adalight_light_effect.h
@@ -6,8 +6,7 @@
#include
-namespace esphome {
-namespace adalight {
+namespace esphome::adalight {
class AdalightLightEffect : public light::AddressableLightEffect, public uart::UARTDevice {
public:
@@ -35,5 +34,4 @@ class AdalightLightEffect : public light::AddressableLightEffect, public uart::U
std::vector frame_;
};
-} // namespace adalight
-} // namespace esphome
+} // namespace esphome::adalight
diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h
index cf48ccd9c..676940eca 100644
--- a/esphome/components/adc/adc_sensor.h
+++ b/esphome/components/adc/adc_sensor.h
@@ -17,8 +17,7 @@
#include
#endif
-namespace esphome {
-namespace adc {
+namespace esphome::adc {
#ifdef USE_ESP32
// clang-format off
@@ -162,5 +161,4 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage
#endif
};
-} // namespace adc
-} // namespace esphome
+} // namespace esphome::adc
diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp
index c779fd589..16c86aee1 100644
--- a/esphome/components/adc/adc_sensor_common.cpp
+++ b/esphome/components/adc/adc_sensor_common.cpp
@@ -1,8 +1,7 @@
#include "adc_sensor.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace adc {
+namespace esphome::adc {
static const char *const TAG = "adc.common";
@@ -79,5 +78,4 @@ void ADCSensor::set_sample_count(uint8_t sample_count) {
void ADCSensor::set_sampling_mode(SamplingMode sampling_mode) { this->sampling_mode_ = sampling_mode; }
-} // namespace adc
-} // namespace esphome
+} // namespace esphome::adc
diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp
index fc707013a..a761b3774 100644
--- a/esphome/components/adc/adc_sensor_esp32.cpp
+++ b/esphome/components/adc/adc_sensor_esp32.cpp
@@ -4,8 +4,7 @@
#include "esphome/core/log.h"
#include
-namespace esphome {
-namespace adc {
+namespace esphome::adc {
static const char *const TAG = "adc.esp32";
@@ -364,7 +363,6 @@ float ADCSensor::sample_autorange_() {
return final_result;
}
-} // namespace adc
-} // namespace esphome
+} // namespace esphome::adc
#endif // USE_ESP32
diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp
index be14b252d..e4f2f82f0 100644
--- a/esphome/components/adc/adc_sensor_esp8266.cpp
+++ b/esphome/components/adc/adc_sensor_esp8266.cpp
@@ -11,8 +11,7 @@ ADC_MODE(ADC_VCC)
#include
#endif // USE_ADC_SENSOR_VCC
-namespace esphome {
-namespace adc {
+namespace esphome::adc {
static const char *const TAG = "adc.esp8266";
@@ -55,7 +54,6 @@ float ADCSensor::sample() {
return aggr.aggregate() / 1024.0f;
}
-} // namespace adc
-} // namespace esphome
+} // namespace esphome::adc
#endif // USE_ESP8266
diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp
index 0b1393c2e..d9b9f50be 100644
--- a/esphome/components/adc/adc_sensor_libretiny.cpp
+++ b/esphome/components/adc/adc_sensor_libretiny.cpp
@@ -3,8 +3,7 @@
#include "adc_sensor.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace adc {
+namespace esphome::adc {
static const char *const TAG = "adc.libretiny";
@@ -48,7 +47,6 @@ float ADCSensor::sample() {
return aggr.aggregate() / 1000.0f;
}
-} // namespace adc
-} // namespace esphome
+} // namespace esphome::adc
#endif // USE_LIBRETINY
diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2040.cpp
index a79707e23..8d41edb81 100644
--- a/esphome/components/adc/adc_sensor_rp2040.cpp
+++ b/esphome/components/adc/adc_sensor_rp2040.cpp
@@ -15,8 +15,7 @@
#define PICO_VSYS_PIN 29 // NOLINT(cppcoreguidelines-macro-usage)
#endif
-namespace esphome {
-namespace adc {
+namespace esphome::adc {
static const char *const TAG = "adc.rp2040";
@@ -98,7 +97,6 @@ float ADCSensor::sample() {
return aggr.aggregate() * 3.3f / 4096.0f * coeff;
}
-} // namespace adc
-} // namespace esphome
+} // namespace esphome::adc
#endif // USE_RP2040
diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp
index 2fb9d4b0e..c3632b00e 100644
--- a/esphome/components/adc/adc_sensor_zephyr.cpp
+++ b/esphome/components/adc/adc_sensor_zephyr.cpp
@@ -5,8 +5,7 @@
#include "hal/nrf_saadc.h"
-namespace esphome {
-namespace adc {
+namespace esphome::adc {
static const char *const TAG = "adc.zephyr";
@@ -202,6 +201,5 @@ float ADCSensor::sample() {
return val_mv / 1000.0f;
}
-} // namespace adc
-} // namespace esphome
+} // namespace esphome::adc
#endif
diff --git a/esphome/components/adc128s102/adc128s102.cpp b/esphome/components/adc128s102/adc128s102.cpp
index 935dbde8e..ef0db4730 100644
--- a/esphome/components/adc128s102/adc128s102.cpp
+++ b/esphome/components/adc128s102/adc128s102.cpp
@@ -1,8 +1,7 @@
#include "adc128s102.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace adc128s102 {
+namespace esphome::adc128s102 {
static const char *const TAG = "adc128s102";
@@ -28,5 +27,4 @@ uint16_t ADC128S102::read_data(uint8_t channel) {
return digital_value;
}
-} // namespace adc128s102
-} // namespace esphome
+} // namespace esphome::adc128s102
diff --git a/esphome/components/adc128s102/adc128s102.h b/esphome/components/adc128s102/adc128s102.h
index bd6b7f7af..f04ed87b2 100644
--- a/esphome/components/adc128s102/adc128s102.h
+++ b/esphome/components/adc128s102/adc128s102.h
@@ -4,8 +4,7 @@
#include "esphome/core/hal.h"
#include "esphome/components/spi/spi.h"
-namespace esphome {
-namespace adc128s102 {
+namespace esphome::adc128s102 {
class ADC128S102 : public Component,
public spi::SPIDeviceparent_->read_data(this->channel_); }
void ADC128S102Sensor::update() { this->publish_state(this->sample()); }
-} // namespace adc128s102
-} // namespace esphome
+} // namespace esphome::adc128s102
diff --git a/esphome/components/adc128s102/sensor/adc128s102_sensor.h b/esphome/components/adc128s102/sensor/adc128s102_sensor.h
index 5e6fc74e9..c84010238 100644
--- a/esphome/components/adc128s102/sensor/adc128s102_sensor.h
+++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.h
@@ -7,8 +7,7 @@
#include "../adc128s102.h"
-namespace esphome {
-namespace adc128s102 {
+namespace esphome::adc128s102 {
class ADC128S102Sensor : public PollingComponent,
public Parented,
@@ -24,5 +23,4 @@ class ADC128S102Sensor : public PollingComponent,
protected:
uint8_t channel_;
};
-} // namespace adc128s102
-} // namespace esphome
+} // namespace esphome::adc128s102
diff --git a/esphome/components/addressable_light/addressable_light_display.cpp b/esphome/components/addressable_light/addressable_light_display.cpp
index 329620bcf..4cbcb3324 100644
--- a/esphome/components/addressable_light/addressable_light_display.cpp
+++ b/esphome/components/addressable_light/addressable_light_display.cpp
@@ -1,8 +1,7 @@
#include "addressable_light_display.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace addressable_light {
+namespace esphome::addressable_light {
static const char *const TAG = "addressable_light.display";
@@ -66,5 +65,4 @@ void HOT AddressableLightDisplay::draw_absolute_pixel_internal(int x, int y, Col
this->addressable_light_buffer_[y * this->get_width_internal() + x] = color;
}
}
-} // namespace addressable_light
-} // namespace esphome
+} // namespace esphome::addressable_light
diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h
index d9b868054..917d334f0 100644
--- a/esphome/components/addressable_light/addressable_light_display.h
+++ b/esphome/components/addressable_light/addressable_light_display.h
@@ -7,8 +7,7 @@
#include
-namespace esphome {
-namespace addressable_light {
+namespace esphome::addressable_light {
class AddressableLightDisplay : public display::DisplayBuffer {
public:
@@ -61,5 +60,4 @@ class AddressableLightDisplay : public display::DisplayBuffer {
optional last_effect_index_;
optional> pixel_mapper_f_;
};
-} // namespace addressable_light
-} // namespace esphome
+} // namespace esphome::addressable_light
diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp
index 8fb3e55b9..9d19770c5 100644
--- a/esphome/components/ade7880/ade7880.cpp
+++ b/esphome/components/ade7880/ade7880.cpp
@@ -13,8 +13,7 @@
#include
-namespace esphome {
-namespace ade7880 {
+namespace esphome::ade7880 {
static const char *const TAG = "ade7880";
@@ -313,5 +312,4 @@ void ADE7880::reset_device_() {
this->store_.reset_pending = true;
}
-} // namespace ade7880
-} // namespace esphome
+} // namespace esphome::ade7880
diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h
index 40bc22e54..69c8e5abb 100644
--- a/esphome/components/ade7880/ade7880.h
+++ b/esphome/components/ade7880/ade7880.h
@@ -16,8 +16,7 @@
#include "ade7880_registers.h"
-namespace esphome {
-namespace ade7880 {
+namespace esphome::ade7880 {
struct NeutralChannel {
void set_current(sensor::Sensor *sens) { this->current = sens; }
@@ -125,5 +124,4 @@ class ADE7880 : public i2c::I2CDevice, public PollingComponent {
void write_u32_register16_(uint16_t a_register, uint32_t value);
};
-} // namespace ade7880
-} // namespace esphome
+} // namespace esphome::ade7880
diff --git a/esphome/components/ade7880/ade7880_i2c.cpp b/esphome/components/ade7880/ade7880_i2c.cpp
index fae20f175..294fd430d 100644
--- a/esphome/components/ade7880/ade7880_i2c.cpp
+++ b/esphome/components/ade7880/ade7880_i2c.cpp
@@ -9,8 +9,7 @@
#include "ade7880.h"
-namespace esphome {
-namespace ade7880 {
+namespace esphome::ade7880 {
// adapted from https://stackoverflow.com/a/55912127/1886371
template inline T sign_extend(const T &v) noexcept {
@@ -97,5 +96,4 @@ void ADE7880::write_u32_register16_(uint16_t a_register, uint32_t value) {
this->write_register16(a_register, reinterpret_cast(&out), sizeof(out));
}
-} // namespace ade7880
-} // namespace esphome
+} // namespace esphome::ade7880
diff --git a/esphome/components/ade7880/ade7880_registers.h b/esphome/components/ade7880/ade7880_registers.h
index 9fd8ca3bf..aee4e4244 100644
--- a/esphome/components/ade7880/ade7880_registers.h
+++ b/esphome/components/ade7880/ade7880_registers.h
@@ -4,8 +4,7 @@
// Source: https://www.analog.com/media/en/technical-documentation/application-notes/AN-1127.pdf
-namespace esphome {
-namespace ade7880 {
+namespace esphome::ade7880 {
// DSP Data Memory RAM registers
constexpr uint16_t AIGAIN = 0x4380;
@@ -242,5 +241,4 @@ constexpr uint8_t DSPWP_SET_RO = (1 << 7);
// DSPWP_SEL Register Bits
constexpr uint8_t DSPWP_SEL_SET = 0xAD;
-} // namespace ade7880
-} // namespace esphome
+} // namespace esphome::ade7880
diff --git a/esphome/components/ade7953_base/ade7953_base.cpp b/esphome/components/ade7953_base/ade7953_base.cpp
index 2dfab8ff8..1adf44f8f 100644
--- a/esphome/components/ade7953_base/ade7953_base.cpp
+++ b/esphome/components/ade7953_base/ade7953_base.cpp
@@ -3,8 +3,7 @@
#include
-namespace esphome {
-namespace ade7953_base {
+namespace esphome::ade7953_base {
static const char *const TAG = "ade7953";
@@ -160,5 +159,4 @@ void ADE7953::update() {
ADE_PUBLISH(frequency, 223750.0f, 1 + val_16);
}
-} // namespace ade7953_base
-} // namespace esphome
+} // namespace esphome::ade7953_base
diff --git a/esphome/components/ade7953_base/ade7953_base.h b/esphome/components/ade7953_base/ade7953_base.h
index b58f95b23..a1dfea23b 100644
--- a/esphome/components/ade7953_base/ade7953_base.h
+++ b/esphome/components/ade7953_base/ade7953_base.h
@@ -6,8 +6,7 @@
#include
-namespace esphome {
-namespace ade7953_base {
+namespace esphome::ade7953_base {
static constexpr uint8_t PGA_V_8 =
0x007; // PGA_V, (R/W) Default: 0x00, Unsigned, Voltage channel gain configuration (Bits[2:0])
@@ -131,5 +130,4 @@ class ADE7953 : public PollingComponent, public sensor::Sensor {
virtual bool ade_read_32(uint16_t reg, uint32_t *value) = 0;
};
-} // namespace ade7953_base
-} // namespace esphome
+} // namespace esphome::ade7953_base
diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.cpp b/esphome/components/ade7953_i2c/ade7953_i2c.cpp
index 59c2254d4..252e55ee5 100644
--- a/esphome/components/ade7953_i2c/ade7953_i2c.cpp
+++ b/esphome/components/ade7953_i2c/ade7953_i2c.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace ade7953_i2c {
+namespace esphome::ade7953_i2c {
static const char *const TAG = "ade7953";
@@ -76,5 +75,4 @@ bool AdE7953I2c::ade_read_32(uint16_t reg, uint32_t *value) {
return false;
}
-} // namespace ade7953_i2c
-} // namespace esphome
+} // namespace esphome::ade7953_i2c
diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.h b/esphome/components/ade7953_i2c/ade7953_i2c.h
index 65dc30ddd..74d7e3e7c 100644
--- a/esphome/components/ade7953_i2c/ade7953_i2c.h
+++ b/esphome/components/ade7953_i2c/ade7953_i2c.h
@@ -8,8 +8,7 @@
#include
-namespace esphome {
-namespace ade7953_i2c {
+namespace esphome::ade7953_i2c {
class AdE7953I2c : public ade7953_base::ADE7953, public i2c::I2CDevice {
public:
@@ -24,5 +23,4 @@ class AdE7953I2c : public ade7953_base::ADE7953, public i2c::I2CDevice {
bool ade_read_32(uint16_t reg, uint32_t *value) override;
};
-} // namespace ade7953_i2c
-} // namespace esphome
+} // namespace esphome::ade7953_i2c
diff --git a/esphome/components/ade7953_spi/ade7953_spi.cpp b/esphome/components/ade7953_spi/ade7953_spi.cpp
index a69b7d19f..c2d85231d 100644
--- a/esphome/components/ade7953_spi/ade7953_spi.cpp
+++ b/esphome/components/ade7953_spi/ade7953_spi.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace ade7953_spi {
+namespace esphome::ade7953_spi {
static const char *const TAG = "ade7953";
@@ -83,5 +82,4 @@ bool AdE7953Spi::ade_read_32(uint16_t reg, uint32_t *value) {
return false;
}
-} // namespace ade7953_spi
-} // namespace esphome
+} // namespace esphome::ade7953_spi
diff --git a/esphome/components/ade7953_spi/ade7953_spi.h b/esphome/components/ade7953_spi/ade7953_spi.h
index 27f6025d9..657397db4 100644
--- a/esphome/components/ade7953_spi/ade7953_spi.h
+++ b/esphome/components/ade7953_spi/ade7953_spi.h
@@ -8,8 +8,7 @@
#include
-namespace esphome {
-namespace ade7953_spi {
+namespace esphome::ade7953_spi {
class AdE7953Spi : public ade7953_base::ADE7953,
public spi::SPIDevice
-namespace esphome {
-namespace ads1115 {
+namespace esphome::ads1115 {
enum ADS1115Multiplexer {
ADS1115_MULTIPLEXER_P0_N1 = 0b000,
@@ -60,5 +59,4 @@ class ADS1115Component : public Component, public i2c::I2CDevice {
bool continuous_mode_;
};
-} // namespace ads1115
-} // namespace esphome
+} // namespace esphome::ads1115
diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.cpp b/esphome/components/ads1115/sensor/ads1115_sensor.cpp
index fac6b60d0..8086d9723 100644
--- a/esphome/components/ads1115/sensor/ads1115_sensor.cpp
+++ b/esphome/components/ads1115/sensor/ads1115_sensor.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/log.h"
-namespace esphome {
-namespace ads1115 {
+namespace esphome::ads1115 {
static const char *const TAG = "ads1115.sensor";
@@ -29,5 +28,4 @@ void ADS1115Sensor::dump_config() {
this->multiplexer_, this->gain_, this->resolution_, this->samplerate_);
}
-} // namespace ads1115
-} // namespace esphome
+} // namespace esphome::ads1115
diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.h b/esphome/components/ads1115/sensor/ads1115_sensor.h
index 5ca25c13a..3b82c153d 100644
--- a/esphome/components/ads1115/sensor/ads1115_sensor.h
+++ b/esphome/components/ads1115/sensor/ads1115_sensor.h
@@ -8,8 +8,7 @@
#include "../ads1115.h"
-namespace esphome {
-namespace ads1115 {
+namespace esphome::ads1115 {
/// Internal holder class that is in instance of Sensor so that the hub can create individual sensors.
class ADS1115Sensor : public sensor::Sensor,
@@ -33,5 +32,4 @@ class ADS1115Sensor : public sensor::Sensor,
ADS1115Samplerate samplerate_;
};
-} // namespace ads1115
-} // namespace esphome
+} // namespace esphome::ads1115
diff --git a/esphome/components/ads1118/ads1118.cpp b/esphome/components/ads1118/ads1118.cpp
index f7db9f93d..0a07193bf 100644
--- a/esphome/components/ads1118/ads1118.cpp
+++ b/esphome/components/ads1118/ads1118.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace ads1118 {
+namespace esphome::ads1118 {
static const char *const TAG = "ads1118";
static const uint8_t ADS1118_DATA_RATE_860_SPS = 0b111;
@@ -122,5 +121,4 @@ float ADS1118::request_measurement(ADS1118Multiplexer multiplexer, ADS1118Gain g
}
}
-} // namespace ads1118
-} // namespace esphome
+} // namespace esphome::ads1118
diff --git a/esphome/components/ads1118/ads1118.h b/esphome/components/ads1118/ads1118.h
index e96baab38..ef125a0b4 100644
--- a/esphome/components/ads1118/ads1118.h
+++ b/esphome/components/ads1118/ads1118.h
@@ -4,8 +4,7 @@
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
-namespace esphome {
-namespace ads1118 {
+namespace esphome::ads1118 {
enum ADS1118Multiplexer {
ADS1118_MULTIPLEXER_P0_N1 = 0b000,
@@ -41,5 +40,4 @@ class ADS1118 : public Component,
uint16_t config_{0};
};
-} // namespace ads1118
-} // namespace esphome
+} // namespace esphome::ads1118
diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.cpp b/esphome/components/ads1118/sensor/ads1118_sensor.cpp
index 7193c3c88..383a3d25f 100644
--- a/esphome/components/ads1118/sensor/ads1118_sensor.cpp
+++ b/esphome/components/ads1118/sensor/ads1118_sensor.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/log.h"
-namespace esphome {
-namespace ads1118 {
+namespace esphome::ads1118 {
static const char *const TAG = "ads1118.sensor";
@@ -27,5 +26,4 @@ void ADS1118Sensor::update() {
}
}
-} // namespace ads1118
-} // namespace esphome
+} // namespace esphome::ads1118
diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.h b/esphome/components/ads1118/sensor/ads1118_sensor.h
index d2d7a03f5..b929e75c6 100644
--- a/esphome/components/ads1118/sensor/ads1118_sensor.h
+++ b/esphome/components/ads1118/sensor/ads1118_sensor.h
@@ -8,8 +8,7 @@
#include "../ads1118.h"
-namespace esphome {
-namespace ads1118 {
+namespace esphome::ads1118 {
class ADS1118Sensor : public PollingComponent,
public sensor::Sensor,
@@ -32,5 +31,4 @@ class ADS1118Sensor : public PollingComponent,
bool temperature_mode_;
};
-} // namespace ads1118
-} // namespace esphome
+} // namespace esphome::ads1118
diff --git a/esphome/components/ags10/ags10.cpp b/esphome/components/ags10/ags10.cpp
index fa7170114..230548ae9 100644
--- a/esphome/components/ags10/ags10.cpp
+++ b/esphome/components/ags10/ags10.cpp
@@ -3,8 +3,7 @@
#include
-namespace esphome {
-namespace ags10 {
+namespace esphome::ags10 {
static const char *const TAG = "ags10";
// Data acquisition.
@@ -192,5 +191,4 @@ template optional> AGS10Component::read_and_che
return data;
}
-} // namespace ags10
-} // namespace esphome
+} // namespace esphome::ags10
diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h
index 9e034b20c..703acd522 100644
--- a/esphome/components/ags10/ags10.h
+++ b/esphome/components/ags10/ags10.h
@@ -5,8 +5,7 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
-namespace esphome {
-namespace ags10 {
+namespace esphome::ags10 {
class AGS10Component : public PollingComponent, public i2c::I2CDevice {
public:
@@ -136,5 +135,4 @@ template class AGS10SetZeroPointAction : public Action, p
}
}
};
-} // namespace ags10
-} // namespace esphome
+} // namespace esphome::ags10
diff --git a/esphome/components/aht10/aht10.cpp b/esphome/components/aht10/aht10.cpp
index 1b1f8335c..cc90abfc3 100644
--- a/esphome/components/aht10/aht10.cpp
+++ b/esphome/components/aht10/aht10.cpp
@@ -17,8 +17,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace aht10 {
+namespace esphome::aht10 {
static const char *const TAG = "aht10";
static const uint8_t AHT10_INITIALIZE_CMD[] = {0xE1, 0x08, 0x00};
@@ -160,5 +159,4 @@ void AHT10Component::dump_config() {
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
}
-} // namespace aht10
-} // namespace esphome
+} // namespace esphome::aht10
diff --git a/esphome/components/aht10/aht10.h b/esphome/components/aht10/aht10.h
index ce9cd963a..7b9b1761c 100644
--- a/esphome/components/aht10/aht10.h
+++ b/esphome/components/aht10/aht10.h
@@ -6,8 +6,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
-namespace esphome {
-namespace aht10 {
+namespace esphome::aht10 {
enum AHT10Variant { AHT10, AHT20 };
@@ -31,5 +30,4 @@ class AHT10Component : public PollingComponent, public i2c::I2CDevice {
uint32_t start_time_{};
};
-} // namespace aht10
-} // namespace esphome
+} // namespace esphome::aht10
diff --git a/esphome/components/aic3204/aic3204.cpp b/esphome/components/aic3204/aic3204.cpp
index e1acf32f8..0ba960fd7 100644
--- a/esphome/components/aic3204/aic3204.cpp
+++ b/esphome/components/aic3204/aic3204.cpp
@@ -4,8 +4,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace aic3204 {
+namespace esphome::aic3204 {
static const char *const TAG = "aic3204";
@@ -167,5 +166,4 @@ bool AIC3204::write_volume_() {
return true;
}
-} // namespace aic3204
-} // namespace esphome
+} // namespace esphome::aic3204
diff --git a/esphome/components/aic3204/aic3204.h b/esphome/components/aic3204/aic3204.h
index 28006e33f..9b8c79282 100644
--- a/esphome/components/aic3204/aic3204.h
+++ b/esphome/components/aic3204/aic3204.h
@@ -6,8 +6,7 @@
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
-namespace esphome {
-namespace aic3204 {
+namespace esphome::aic3204 {
// TLV320AIC3204 Register Addresses
// Page 0
@@ -83,5 +82,4 @@ class AIC3204 : public audio_dac::AudioDac, public Component, public i2c::I2CDev
float volume_{0};
};
-} // namespace aic3204
-} // namespace esphome
+} // namespace esphome::aic3204
diff --git a/esphome/components/aic3204/automation.h b/esphome/components/aic3204/automation.h
index 851ff930f..50ae03edb 100644
--- a/esphome/components/aic3204/automation.h
+++ b/esphome/components/aic3204/automation.h
@@ -4,8 +4,7 @@
#include "esphome/core/component.h"
#include "aic3204.h"
-namespace esphome {
-namespace aic3204 {
+namespace esphome::aic3204 {
template class SetAutoMuteAction : public Action {
public:
@@ -19,5 +18,4 @@ template class SetAutoMuteAction : public Action {
AIC3204 *aic3204_;
};
-} // namespace aic3204
-} // namespace esphome
+} // namespace esphome::aic3204
diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp
index 58faf923f..881b3e297 100644
--- a/esphome/components/airthings_ble/airthings_listener.cpp
+++ b/esphome/components/airthings_ble/airthings_listener.cpp
@@ -4,8 +4,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace airthings_ble {
+namespace esphome::airthings_ble {
static const char *const TAG = "airthings_ble";
@@ -29,7 +28,6 @@ bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &devic
return false;
}
-} // namespace airthings_ble
-} // namespace esphome
+} // namespace esphome::airthings_ble
#endif
diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h
index 52f69ea97..707e9c3f2 100644
--- a/esphome/components/airthings_ble/airthings_listener.h
+++ b/esphome/components/airthings_ble/airthings_listener.h
@@ -5,15 +5,13 @@
#include "esphome/core/component.h"
#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
-namespace esphome {
-namespace airthings_ble {
+namespace esphome::airthings_ble {
class AirthingsListener : public esp32_ble_tracker::ESPBTDeviceListener {
public:
bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
};
-} // namespace airthings_ble
-} // namespace esphome
+} // namespace esphome::airthings_ble
#endif
diff --git a/esphome/components/airthings_wave_base/airthings_wave_base.cpp b/esphome/components/airthings_wave_base/airthings_wave_base.cpp
index e4c7d2a81..5fa59f22f 100644
--- a/esphome/components/airthings_wave_base/airthings_wave_base.cpp
+++ b/esphome/components/airthings_wave_base/airthings_wave_base.cpp
@@ -6,8 +6,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace airthings_wave_base {
+namespace esphome::airthings_wave_base {
static const char *const TAG = "airthings_wave_base";
@@ -211,7 +210,6 @@ void AirthingsWaveBase::set_response_timeout_() {
});
}
-} // namespace airthings_wave_base
-} // namespace esphome
+} // namespace esphome::airthings_wave_base
#endif // USE_ESP32
diff --git a/esphome/components/airthings_wave_base/airthings_wave_base.h b/esphome/components/airthings_wave_base/airthings_wave_base.h
index 1dc2e1f71..2f1e15491 100644
--- a/esphome/components/airthings_wave_base/airthings_wave_base.h
+++ b/esphome/components/airthings_wave_base/airthings_wave_base.h
@@ -14,8 +14,7 @@
#include "esphome/core/component.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace airthings_wave_base {
+namespace esphome::airthings_wave_base {
namespace espbt = esphome::esp32_ble_tracker;
@@ -84,7 +83,6 @@ class AirthingsWaveBase : public PollingComponent, public ble_client::BLEClientN
};
};
-} // namespace airthings_wave_base
-} // namespace esphome
+} // namespace esphome::airthings_wave_base
#endif // USE_ESP32
diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp b/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp
index 873826d06..f487e9dbc 100644
--- a/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp
+++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.cpp
@@ -2,8 +2,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace airthings_wave_mini {
+namespace esphome::airthings_wave_mini {
static const char *const TAG = "airthings_wave_mini";
@@ -49,7 +48,6 @@ AirthingsWaveMini::AirthingsWaveMini() {
espbt::ESPBTUUID::from_raw(ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID);
}
-} // namespace airthings_wave_mini
-} // namespace esphome
+} // namespace esphome::airthings_wave_mini
#endif // USE_ESP32
diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.h b/esphome/components/airthings_wave_mini/airthings_wave_mini.h
index 825ddbdc6..910ac9023 100644
--- a/esphome/components/airthings_wave_mini/airthings_wave_mini.h
+++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.h
@@ -4,8 +4,7 @@
#include "esphome/components/airthings_wave_base/airthings_wave_base.h"
-namespace esphome {
-namespace airthings_wave_mini {
+namespace esphome::airthings_wave_mini {
namespace espbt = esphome::esp32_ble_tracker;
@@ -34,7 +33,6 @@ class AirthingsWaveMini : public airthings_wave_base::AirthingsWaveBase {
};
};
-} // namespace airthings_wave_mini
-} // namespace esphome
+} // namespace esphome::airthings_wave_mini
#endif // USE_ESP32
diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp
index 5ed62fff6..80fe081b5 100644
--- a/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp
+++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.cpp
@@ -2,8 +2,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace airthings_wave_plus {
+namespace esphome::airthings_wave_plus {
static const char *const TAG = "airthings_wave_plus";
@@ -98,7 +97,6 @@ void AirthingsWavePlus::setup() {
espbt::ESPBTUUID::from_raw(access_control_point_characteristic_uuid);
}
-} // namespace airthings_wave_plus
-} // namespace esphome
+} // namespace esphome::airthings_wave_plus
#endif // USE_ESP32
diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.h b/esphome/components/airthings_wave_plus/airthings_wave_plus.h
index c978a9af9..6f51f3c65 100644
--- a/esphome/components/airthings_wave_plus/airthings_wave_plus.h
+++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.h
@@ -4,8 +4,7 @@
#include "esphome/components/airthings_wave_base/airthings_wave_base.h"
-namespace esphome {
-namespace airthings_wave_plus {
+namespace esphome::airthings_wave_plus {
namespace espbt = esphome::esp32_ble_tracker;
@@ -58,7 +57,6 @@ class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase {
};
};
-} // namespace airthings_wave_plus
-} // namespace esphome
+} // namespace esphome::airthings_wave_plus
#endif // USE_ESP32
diff --git a/esphome/components/alarm_control_panel/__init__.py b/esphome/components/alarm_control_panel/__init__.py
index 9fcdf42ec..2f5d4c7c2 100644
--- a/esphome/components/alarm_control_panel/__init__.py
+++ b/esphome/components/alarm_control_panel/__init__.py
@@ -13,7 +13,11 @@ from esphome.const import (
CONF_WEB_SERVER,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
-from esphome.core.entity_helpers import entity_duplicate_validator, setup_entity
+from esphome.core.entity_helpers import (
+ entity_duplicate_validator,
+ queue_entity_register,
+ setup_entity,
+)
from esphome.cpp_generator import MockObjClass
CODEOWNERS = ["@grahambrown11", "@hwstar"]
@@ -181,7 +185,7 @@ async def setup_alarm_control_panel_core_(var, config):
async def register_alarm_control_panel(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
- cg.add(cg.App.register_alarm_control_panel(var))
+ queue_entity_register("alarm_control_panel", config)
CORE.register_platform_component("alarm_control_panel", var)
await setup_alarm_control_panel_core_(var, config)
diff --git a/esphome/components/alpha3/alpha3.cpp b/esphome/components/alpha3/alpha3.cpp
index 6e82ec047..048c36561 100644
--- a/esphome/components/alpha3/alpha3.cpp
+++ b/esphome/components/alpha3/alpha3.cpp
@@ -5,8 +5,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace alpha3 {
+namespace esphome::alpha3 {
static const char *const TAG = "alpha3";
@@ -185,7 +184,6 @@ void Alpha3::update() {
delay(25); // need to wait between requests
}
}
-} // namespace alpha3
-} // namespace esphome
+} // namespace esphome::alpha3
#endif
diff --git a/esphome/components/alpha3/alpha3.h b/esphome/components/alpha3/alpha3.h
index 19d8e9933..c63129031 100644
--- a/esphome/components/alpha3/alpha3.h
+++ b/esphome/components/alpha3/alpha3.h
@@ -9,8 +9,7 @@
#include
-namespace esphome {
-namespace alpha3 {
+namespace esphome::alpha3 {
namespace espbt = esphome::esp32_ble_tracker;
@@ -64,7 +63,6 @@ class Alpha3 : public esphome::ble_client::BLEClientNode, public PollingComponen
void send_request_(uint8_t *request, size_t len);
bool is_current_response_type_(const uint8_t *response_type);
};
-} // namespace alpha3
-} // namespace esphome
+} // namespace esphome::alpha3
#endif
diff --git a/esphome/components/am2315c/am2315c.cpp b/esphome/components/am2315c/am2315c.cpp
index 1390b7497..8980a8dfc 100644
--- a/esphome/components/am2315c/am2315c.cpp
+++ b/esphome/components/am2315c/am2315c.cpp
@@ -24,8 +24,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace am2315c {
+namespace esphome::am2315c {
static const char *const TAG = "am2315c";
@@ -176,5 +175,4 @@ void AM2315C::dump_config() {
LOG_SENSOR(" ", "Humidity", this->humidity_sensor_);
}
-} // namespace am2315c
-} // namespace esphome
+} // namespace esphome::am2315c
diff --git a/esphome/components/am2315c/am2315c.h b/esphome/components/am2315c/am2315c.h
index d7baf01ca..5a959af4c 100644
--- a/esphome/components/am2315c/am2315c.h
+++ b/esphome/components/am2315c/am2315c.h
@@ -25,8 +25,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
-namespace esphome {
-namespace am2315c {
+namespace esphome::am2315c {
class AM2315C : public PollingComponent, public i2c::I2CDevice {
public:
@@ -45,5 +44,4 @@ class AM2315C : public PollingComponent, public i2c::I2CDevice {
sensor::Sensor *humidity_sensor_{nullptr};
};
-} // namespace am2315c
-} // namespace esphome
+} // namespace esphome::am2315c
diff --git a/esphome/components/am2320/am2320.cpp b/esphome/components/am2320/am2320.cpp
index 7fef3bb3a..5445ab389 100644
--- a/esphome/components/am2320/am2320.cpp
+++ b/esphome/components/am2320/am2320.cpp
@@ -8,8 +8,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace am2320 {
+namespace esphome::am2320 {
static const char *const TAG = "am2320";
@@ -86,5 +85,4 @@ bool AM2320Component::read_data_(uint8_t *data) {
return true;
}
-} // namespace am2320
-} // namespace esphome
+} // namespace esphome::am2320
diff --git a/esphome/components/am2320/am2320.h b/esphome/components/am2320/am2320.h
index 708dbb632..ddb5c6f16 100644
--- a/esphome/components/am2320/am2320.h
+++ b/esphome/components/am2320/am2320.h
@@ -4,8 +4,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
-namespace esphome {
-namespace am2320 {
+namespace esphome::am2320 {
class AM2320Component : public PollingComponent, public i2c::I2CDevice {
public:
@@ -24,5 +23,4 @@ class AM2320Component : public PollingComponent, public i2c::I2CDevice {
sensor::Sensor *humidity_sensor_{nullptr};
};
-} // namespace am2320
-} // namespace esphome
+} // namespace esphome::am2320
diff --git a/esphome/components/am43/am43_base.cpp b/esphome/components/am43/am43_base.cpp
index d70e63838..977185e5e 100644
--- a/esphome/components/am43/am43_base.cpp
+++ b/esphome/components/am43/am43_base.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/helpers.h"
#include
-namespace esphome {
-namespace am43 {
+namespace esphome::am43 {
const uint8_t START_PACKET[5] = {0x00, 0xff, 0x00, 0x00, 0x9a};
@@ -134,5 +133,4 @@ void Am43Decoder::decode(const uint8_t *data, uint16_t length) {
}
};
-} // namespace am43
-} // namespace esphome
+} // namespace esphome::am43
diff --git a/esphome/components/am43/am43_base.h b/esphome/components/am43/am43_base.h
index 35354af9e..5df83747c 100644
--- a/esphome/components/am43/am43_base.h
+++ b/esphome/components/am43/am43_base.h
@@ -3,8 +3,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace am43 {
+namespace esphome::am43 {
static const uint16_t AM43_SERVICE_UUID = 0xFE50;
static const uint16_t AM43_CHARACTERISTIC_UUID = 0xFE51;
@@ -74,5 +73,4 @@ class Am43Decoder {
bool has_pin_response_;
};
-} // namespace am43
-} // namespace esphome
+} // namespace esphome::am43
diff --git a/esphome/components/am43/cover/am43_cover.cpp b/esphome/components/am43/cover/am43_cover.cpp
index 2fa26d266..35366dbaa 100644
--- a/esphome/components/am43/cover/am43_cover.cpp
+++ b/esphome/components/am43/cover/am43_cover.cpp
@@ -3,8 +3,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace am43 {
+namespace esphome::am43 {
static const char *const TAG = "am43_cover";
@@ -154,7 +153,6 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
}
}
-} // namespace am43
-} // namespace esphome
+} // namespace esphome::am43
#endif
diff --git a/esphome/components/am43/cover/am43_cover.h b/esphome/components/am43/cover/am43_cover.h
index d6d020e98..aa48aced1 100644
--- a/esphome/components/am43/cover/am43_cover.h
+++ b/esphome/components/am43/cover/am43_cover.h
@@ -10,8 +10,7 @@
#include
-namespace esphome {
-namespace am43 {
+namespace esphome::am43 {
namespace espbt = esphome::esp32_ble_tracker;
@@ -38,7 +37,6 @@ class Am43Component : public cover::Cover, public esphome::ble_client::BLEClient
float position_;
};
-} // namespace am43
-} // namespace esphome
+} // namespace esphome::am43
#endif
diff --git a/esphome/components/am43/sensor/am43_sensor.cpp b/esphome/components/am43/sensor/am43_sensor.cpp
index b2bc3254e..ddc3eadae 100644
--- a/esphome/components/am43/sensor/am43_sensor.cpp
+++ b/esphome/components/am43/sensor/am43_sensor.cpp
@@ -4,8 +4,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace am43 {
+namespace esphome::am43 {
static const char *const TAG = "am43";
@@ -111,7 +110,6 @@ void Am43::update() {
}
}
-} // namespace am43
-} // namespace esphome
+} // namespace esphome::am43
#endif
diff --git a/esphome/components/am43/sensor/am43_sensor.h b/esphome/components/am43/sensor/am43_sensor.h
index 195b96a19..9198a5cbc 100644
--- a/esphome/components/am43/sensor/am43_sensor.h
+++ b/esphome/components/am43/sensor/am43_sensor.h
@@ -10,8 +10,7 @@
#include
-namespace esphome {
-namespace am43 {
+namespace esphome::am43 {
namespace espbt = esphome::esp32_ble_tracker;
@@ -38,7 +37,6 @@ class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent
uint32_t last_battery_update_;
};
-} // namespace am43
-} // namespace esphome
+} // namespace esphome::am43
#endif
diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp
index 0b3bd0e47..d25c10021 100644
--- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp
+++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp
@@ -1,8 +1,7 @@
#include "analog_threshold_binary_sensor.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace analog_threshold {
+namespace esphome::analog_threshold {
static const char *const TAG = "analog_threshold.binary_sensor";
@@ -43,5 +42,4 @@ void AnalogThresholdBinarySensor::dump_config() {
this->upper_threshold_.value(), this->lower_threshold_.value());
}
-} // namespace analog_threshold
-} // namespace esphome
+} // namespace esphome::analog_threshold
diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h
index 55a822b9b..c768f1f82 100644
--- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h
+++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h
@@ -5,8 +5,7 @@
#include "esphome/components/binary_sensor/binary_sensor.h"
#include "esphome/components/sensor/sensor.h"
-namespace esphome {
-namespace analog_threshold {
+namespace esphome::analog_threshold {
class AnalogThresholdBinarySensor : public Component, public binary_sensor::BinarySensor {
public:
@@ -24,5 +23,4 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina
bool raw_state_{false}; // Pre-filter state for hysteresis logic
};
-} // namespace analog_threshold
-} // namespace esphome
+} // namespace esphome::analog_threshold
diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp
index c2ae3b2f7..2a293adf1 100644
--- a/esphome/components/animation/animation.cpp
+++ b/esphome/components/animation/animation.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/hal.h"
-namespace esphome {
-namespace animation {
+namespace esphome::animation {
Animation::Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count,
image::ImageType type, image::Transparency transparent)
@@ -62,9 +61,13 @@ void Animation::set_frame(int frame) {
}
void Animation::update_data_start_() {
- const uint32_t image_size = this->get_width_stride() * this->height_;
+ uint32_t image_size = this->get_width_stride() * this->height_;
+ // RGB565 with an alpha channel stores the alpha plane immediately after the RGB
+ // plane within each frame, so the per-frame stride includes the alpha bytes.
+ if (this->type_ == image::IMAGE_TYPE_RGB565 && this->transparency_ == image::TRANSPARENCY_ALPHA_CHANNEL) {
+ image_size += static_cast(this->width_) * this->height_;
+ }
this->data_start_ = this->animation_data_start_ + image_size * this->current_frame_;
}
-} // namespace animation
-} // namespace esphome
+} // namespace esphome::animation
diff --git a/esphome/components/animation/animation.h b/esphome/components/animation/animation.h
index b33254df3..ca800ad93 100644
--- a/esphome/components/animation/animation.h
+++ b/esphome/components/animation/animation.h
@@ -3,8 +3,7 @@
#include "esphome/core/automation.h"
-namespace esphome {
-namespace animation {
+namespace esphome::animation {
class Animation : public image::Image {
public:
@@ -64,5 +63,4 @@ template class AnimationSetFrameAction : public Action {
Animation *parent_;
};
-} // namespace animation
-} // namespace esphome
+} // namespace esphome::animation
diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp
index f21230b07..6e382872e 100644
--- a/esphome/components/anova/anova.cpp
+++ b/esphome/components/anova/anova.cpp
@@ -3,8 +3,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace anova {
+namespace esphome::anova {
static const char *const TAG = "anova";
@@ -160,7 +159,6 @@ void Anova::update() {
}
}
-} // namespace anova
-} // namespace esphome
+} // namespace esphome::anova
#endif
diff --git a/esphome/components/anova/anova.h b/esphome/components/anova/anova.h
index 2e43ebfb9..a3e175be2 100644
--- a/esphome/components/anova/anova.h
+++ b/esphome/components/anova/anova.h
@@ -10,8 +10,7 @@
#include
-namespace esphome {
-namespace anova {
+namespace esphome::anova {
namespace espbt = esphome::esp32_ble_tracker;
@@ -45,7 +44,6 @@ class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode
bool fahrenheit_;
};
-} // namespace anova
-} // namespace esphome
+} // namespace esphome::anova
#endif
diff --git a/esphome/components/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp
index a14dd728a..84dd4393e 100644
--- a/esphome/components/anova/anova_base.cpp
+++ b/esphome/components/anova/anova_base.cpp
@@ -4,8 +4,7 @@
#include "esphome/core/alloc_helpers.h"
-namespace esphome {
-namespace anova {
+namespace esphome::anova {
float ftoc(float f) { return (f - 32.0) * (5.0f / 9.0f); }
@@ -132,5 +131,4 @@ void AnovaCodec::decode(const uint8_t *data, uint16_t length) {
}
}
-} // namespace anova
-} // namespace esphome
+} // namespace esphome::anova
diff --git a/esphome/components/anova/anova_base.h b/esphome/components/anova/anova_base.h
index b83115784..b3ed0f01a 100644
--- a/esphome/components/anova/anova_base.h
+++ b/esphome/components/anova/anova_base.h
@@ -3,8 +3,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace anova {
+namespace esphome::anova {
enum CurrentQuery {
NONE,
@@ -75,5 +74,4 @@ class AnovaCodec {
CurrentQuery current_query_;
};
-} // namespace anova
-} // namespace esphome
+} // namespace esphome::anova
diff --git a/esphome/components/apds9306/apds9306.cpp b/esphome/components/apds9306/apds9306.cpp
index fb3adde86..57a502ca4 100644
--- a/esphome/components/apds9306/apds9306.cpp
+++ b/esphome/components/apds9306/apds9306.cpp
@@ -5,8 +5,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace apds9306 {
+namespace esphome::apds9306 {
static const char *const TAG = "apds9306";
@@ -147,5 +146,4 @@ void APDS9306::update() {
this->publish_state(lux);
}
-} // namespace apds9306
-} // namespace esphome
+} // namespace esphome::apds9306
diff --git a/esphome/components/apds9306/apds9306.h b/esphome/components/apds9306/apds9306.h
index 44362908c..093ec55bc 100644
--- a/esphome/components/apds9306/apds9306.h
+++ b/esphome/components/apds9306/apds9306.h
@@ -7,8 +7,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/core/component.h"
-namespace esphome {
-namespace apds9306 {
+namespace esphome::apds9306 {
enum MeasurementBitWidth : uint8_t {
MEASUREMENT_BIT_WIDTH_20 = 0,
@@ -62,5 +61,4 @@ class APDS9306 : public sensor::Sensor, public PollingComponent, public i2c::I2C
AmbientLightGain gain_;
};
-} // namespace apds9306
-} // namespace esphome
+} // namespace esphome::apds9306
diff --git a/esphome/components/apds9960/apds9960.cpp b/esphome/components/apds9960/apds9960.cpp
index a07175f2c..da8029b4e 100644
--- a/esphome/components/apds9960/apds9960.cpp
+++ b/esphome/components/apds9960/apds9960.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
-namespace esphome {
-namespace apds9960 {
+namespace esphome::apds9960 {
static const char *const TAG = "apds9960";
@@ -402,5 +401,4 @@ bool APDS9960::is_gesture_enabled_() const {
#endif
}
-} // namespace apds9960
-} // namespace esphome
+} // namespace esphome::apds9960
diff --git a/esphome/components/apds9960/apds9960.h b/esphome/components/apds9960/apds9960.h
index 4574b70a4..282329420 100644
--- a/esphome/components/apds9960/apds9960.h
+++ b/esphome/components/apds9960/apds9960.h
@@ -10,8 +10,7 @@
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
-namespace esphome {
-namespace apds9960 {
+namespace esphome::apds9960 {
class APDS9960 : public PollingComponent, public i2c::I2CDevice {
#ifdef USE_SENSOR
@@ -71,5 +70,4 @@ class APDS9960 : public PollingComponent, public i2c::I2CDevice {
uint32_t gesture_start_{0};
};
-} // namespace apds9960
-} // namespace esphome
+} // namespace esphome::apds9960
diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py
index ad778f20a..932702d47 100644
--- a/esphome/components/api/__init__.py
+++ b/esphome/components/api/__init__.py
@@ -72,17 +72,35 @@ APIUnregisterServiceCallAction = api_ns.class_(
UserServiceTrigger = api_ns.class_("UserServiceTrigger", automation.Trigger)
ListEntitiesServicesArgument = api_ns.class_("ListEntitiesServicesArgument")
-SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = {
+# Owning element type for each YAML service variable type. Used to derive both
+# the zero-copy native types and the owning fallback types below.
+_SERVICE_ARG_SCALAR_TYPES: dict[str, MockObj] = {
"bool": cg.bool_,
"int": cg.int32,
"float": cg.float_,
+ "string": cg.std_string,
+}
+SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = {
+ # Scalars are passed by value; string uses a non-owning view into rx_buf_.
+ **_SERVICE_ARG_SCALAR_TYPES,
"string": cg.StringRef,
- "bool[]": cg.FixedVector.template(cg.bool_).operator("const").operator("ref"),
- "int[]": cg.FixedVector.template(cg.int32).operator("const").operator("ref"),
- "float[]": cg.FixedVector.template(cg.float_).operator("const").operator("ref"),
- "string[]": cg.FixedVector.template(cg.std_string)
- .operator("const")
- .operator("ref"),
+ # Arrays are passed as non-owning const references into rx_buf_.
+ **{
+ f"{name}[]": cg.FixedVector.template(t).operator("const").operator("ref")
+ for name, t in _SERVICE_ARG_SCALAR_TYPES.items()
+ },
+}
+# Owning fallback types used when the action chain contains non-synchronous actions
+# (delay, wait_until, script.wait, etc.). The default non-owning types reference
+# storage in the receive buffer, which is reused once the synchronous portion of
+# the chain returns. FixedVector is also non-copyable, so the deferred lambda
+# capture in DelayAction::play_complex would fail to compile.
+SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
+ "string": cg.std_string,
+ **{
+ f"{name}[]": cg.std_vector.template(t)
+ for name, t in _SERVICE_ARG_SCALAR_TYPES.items()
+ },
}
CONF_ENCRYPTION = "encryption"
CONF_BATCH_DELAY = "batch_delay"
@@ -216,7 +234,7 @@ ACTIONS_SCHEMA = automation.validate_automation(
ENCRYPTION_SCHEMA = cv.Schema(
{
- cv.Optional(CONF_KEY): validate_encryption_key,
+ cv.Optional(CONF_KEY): cv.sensitive(validate_encryption_key),
}
)
@@ -381,17 +399,20 @@ async def to_code(config: ConfigType) -> None:
func_args.append((cg.bool_, "return_response"))
# Check if action chain has non-synchronous actions that would make
- # non-owning StringRef dangle (rx_buf_ reused after delay)
+ # non-owning args (StringRef, const FixedVector&) dangle once the
+ # rx_buf_ is reused after a delay/wait_until/script.wait/etc. The
+ # FixedVector references would also fail to compile because they
+ # are non-copyable and DelayAction captures args by value.
has_non_synchronous = automation.has_non_synchronous_actions(
conf.get(CONF_THEN, [])
)
service_arg_names: list[str] = []
for name, var_ in conf[CONF_VARIABLES].items():
- native = SERVICE_ARG_NATIVE_TYPES[var_]
- # Fall back to std::string for string args if non-synchronous actions exist
- if has_non_synchronous and native is cg.StringRef:
- native = cg.std_string
+ if has_non_synchronous and var_ in SERVICE_ARG_FALLBACK_TYPES:
+ native = SERVICE_ARG_FALLBACK_TYPES[var_]
+ else:
+ native = SERVICE_ARG_NATIVE_TYPES[var_]
service_template_args.append(native)
func_args.append((native, name))
service_arg_names.append(name)
diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto
index c3e4c3863..f4f15c104 100644
--- a/esphome/components/api/api.proto
+++ b/esphome/components/api/api.proto
@@ -1025,6 +1025,13 @@ message CameraImageRequest {
bool stream = 2;
}
+// ==================== TEMPERATURE UNIT ====================
+enum TemperatureUnit {
+ TEMPERATURE_UNIT_CELSIUS = 0;
+ TEMPERATURE_UNIT_FAHRENHEIT = 1;
+ TEMPERATURE_UNIT_KELVIN = 2;
+}
+
// ==================== CLIMATE ====================
enum ClimateMode {
CLIMATE_MODE_OFF = 0;
@@ -1110,6 +1117,7 @@ message ListEntitiesClimateResponse {
float visual_max_humidity = 25;
uint32 device_id = 26 [(field_ifdef) = "USE_DEVICES"];
uint32 feature_flags = 27;
+ TemperatureUnit temperature_unit = 28;
}
message ClimateStateResponse {
option (id) = 47;
@@ -1203,6 +1211,7 @@ message ListEntitiesWaterHeaterResponse {
repeated WaterHeaterMode supported_modes = 11 [(container_pointer_no_template) = "water_heater::WaterHeaterModeMask"];
// Bitmask of WaterHeaterFeature flags
uint32 supported_features = 12;
+ TemperatureUnit temperature_unit = 13;
}
message WaterHeaterStateResponse {
@@ -1410,6 +1419,8 @@ enum LockState {
LOCK_STATE_JAMMED = 3;
LOCK_STATE_LOCKING = 4;
LOCK_STATE_UNLOCKING = 5;
+ LOCK_STATE_OPENING = 6;
+ LOCK_STATE_OPEN = 7;
}
enum LockCommand {
LOCK_UNLOCK = 0;
@@ -1628,7 +1639,7 @@ message BluetoothLEAdvertisementResponse {
message BluetoothLERawAdvertisement {
option (inline_encode) = true;
- uint64 address = 1 [(force) = true];
+ uint64 address = 1 [(force) = true, (mac_address) = true];
sint32 rssi = 2 [(force) = true];
uint32 address_type = 3 [(max_value) = 4];
@@ -2015,6 +2026,7 @@ message VoiceAssistantAudio {
bytes data = 1 [(pointer_to_buffer) = true];
bool end = 2;
+ bytes data2 = 3 [(pointer_to_buffer) = true];
}
enum VoiceAssistantTimerEvent {
@@ -2500,6 +2512,7 @@ message ZWaveProxyFrame {
option (source) = SOURCE_BOTH;
option (ifdef) = "USE_ZWAVE_PROXY";
option (no_delay) = true;
+ option (speed_optimized) = true;
bytes data = 1;
}
@@ -2560,6 +2573,7 @@ message InfraredRFReceiveEvent {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
option (no_delay) = true;
+ option (speed_optimized) = true;
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance
@@ -2616,6 +2630,7 @@ message SerialProxyDataReceived {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SERIAL_PROXY";
option (no_delay) = true;
+ option (speed_optimized) = true;
uint32 instance = 1; // Instance index (0-based)
bytes data = 2; // Raw data received from the serial device
diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp
index b6f4aa214..2b1458e2a 100644
--- a/esphome/components/api/api_connection.cpp
+++ b/esphome/components/api/api_connection.cpp
@@ -1,5 +1,6 @@
#include "api_connection.h"
#ifdef USE_API
+#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
#endif
@@ -1168,7 +1169,7 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) {
void APIConnection::on_get_time_response(const GetTimeResponse &value) {
if (homeassistant::global_homeassistant_time != nullptr) {
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
-#ifdef USE_TIME_TIMEZONE
+#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE)
if (!value.timezone.empty()) {
// Check if the sender provided pre-parsed timezone data.
// If std_offset is non-zero or DST rules are present, the parsed data was populated.
@@ -1305,6 +1306,9 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
VoiceAssistantConfigurationResponse resp;
if (!this->check_voice_assistant_api_connection_()) {
+ // send_message encodes synchronously, so this stack local outlives the encode
+ const std::vector empty_wake_words;
+ resp.active_wake_words = &empty_wake_words;
return this->send_message(resp);
}
diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h
index 4165b7f3a..804cd9ddd 100644
--- a/esphome/components/api/api_connection.h
+++ b/esphome/components/api/api_connection.h
@@ -11,7 +11,8 @@
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
-#include "api_server.h"
+#include "list_entities.h"
+#include "subscribe_state.h"
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#ifdef USE_ESP32_CRASH_HANDLER
@@ -36,6 +37,9 @@ class ComponentIterator;
namespace esphome::api {
+// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
+class APIServer;
+
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending
@@ -411,44 +415,10 @@ class APIConnection final : public APIServerConnectionBase {
// Non-template buffer management for send_message
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
- // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
- // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
- static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
- const void *msg, APIConnection *conn,
- uint32_t remaining_size) {
-#ifdef HAS_PROTO_MESSAGE_DUMP
- if (conn->flags_.log_only_mode) {
- auto *proto_msg = static_cast(msg);
- DumpBuffer dump_buf;
- conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
- return 1;
- }
-#endif
- const uint8_t footer_size = conn->helper_->frame_footer_size();
-
- // First message uses max padding (already in buffer), subsequent use exact header size
- size_t to_add;
- if (conn->flags_.batch_first_message) {
- conn->flags_.batch_first_message = false;
- conn->batch_header_size_ = conn->helper_->frame_header_padding();
- to_add = calculated_size;
- } else {
- conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
- to_add = calculated_size + conn->batch_header_size_ + footer_size;
- }
-
- // Check if it fits (using actual header size, not max padding)
- uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
- if (total_calculated_size > remaining_size)
- return 0;
-
- auto &shared_buf = conn->parent_->get_shared_buffer_ref();
- shared_buf.resize(shared_buf.size() + to_add);
- ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
- encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
-
- return total_calculated_size;
- }
+ // Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
+ // Defined in api_connection_buffer.h (needs APIServer complete).
+ static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
+ const void *msg, APIConnection *conn, uint32_t remaining_size);
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
@@ -792,7 +762,8 @@ class APIConnection final : public APIServerConnectionBase {
// Read by process_batch_multi_ to pass into MessageInfo.
uint8_t batch_header_size_{0};
- uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
+ // Defined in api_connection_buffer.h (needs APIServer complete).
+ uint32_t get_batch_delay_ms_() const;
// Message will use 8 more bytes than the minimum size, and typical
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
// If its IPv6 the header is 40 bytes, and if its IPv4
diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h
new file mode 100644
index 000000000..1dd8a162e
--- /dev/null
+++ b/esphome/components/api/api_connection_buffer.h
@@ -0,0 +1,54 @@
+#pragma once
+
+#include "esphome/core/defines.h"
+#ifdef USE_API
+
+// Inline APIConnection methods that need APIServer complete. Include this
+// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
+
+#include "api_connection.h"
+#include "api_server.h"
+
+namespace esphome::api {
+
+inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size,
+ MessageEncodeFn encode_fn, const void *msg,
+ APIConnection *conn, uint32_t remaining_size) {
+#ifdef HAS_PROTO_MESSAGE_DUMP
+ if (conn->flags_.log_only_mode) {
+ auto *proto_msg = static_cast(msg);
+ DumpBuffer dump_buf;
+ conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
+ return 1;
+ }
+#endif
+ const uint8_t footer_size = conn->helper_->frame_footer_size();
+
+ // First message uses max padding (already in buffer), subsequent use exact header size
+ size_t to_add;
+ if (conn->flags_.batch_first_message) {
+ conn->flags_.batch_first_message = false;
+ conn->batch_header_size_ = conn->helper_->frame_header_padding();
+ to_add = calculated_size;
+ } else {
+ conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
+ to_add = calculated_size + conn->batch_header_size_ + footer_size;
+ }
+
+ // Check if it fits (using actual header size, not max padding)
+ uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
+ if (total_calculated_size > remaining_size)
+ return 0;
+
+ auto &shared_buf = conn->parent_->get_shared_buffer_ref();
+ shared_buf.resize(shared_buf.size() + to_add);
+ ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
+ encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
+
+ return total_calculated_size;
+}
+
+inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
+
+} // namespace esphome::api
+#endif
diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto
index d5d0b37e8..ac9c4e59c 100644
--- a/esphome/components/api/api_options.proto
+++ b/esphome/components/api/api_options.proto
@@ -110,4 +110,10 @@ extend google.protobuf.FieldOptions {
// length varint calculations and direct byte writes, since the length
// varint is guaranteed to be 1 byte.
optional uint32 max_data_length = 50018;
+
+ // mac_address: Field is a 48-bit MAC address stored in a uint64.
+ // Emits encode_varint_raw_48bit which has a 7-byte fast path that avoids
+ // the per-byte loop when the upper bits are non-zero (the common case
+ // for real MAC addresses, since OUIs occupy the top 24 bits).
+ optional bool mac_address = 50019 [default=false];
}
diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp
index 3d1245393..c711ef167 100644
--- a/esphome/components/api/api_pb2.cpp
+++ b/esphome/components/api/api_pb2.cpp
@@ -1439,6 +1439,7 @@ uint8_t *ListEntitiesClimateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 26, this->device_id);
#endif
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 27, this->feature_flags);
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 28, static_cast(this->temperature_unit));
return pos;
}
uint32_t ListEntitiesClimateResponse::calculate_size() const {
@@ -1488,6 +1489,7 @@ uint32_t ListEntitiesClimateResponse::calculate_size() const {
size += ProtoSize::calc_uint32(2, this->device_id);
#endif
size += ProtoSize::calc_uint32(2, this->feature_flags);
+ size += this->temperature_unit ? 3 : 0;
return size;
}
uint8_t *ClimateStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -1645,6 +1647,7 @@ uint8_t *ListEntitiesWaterHeaterResponse::encode(ProtoWriteBuffer &buffer PROTO_
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, static_cast(it), true);
}
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 12, this->supported_features);
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 13, static_cast(this->temperature_unit));
return pos;
}
uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const {
@@ -1667,6 +1670,7 @@ uint32_t ListEntitiesWaterHeaterResponse::calculate_size() const {
size += this->supported_modes->size() * 2;
}
size += ProtoSize::calc_uint32(1, this->supported_features);
+ size += this->temperature_unit ? 2 : 0;
return size;
}
uint8_t *WaterHeaterStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -2348,7 +2352,7 @@ BluetoothLERawAdvertisementsResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCO
uint8_t *len_pos = pos;
ProtoEncode::reserve_byte(pos PROTO_ENCODE_DEBUG_ARG);
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 8);
- ProtoEncode::encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address);
+ ProtoEncode::encode_varint_raw_48bit(pos PROTO_ENCODE_DEBUG_ARG, sub_msg.address);
ProtoEncode::write_raw_byte(pos PROTO_ENCODE_DEBUG_ARG, 16);
ProtoEncode::encode_varint_raw_short(pos PROTO_ENCODE_DEBUG_ARG, encode_zigzag32(sub_msg.rssi));
if (sub_msg.address_type) {
@@ -2369,7 +2373,7 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const {
for (uint16_t i = 0; i < this->advertisements_len; i++) {
auto &sub_msg = this->advertisements[i];
size += 2;
- size += ProtoSize::calc_uint64_force(1, sub_msg.address);
+ size += ProtoSize::calc_uint64_48bit_force(1, sub_msg.address);
size += ProtoSize::calc_sint32_force(1, sub_msg.rssi);
size += sub_msg.address_type ? 2 : 0;
size += 2 + sub_msg.data_len;
@@ -2889,6 +2893,11 @@ bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited
this->data_len = value.size();
break;
}
+ case 3: {
+ this->data2 = value.data();
+ this->data2_len = value.size();
+ break;
+ }
default:
return false;
}
@@ -2898,12 +2907,14 @@ uint8_t *VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len);
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end);
+ ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data2, this->data2_len);
return pos;
}
uint32_t VoiceAssistantAudio::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_length(1, this->data_len);
size += ProtoSize::calc_bool(1, this->end);
+ size += ProtoSize::calc_length(1, this->data2_len);
return size;
}
bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) {
@@ -3780,12 +3791,16 @@ bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited valu
}
return true;
}
-uint8_t *ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
+uint8_t *
+ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len);
return pos;
}
-uint32_t ZWaveProxyFrame::calculate_size() const {
+__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
+uint32_t
+ZWaveProxyFrame::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_length(1, this->data_len);
return size;
@@ -3906,7 +3921,9 @@ bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto3
}
return true;
}
-uint8_t *InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
+uint8_t *
+InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
#ifdef USE_DEVICES
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id);
@@ -3917,7 +3934,9 @@ uint8_t *InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DE
}
return pos;
}
-uint32_t InfraredRFReceiveEvent::calculate_size() const {
+__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
+uint32_t
+InfraredRFReceiveEvent::calculate_size() const {
uint32_t size = 0;
#ifdef USE_DEVICES
size += ProtoSize::calc_uint32(1, this->device_id);
@@ -3997,13 +4016,17 @@ bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_
}
return true;
}
-uint8_t *SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
+uint8_t *
+SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance);
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_);
return pos;
}
-uint32_t SerialProxyDataReceived::calculate_size() const {
+__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
+uint32_t
+SerialProxyDataReceived::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->instance);
size += ProtoSize::calc_length(1, this->data_len_);
diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h
index 5aa592e4f..7e926ee0d 100644
--- a/esphome/components/api/api_pb2.h
+++ b/esphome/components/api/api_pb2.h
@@ -92,6 +92,11 @@ enum SupportsResponseType : uint32_t {
SUPPORTS_RESPONSE_STATUS = 100,
};
#endif
+enum TemperatureUnit : uint32_t {
+ TEMPERATURE_UNIT_CELSIUS = 0,
+ TEMPERATURE_UNIT_FAHRENHEIT = 1,
+ TEMPERATURE_UNIT_KELVIN = 2,
+};
#ifdef USE_CLIMATE
enum ClimateMode : uint32_t {
CLIMATE_MODE_OFF = 0,
@@ -176,6 +181,8 @@ enum LockState : uint32_t {
LOCK_STATE_JAMMED = 3,
LOCK_STATE_LOCKING = 4,
LOCK_STATE_UNLOCKING = 5,
+ LOCK_STATE_OPENING = 6,
+ LOCK_STATE_OPEN = 7,
};
enum LockCommand : uint32_t {
LOCK_UNLOCK = 0,
@@ -1372,7 +1379,7 @@ class CameraImageRequest final : public ProtoDecodableMessage {
class ListEntitiesClimateResponse final : public InfoResponseProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 46;
- static constexpr uint8_t ESTIMATED_SIZE = 150;
+ static constexpr uint8_t ESTIMATED_SIZE = 153;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_climate_response"); }
#endif
@@ -1394,6 +1401,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage {
float visual_min_humidity{0.0f};
float visual_max_humidity{0.0f};
uint32_t feature_flags{0};
+ enums::TemperatureUnit temperature_unit{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
@@ -1471,7 +1479,7 @@ class ClimateCommandRequest final : public CommandProtoMessage {
class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 132;
- static constexpr uint8_t ESTIMATED_SIZE = 63;
+ static constexpr uint8_t ESTIMATED_SIZE = 65;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_water_heater_response"); }
#endif
@@ -1480,6 +1488,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage {
float target_temperature_step{0.0f};
const water_heater::WaterHeaterModeMask *supported_modes{};
uint32_t supported_features{0};
+ enums::TemperatureUnit temperature_unit{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
@@ -2427,13 +2436,15 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage {
class VoiceAssistantAudio final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 106;
- static constexpr uint8_t ESTIMATED_SIZE = 21;
+ static constexpr uint8_t ESTIMATED_SIZE = 40;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_audio"); }
#endif
const uint8_t *data{nullptr};
uint16_t data_len{0};
bool end{false};
+ const uint8_t *data2{nullptr};
+ uint16_t data2_len{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp
index bdcb6d414..850ad37bc 100644
--- a/esphome/components/api/api_pb2_dump.cpp
+++ b/esphome/components/api/api_pb2_dump.cpp
@@ -297,6 +297,18 @@ template<> const char *proto_enum_to_string(enums::
}
}
#endif
+template<> const char *proto_enum_to_string(enums::TemperatureUnit value) {
+ switch (value) {
+ case enums::TEMPERATURE_UNIT_CELSIUS:
+ return ESPHOME_PSTR("TEMPERATURE_UNIT_CELSIUS");
+ case enums::TEMPERATURE_UNIT_FAHRENHEIT:
+ return ESPHOME_PSTR("TEMPERATURE_UNIT_FAHRENHEIT");
+ case enums::TEMPERATURE_UNIT_KELVIN:
+ return ESPHOME_PSTR("TEMPERATURE_UNIT_KELVIN");
+ default:
+ return ESPHOME_PSTR("UNKNOWN");
+ }
+}
#ifdef USE_CLIMATE
template<> const char *proto_enum_to_string(enums::ClimateMode value) {
switch (value) {
@@ -475,6 +487,10 @@ template<> const char *proto_enum_to_string(enums::LockState v
return ESPHOME_PSTR("LOCK_STATE_LOCKING");
case enums::LOCK_STATE_UNLOCKING:
return ESPHOME_PSTR("LOCK_STATE_UNLOCKING");
+ case enums::LOCK_STATE_OPENING:
+ return ESPHOME_PSTR("LOCK_STATE_OPENING");
+ case enums::LOCK_STATE_OPEN:
+ return ESPHOME_PSTR("LOCK_STATE_OPEN");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -1539,6 +1555,7 @@ const char *ListEntitiesClimateResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
#endif
dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
+ dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast(this->temperature_unit));
return out.c_str();
}
const char *ClimateStateResponse::dump_to(DumpBuffer &out) const {
@@ -1612,6 +1629,7 @@ const char *ListEntitiesWaterHeaterResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("supported_modes"), static_cast(it), 4);
}
dump_field(out, ESPHOME_PSTR("supported_features"), this->supported_features);
+ dump_field(out, ESPHOME_PSTR("temperature_unit"), static_cast(this->temperature_unit));
return out.c_str();
}
const char *WaterHeaterStateResponse::dump_to(DumpBuffer &out) const {
@@ -2156,6 +2174,7 @@ const char *VoiceAssistantAudio::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantAudio"));
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
dump_field(out, ESPHOME_PSTR("end"), this->end);
+ dump_bytes_field(out, ESPHOME_PSTR("data2"), this->data2, this->data2_len);
return out.c_str();
}
const char *VoiceAssistantTimerEventResponse::dump_to(DumpBuffer &out) const {
diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp
index 6ae2a3e36..0ba2961a1 100644
--- a/esphome/components/api/api_pb2_service.cpp
+++ b/esphome/components/api/api_pb2_service.cpp
@@ -21,6 +21,7 @@ void APIServerConnectionBase::log_receive_message_(const LogString *name) {
}
#endif
+#ifdef USE_API
void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {
// Check authentication/connection requirements
switch (msg_type) {
@@ -706,5 +707,6 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
}
+#endif // USE_API
} // namespace esphome::api
diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp
index c30bd2e61..031fa342c 100644
--- a/esphome/components/api/api_server.cpp
+++ b/esphome/components/api/api_server.cpp
@@ -1,6 +1,7 @@
#include "api_server.h"
#ifdef USE_API
#include
+#include
#include "api_connection.h"
#include "esphome/components/network/util.h"
#include "esphome/core/application.h"
@@ -677,7 +678,7 @@ uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConn
// Schedule automatic cleanup after timeout (client will have given up by then)
// Uses numeric ID overload to avoid heap allocation from str_sprintf
this->set_timeout(action_call_id, USE_API_ACTION_CALL_TIMEOUT_MS, [this, action_call_id]() {
- ESP_LOGD(TAG, "Action call %u timed out", action_call_id);
+ ESP_LOGD(TAG, "Action call %" PRIu32 " timed out", action_call_id);
this->unregister_active_action_call(action_call_id);
});
@@ -721,7 +722,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
return;
}
}
- ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id);
+ ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id);
}
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
void APIServer::send_action_response(uint32_t action_call_id, bool success, StringRef error_message,
@@ -733,7 +734,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
return;
}
}
- ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id);
+ ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id);
}
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h
index e662d78eb..fbc811509 100644
--- a/esphome/components/api/api_server.h
+++ b/esphome/components/api/api_server.h
@@ -3,6 +3,8 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_buffer.h"
+// Must precede clients_ so APIConnection is complete for default_delete (libc++).
+#include "api_connection.h"
#include "api_noise_context.h"
#include "api_pb2.h"
#include "api_pb2_service.h"
@@ -12,8 +14,6 @@
#include "esphome/core/controller.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
-#include "list_entities.h"
-#include "subscribe_state.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -191,7 +191,7 @@ class APIServer final : public Component,
bool is_connected_with_state_subscription() const;
// Range-for view over the populated slice [0, api_connection_count_). Read-only with respect
- // to ownership — callers get `const unique_ptr&` so they can invoke non-const methods on the
+ // to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the
// APIConnection but cannot reset/move the slot and break the count invariant.
using APIConnectionPtr = std::unique_ptr;
class ActiveClientsView {
@@ -292,7 +292,7 @@ class APIServer final : public Component,
uint32_t last_connected_{0};
// Slots [0, api_connection_count_) are populated; trailing slots are always nullptr.
- std::array, MAX_API_CONNECTIONS> clients_{};
+ std::array clients_{};
// Vectors and strings (12 bytes each on 32-bit)
// Shared proto write buffer for all connections.
// Not pre-allocated: all send paths call prepare_first_message_buffer() which
diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py
index 312d937f0..44edc035f 100644
--- a/esphome/components/api/client.py
+++ b/esphome/components/api/client.py
@@ -18,8 +18,8 @@ with warnings.catch_warnings():
import contextlib
from esphome.const import CONF_KEY, CONF_PORT, __version__
-from esphome.core import CORE
-from esphome.platformio_api import process_stacktrace
+from esphome.core import CORE, EsphomeError
+from esphome.util import safe_print
from . import CONF_ENCRYPTION
@@ -32,6 +32,48 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
+class _LogLineProcessor:
+ """Feeds incoming log lines to the stack-trace decoder.
+
+ Two responsibilities beyond just calling the decoder:
+ 1. Catch EsphomeError. on_log runs inside an asyncio protocol
+ callback; if an exception escapes, the loop tears the transport
+ down with "Fatal error: protocol.data_received() call failed."
+ and ReconnectLogic immediately reconnects, the device replays
+ the same crash trace, and we loop forever.
+ 2. Disable decoding after the first failure. _decode_pc shells out
+ to PlatformIO via _run_idedata, which is expensive; a single
+ crash dump can contain many PC/BT lines and we don't want to
+ retry the failing subprocess for each one.
+ """
+
+ def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None:
+ self._config = config
+ self._platform_handler = platform_handler
+ self._decode_enabled = True
+ self.backtrace_state = False
+
+ def process_line(self, raw_line: str) -> None:
+ if not self._decode_enabled:
+ return
+ try:
+ if self._platform_handler is not None:
+ self.backtrace_state = self._platform_handler(
+ self._config, raw_line, self.backtrace_state
+ )
+ except EsphomeError as exc:
+ self._decode_enabled = False
+ self.backtrace_state = False
+ # _run_idedata raises EsphomeError with no message; fall back
+ # to a generic explanation when str(exc) is empty.
+ detail = str(exc) or "build artifacts not found locally"
+ _LOGGER.warning(
+ "Crash trace decoding unavailable: %s. "
+ "Run 'esphome compile' for this device to enable PC decoding.",
+ detail,
+ )
+
+
async def async_run_logs(
config: dict[str, Any],
addresses: list[str],
@@ -59,22 +101,25 @@ async def async_run_logs(
client_info=f"ESPHome Logs {__version__}",
noise_psk=noise_psk,
addresses=addresses, # Pass all addresses for automatic retry
+ provide_time=False,
)
- dashboard = CORE.dashboard
- backtrace_state = False
# Try platform-specific stacktrace handler first, fall back to generic
platform_process_stacktrace = None
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
- platform_process_stacktrace = getattr(module, "process_stacktrace")
+ platform_process_stacktrace = module.process_stacktrace
except (AttributeError, ImportError):
- pass
+ _LOGGER.info(
+ 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
+ CORE.target_platform,
+ )
+
+ processor = _LogLineProcessor(config, platform_process_stacktrace)
def on_log(msg: SubscribeLogsResponse) -> None:
"""Handle a new log message."""
- nonlocal backtrace_state
- time_ = datetime.now()
+ time_ = datetime.now().astimezone()
message: bytes = msg.message
text = message.decode("utf8", "backslashreplace")
nanoseconds = time_.microsecond // 1000
@@ -82,16 +127,13 @@ async def async_run_logs(
f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]"
)
for parsed_msg in parse_log_message(text, timestamp):
- print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg)
+ # safe_print handles the dashboard \033 escaping and falls back
+ # to backslashreplace encoding on stdouts that can't represent
+ # the wifi signal-bar block characters (Windows redirected
+ # cp1252 pipe).
+ safe_print(parsed_msg)
for raw_line in text.splitlines():
- if platform_process_stacktrace:
- backtrace_state = platform_process_stacktrace(
- config, raw_line, backtrace_state
- )
- else:
- backtrace_state = process_stacktrace(
- config, raw_line, backtrace_state=backtrace_state
- )
+ processor.process_line(raw_line)
# Safe to fall back to plaintext here only for this diagnostics use
# case: the stream is one-way from device to client, and this code
diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h
index 9d14061d0..aef046fbb 100644
--- a/esphome/components/api/homeassistant_service.h
+++ b/esphome/components/api/homeassistant_service.h
@@ -78,7 +78,8 @@ class ActionResponse {
: success_(success), error_message_(error_message) {
if (data == nullptr || data_len == 0)
return;
- this->json_document_ = json::parse_json(data, data_len);
+ JsonDocument tmp = json::parse_json(data, data_len);
+ swap(this->json_document_, tmp);
}
#endif
diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h
index 95c626feb..88fbdb77c 100644
--- a/esphome/components/api/list_entities.h
+++ b/esphome/components/api/list_entities.h
@@ -18,83 +18,22 @@ class APIConnection;
class ListEntitiesIterator final : public ComponentIterator {
public:
ListEntitiesIterator(APIConnection *client);
-#ifdef USE_BINARY_SENSOR
- bool on_binary_sensor(binary_sensor::BinarySensor *entity) override;
-#endif
-#ifdef USE_COVER
- bool on_cover(cover::Cover *entity) override;
-#endif
-#ifdef USE_FAN
- bool on_fan(fan::Fan *entity) override;
-#endif
-#ifdef USE_LIGHT
- bool on_light(light::LightState *entity) override;
-#endif
-#ifdef USE_SENSOR
- bool on_sensor(sensor::Sensor *entity) override;
-#endif
-#ifdef USE_SWITCH
- bool on_switch(switch_::Switch *entity) override;
-#endif
-#ifdef USE_BUTTON
- bool on_button(button::Button *entity) override;
-#endif
-#ifdef USE_TEXT_SENSOR
- bool on_text_sensor(text_sensor::TextSensor *entity) override;
-#endif
+
+// Entity overrides (generated from entity_types.h).
+// All implementations live in list_entities.cpp via LIST_ENTITIES_HANDLER.
+// NOLINTBEGIN(bugprone-macro-parentheses)
+#define ENTITY_TYPE_(type, singular, plural, count, upper) bool on_##singular(type *entity) override;
+#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
+ ENTITY_TYPE_(type, singular, plural, count, upper)
+#include "esphome/core/entity_types.h"
+#undef ENTITY_TYPE_
+#undef ENTITY_CONTROLLER_TYPE_
+ // NOLINTEND(bugprone-macro-parentheses)
#ifdef USE_API_USER_DEFINED_ACTIONS
bool on_service(UserServiceDescriptor *service) override;
#endif
#ifdef USE_CAMERA
bool on_camera(camera::Camera *entity) override;
-#endif
-#ifdef USE_CLIMATE
- bool on_climate(climate::Climate *entity) override;
-#endif
-#ifdef USE_NUMBER
- bool on_number(number::Number *entity) override;
-#endif
-#ifdef USE_DATETIME_DATE
- bool on_date(datetime::DateEntity *entity) override;
-#endif
-#ifdef USE_DATETIME_TIME
- bool on_time(datetime::TimeEntity *entity) override;
-#endif
-#ifdef USE_DATETIME_DATETIME
- bool on_datetime(datetime::DateTimeEntity *entity) override;
-#endif
-#ifdef USE_TEXT
- bool on_text(text::Text *entity) override;
-#endif
-#ifdef USE_SELECT
- bool on_select(select::Select *entity) override;
-#endif
-#ifdef USE_LOCK
- bool on_lock(lock::Lock *entity) override;
-#endif
-#ifdef USE_VALVE
- bool on_valve(valve::Valve *entity) override;
-#endif
-#ifdef USE_MEDIA_PLAYER
- bool on_media_player(media_player::MediaPlayer *entity) override;
-#endif
-#ifdef USE_ALARM_CONTROL_PANEL
- bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override;
-#endif
-#ifdef USE_WATER_HEATER
- bool on_water_heater(water_heater::WaterHeater *entity) override;
-#endif
-#ifdef USE_INFRARED
- bool on_infrared(infrared::Infrared *entity) override;
-#endif
-#ifdef USE_RADIO_FREQUENCY
- bool on_radio_frequency(radio_frequency::RadioFrequency *entity) override;
-#endif
-#ifdef USE_EVENT
- bool on_event(event::Event *entity) override;
-#endif
-#ifdef USE_UPDATE
- bool on_update(update::UpdateEntity *entity) override;
#endif
bool on_end() override;
diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h
index 8cac7fff3..f058f6af2 100644
--- a/esphome/components/api/proto.h
+++ b/esphome/components/api/proto.h
@@ -342,6 +342,32 @@ class ProtoEncode {
}
encode_varint_raw_loop(pos PROTO_ENCODE_DEBUG_ARG, value);
}
+ /// Encode a 48-bit MAC address (stored in a uint64) as varint.
+ /// Real MAC addresses occupy the full 48 bits (OUI in upper 24), so the
+ /// fast path -- any non-zero bit in the top 6 of 48 -- emits exactly 7 bytes
+ /// with no per-byte branch. Falls back to the general loop otherwise.
+ /// Caller must guarantee value fits in 48 bits (checked in debug builds).
+ static inline void ESPHOME_ALWAYS_INLINE encode_varint_raw_48bit(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
+ uint64_t value) {
+#ifdef ESPHOME_DEBUG_API
+ assert(value < (1ULL << (MAC_ADDRESS_SIZE * 8)) && "encode_varint_raw_48bit: value exceeds 48 bits");
+#endif
+ // 7-byte varint holds 49 bits (7 * 7), so a 48-bit value needs all 7 bytes
+ // whenever bit 42 or higher is set (i.e. value >= 1 << (48 - 6)).
+ if (value >= (1ULL << (MAC_ADDRESS_SIZE * 8 - 6))) [[likely]] {
+ PROTO_ENCODE_CHECK_BOUNDS(pos, 7);
+ pos[0] = static_cast(value | 0x80);
+ pos[1] = static_cast((value >> 7) | 0x80);
+ pos[2] = static_cast((value >> 14) | 0x80);
+ pos[3] = static_cast((value >> 21) | 0x80);
+ pos[4] = static_cast((value >> 28) | 0x80);
+ pos[5] = static_cast((value >> 35) | 0x80);
+ pos[6] = static_cast(value >> 42);
+ pos += 7;
+ return;
+ }
+ encode_varint_raw_64(pos PROTO_ENCODE_DEBUG_ARG, value);
+ }
static inline void ESPHOME_ALWAYS_INLINE encode_field_raw(uint8_t *__restrict__ &pos PROTO_ENCODE_DEBUG_PARAM,
uint32_t field_id, uint32_t type) {
encode_varint_raw(pos PROTO_ENCODE_DEBUG_ARG, (field_id << 3) | type);
@@ -398,6 +424,7 @@ class ProtoEncode {
if (len == 0 && !force)
return;
encode_field_raw(pos PROTO_ENCODE_DEBUG_ARG, field_id, 2); // type 2: Length-delimited string
+ // NOLINTNEXTLINE(readability-inconsistent-ifelse-braces) -- false positive on [[likely]] attribute
if (len < VARINT_MAX_1_BYTE) [[likely]] {
PROTO_ENCODE_CHECK_BOUNDS(pos, 1 + len);
*pos++ = static_cast(len);
@@ -817,6 +844,14 @@ class ProtoSize {
static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_force(uint32_t field_id_size, uint64_t value) {
return field_id_size + varint(value);
}
+ /// 48-bit MAC address variant: matches encode_varint_raw_48bit's fast path.
+ /// When any of the top 6 of 48 bits is set the encoded varint is 7 bytes;
+ /// otherwise fall back to the general size calculation.
+ /// Caller must guarantee value fits in 48 bits (encoder asserts in debug).
+ static constexpr inline uint32_t ESPHOME_ALWAYS_INLINE calc_uint64_48bit_force(uint32_t field_id_size,
+ uint64_t value) {
+ return field_id_size + (value >= (1ULL << (MAC_ADDRESS_SIZE * 8 - 6)) ? 7 : varint(value));
+ }
static constexpr uint32_t calc_length(uint32_t field_id_size, size_t len) {
return len ? field_id_size + varint(static_cast(len)) + static_cast(len) : 0;
}
diff --git a/esphome/components/api/subscribe_state.cpp b/esphome/components/api/subscribe_state.cpp
index 4bbc17018..09b5640d8 100644
--- a/esphome/components/api/subscribe_state.cpp
+++ b/esphome/components/api/subscribe_state.cpp
@@ -67,7 +67,10 @@ INITIAL_STATE_HANDLER(water_heater, water_heater::WaterHeater)
INITIAL_STATE_HANDLER(update, update::UpdateEntity)
#endif
-// Special cases (button and event) are already defined inline in subscribe_state.h
+// event is an ENTITY_CONTROLLER_TYPE_ but has no state to send.
+#ifdef USE_EVENT
+bool InitialStateIterator::on_event(event::Event *entity) { return true; }
+#endif
InitialStateIterator::InitialStateIterator(APIConnection *client) : client_(client) {}
diff --git a/esphome/components/api/subscribe_state.h b/esphome/components/api/subscribe_state.h
index f20611e06..6b1ae9651 100644
--- a/esphome/components/api/subscribe_state.h
+++ b/esphome/components/api/subscribe_state.h
@@ -19,78 +19,20 @@ class APIConnection;
class InitialStateIterator final : public ComponentIterator {
public:
InitialStateIterator(APIConnection *client);
-#ifdef USE_BINARY_SENSOR
- bool on_binary_sensor(binary_sensor::BinarySensor *entity) override;
-#endif
-#ifdef USE_COVER
- bool on_cover(cover::Cover *entity) override;
-#endif
-#ifdef USE_FAN
- bool on_fan(fan::Fan *entity) override;
-#endif
-#ifdef USE_LIGHT
- bool on_light(light::LightState *entity) override;
-#endif
-#ifdef USE_SENSOR
- bool on_sensor(sensor::Sensor *entity) override;
-#endif
-#ifdef USE_SWITCH
- bool on_switch(switch_::Switch *entity) override;
-#endif
-#ifdef USE_BUTTON
- bool on_button(button::Button *button) override { return true; };
-#endif
-#ifdef USE_TEXT_SENSOR
- bool on_text_sensor(text_sensor::TextSensor *entity) override;
-#endif
-#ifdef USE_CLIMATE
- bool on_climate(climate::Climate *entity) override;
-#endif
-#ifdef USE_NUMBER
- bool on_number(number::Number *entity) override;
-#endif
-#ifdef USE_DATETIME_DATE
- bool on_date(datetime::DateEntity *entity) override;
-#endif
-#ifdef USE_DATETIME_TIME
- bool on_time(datetime::TimeEntity *entity) override;
-#endif
-#ifdef USE_DATETIME_DATETIME
- bool on_datetime(datetime::DateTimeEntity *entity) override;
-#endif
-#ifdef USE_TEXT
- bool on_text(text::Text *entity) override;
-#endif
-#ifdef USE_SELECT
- bool on_select(select::Select *entity) override;
-#endif
-#ifdef USE_LOCK
- bool on_lock(lock::Lock *entity) override;
-#endif
-#ifdef USE_VALVE
- bool on_valve(valve::Valve *entity) override;
-#endif
-#ifdef USE_MEDIA_PLAYER
- bool on_media_player(media_player::MediaPlayer *entity) override;
-#endif
-#ifdef USE_ALARM_CONTROL_PANEL
- bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override;
-#endif
-#ifdef USE_WATER_HEATER
- bool on_water_heater(water_heater::WaterHeater *entity) override;
-#endif
-#ifdef USE_INFRARED
- bool on_infrared(infrared::Infrared *infrared) override { return true; };
-#endif
-#ifdef USE_RADIO_FREQUENCY
- bool on_radio_frequency(radio_frequency::RadioFrequency *radio_frequency) override { return true; };
-#endif
-#ifdef USE_EVENT
- bool on_event(event::Event *event) override { return true; };
-#endif
-#ifdef USE_UPDATE
- bool on_update(update::UpdateEntity *entity) override;
-#endif
+
+// Entity overrides (generated from entity_types.h).
+// ENTITY_TYPE_ entities have no state to send and default to a no-op.
+// ENTITY_CONTROLLER_TYPE_ entities are implemented in subscribe_state.cpp via INITIAL_STATE_HANDLER,
+// except on_event which has no state (defined out-of-line in subscribe_state.cpp).
+// NOLINTBEGIN(bugprone-macro-parentheses)
+#define ENTITY_TYPE_(type, singular, plural, count, upper) \
+ bool on_##singular(type *entity) override { return true; }
+#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
+ bool on_##singular(type *entity) override;
+#include "esphome/core/entity_types.h"
+#undef ENTITY_TYPE_
+#undef ENTITY_CONTROLLER_TYPE_
+ // NOLINTEND(bugprone-macro-parentheses)
protected:
APIConnection *client_;
diff --git a/esphome/components/aqi/aqi_calculator.h b/esphome/components/aqi/aqi_calculator.h
index d624af043..bb8e40228 100644
--- a/esphome/components/aqi/aqi_calculator.h
+++ b/esphome/components/aqi/aqi_calculator.h
@@ -14,11 +14,7 @@ class AQICalculator : public AbstractAQICalculator {
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);
-
- float aqi = std::max(pm2_5_index, pm10_0_index);
- if (aqi < 0.0f) {
- aqi = 0.0f;
- }
+ float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
return static_cast(std::lround(aqi));
}
diff --git a/esphome/components/aqi/caqi_calculator.h b/esphome/components/aqi/caqi_calculator.h
index fe2efe705..3f6da45aa 100644
--- a/esphome/components/aqi/caqi_calculator.h
+++ b/esphome/components/aqi/caqi_calculator.h
@@ -12,11 +12,7 @@ class CAQICalculator : public AbstractAQICalculator {
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);
-
- float aqi = std::max(pm2_5_index, pm10_0_index);
- if (aqi < 0.0f) {
- aqi = 0.0f;
- }
+ float aqi = std::max({pm2_5_index, pm10_0_index, 0.0f});
return static_cast(std::lround(aqi));
}
diff --git a/esphome/components/as3935/as3935.cpp b/esphome/components/as3935/as3935.cpp
index c4dc0466a..a1d74c78d 100644
--- a/esphome/components/as3935/as3935.cpp
+++ b/esphome/components/as3935/as3935.cpp
@@ -1,8 +1,7 @@
#include "as3935.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace as3935 {
+namespace esphome::as3935 {
static const char *const TAG = "as3935";
@@ -320,5 +319,4 @@ uint8_t AS3935Component::read_register_(uint8_t reg, uint8_t mask) {
return value;
}
-} // namespace as3935
-} // namespace esphome
+} // namespace esphome::as3935
diff --git a/esphome/components/as3935/as3935.h b/esphome/components/as3935/as3935.h
index 5f46dadfa..274ce7a13 100644
--- a/esphome/components/as3935/as3935.h
+++ b/esphome/components/as3935/as3935.h
@@ -10,8 +10,7 @@
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
-namespace esphome {
-namespace as3935 {
+namespace esphome::as3935 {
static const uint8_t DIRECT_COMMAND = 0x96;
static const uint8_t ANTFREQ = 3;
@@ -127,5 +126,4 @@ class AS3935Component : public Component {
bool calibration_;
};
-} // namespace as3935
-} // namespace esphome
+} // namespace esphome::as3935
diff --git a/esphome/components/as3935_i2c/as3935_i2c.cpp b/esphome/components/as3935_i2c/as3935_i2c.cpp
index 3a7fa7bf8..4c1020daa 100644
--- a/esphome/components/as3935_i2c/as3935_i2c.cpp
+++ b/esphome/components/as3935_i2c/as3935_i2c.cpp
@@ -1,8 +1,7 @@
#include "as3935_i2c.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace as3935_i2c {
+namespace esphome::as3935_i2c {
static const char *const TAG = "as3935_i2c";
@@ -40,5 +39,4 @@ void I2CAS3935Component::dump_config() {
LOG_I2C_DEVICE(this);
}
-} // namespace as3935_i2c
-} // namespace esphome
+} // namespace esphome::as3935_i2c
diff --git a/esphome/components/as3935_i2c/as3935_i2c.h b/esphome/components/as3935_i2c/as3935_i2c.h
index a2a3d213e..c43ec4afd 100644
--- a/esphome/components/as3935_i2c/as3935_i2c.h
+++ b/esphome/components/as3935_i2c/as3935_i2c.h
@@ -3,8 +3,7 @@
#include "esphome/components/as3935/as3935.h"
#include "esphome/components/i2c/i2c.h"
-namespace esphome {
-namespace as3935_i2c {
+namespace esphome::as3935_i2c {
class I2CAS3935Component : public as3935::AS3935Component, public i2c::I2CDevice {
public:
@@ -15,5 +14,4 @@ class I2CAS3935Component : public as3935::AS3935Component, public i2c::I2CDevice
uint8_t read_register(uint8_t reg) override;
};
-} // namespace as3935_i2c
-} // namespace esphome
+} // namespace esphome::as3935_i2c
diff --git a/esphome/components/as3935_spi/as3935_spi.cpp b/esphome/components/as3935_spi/as3935_spi.cpp
index 1b2e9ccd3..026fde2f2 100644
--- a/esphome/components/as3935_spi/as3935_spi.cpp
+++ b/esphome/components/as3935_spi/as3935_spi.cpp
@@ -1,8 +1,7 @@
#include "as3935_spi.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace as3935_spi {
+namespace esphome::as3935_spi {
static const char *const TAG = "as3935_spi";
@@ -42,5 +41,4 @@ uint8_t SPIAS3935Component::read_register(uint8_t reg) {
return value;
}
-} // namespace as3935_spi
-} // namespace esphome
+} // namespace esphome::as3935_spi
diff --git a/esphome/components/as3935_spi/as3935_spi.h b/esphome/components/as3935_spi/as3935_spi.h
index e5422f9b3..935707a18 100644
--- a/esphome/components/as3935_spi/as3935_spi.h
+++ b/esphome/components/as3935_spi/as3935_spi.h
@@ -4,8 +4,7 @@
#include "esphome/components/as3935/as3935.h"
#include "esphome/components/spi/spi.h"
-namespace esphome {
-namespace as3935_spi {
+namespace esphome::as3935_spi {
enum AS3935RegisterMasks { SPI_READ_M = 0x40 };
@@ -21,5 +20,4 @@ class SPIAS3935Component : public as3935::AS3935Component,
uint8_t read_register(uint8_t reg) override;
};
-} // namespace as3935_spi
-} // namespace esphome
+} // namespace esphome::as3935_spi
diff --git a/esphome/components/as5600/__init__.py b/esphome/components/as5600/__init__.py
index 444306cec..c05e55637 100644
--- a/esphome/components/as5600/__init__.py
+++ b/esphome/components/as5600/__init__.py
@@ -100,7 +100,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION):
if isinstance(value, str) and value.endswith("%"):
value = percent_to_position(value)
- if isinstance(value, str) and (value.endswith("°") or value.endswith("deg")):
+ if isinstance(value, str) and value.endswith(("°", "deg")):
return angle_to_position(
value,
min=round(min * POSITION_TO_ANGLE),
diff --git a/esphome/components/as5600/as5600.cpp b/esphome/components/as5600/as5600.cpp
index ee3083d56..da1add545 100644
--- a/esphome/components/as5600/as5600.cpp
+++ b/esphome/components/as5600/as5600.cpp
@@ -1,8 +1,7 @@
#include "as5600.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace as5600 {
+namespace esphome::as5600 {
static const char *const TAG = "as5600";
@@ -134,5 +133,4 @@ optional AS5600Component::read_raw_position() {
return pos;
}
-} // namespace as5600
-} // namespace esphome
+} // namespace esphome::as5600
diff --git a/esphome/components/as5600/as5600.h b/esphome/components/as5600/as5600.h
index 914a4431b..414633f97 100644
--- a/esphome/components/as5600/as5600.h
+++ b/esphome/components/as5600/as5600.h
@@ -6,8 +6,7 @@
#include "esphome/components/i2c/i2c.h"
#include "esphome/components/sensor/sensor.h"
-namespace esphome {
-namespace as5600 {
+namespace esphome::as5600 {
static const uint16_t POSITION_COUNT = 4096;
static const float RAW_TO_DEGREES = 360.0 / POSITION_COUNT;
@@ -100,5 +99,4 @@ class AS5600Component : public Component, public i2c::I2CDevice {
float range_scale_{1.0};
};
-} // namespace as5600
-} // namespace esphome
+} // namespace esphome::as5600
diff --git a/esphome/components/as5600/sensor/as5600_sensor.cpp b/esphome/components/as5600/sensor/as5600_sensor.cpp
index 4e549d24d..ba295659a 100644
--- a/esphome/components/as5600/sensor/as5600_sensor.cpp
+++ b/esphome/components/as5600/sensor/as5600_sensor.cpp
@@ -1,8 +1,7 @@
#include "as5600_sensor.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace as5600 {
+namespace esphome::as5600 {
static const char *const TAG = "as5600.sensor";
@@ -75,5 +74,4 @@ void AS5600Sensor::update() {
this->status_clear_warning();
}
-} // namespace as5600
-} // namespace esphome
+} // namespace esphome::as5600
diff --git a/esphome/components/as5600/sensor/as5600_sensor.h b/esphome/components/as5600/sensor/as5600_sensor.h
index 77593f4b1..0086fe54c 100644
--- a/esphome/components/as5600/sensor/as5600_sensor.h
+++ b/esphome/components/as5600/sensor/as5600_sensor.h
@@ -7,8 +7,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/as5600/as5600.h"
-namespace esphome {
-namespace as5600 {
+namespace esphome::as5600 {
class AS5600Sensor : public PollingComponent, public Parented, public sensor::Sensor {
public:
@@ -32,5 +31,4 @@ class AS5600Sensor : public PollingComponent, public Parented,
OutRangeMode out_of_range_mode_{OUT_RANGE_MODE_MIN_MAX};
};
-} // namespace as5600
-} // namespace esphome
+} // namespace esphome::as5600
diff --git a/esphome/components/as7341/as7341.cpp b/esphome/components/as7341/as7341.cpp
index 1e78d814c..32094ddf1 100644
--- a/esphome/components/as7341/as7341.cpp
+++ b/esphome/components/as7341/as7341.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/log.h"
#include "esphome/core/hal.h"
-namespace esphome {
-namespace as7341 {
+namespace esphome::as7341 {
static const char *const TAG = "as7341";
@@ -266,5 +265,4 @@ bool AS7341Component::clear_register_bit(uint8_t address, uint8_t bit_position)
uint16_t AS7341Component::swap_bytes(uint16_t data) { return (data >> 8) | (data << 8); }
-} // namespace as7341
-} // namespace esphome
+} // namespace esphome::as7341
diff --git a/esphome/components/as7341/as7341.h b/esphome/components/as7341/as7341.h
index 3ede9d4aa..8bc157fe7 100644
--- a/esphome/components/as7341/as7341.h
+++ b/esphome/components/as7341/as7341.h
@@ -4,8 +4,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
-namespace esphome {
-namespace as7341 {
+namespace esphome::as7341 {
static const uint8_t AS7341_CHIP_ID = 0x09;
@@ -139,5 +138,4 @@ class AS7341Component : public PollingComponent, public i2c::I2CDevice {
uint16_t channel_readings_[12];
};
-} // namespace as7341
-} // namespace esphome
+} // namespace esphome::as7341
diff --git a/esphome/components/at581x/__init__.py b/esphome/components/at581x/__init__.py
index 94b68db4b..5031b72cc 100644
--- a/esphome/components/at581x/__init__.py
+++ b/esphome/components/at581x/__init__.py
@@ -183,19 +183,19 @@ async def at581x_settings_to_code(config, action_id, template_arg, args):
cg.add(var.set_sensing_distance(template_))
if selfcheck := config.get(CONF_POWERON_SELFCHECK_TIME):
- template_ = await cg.templatable(selfcheck, args, cg.int32)
+ template_ = await cg.templatable(selfcheck, args, cg.int_)
cg.add(var.set_poweron_selfcheck_time(template_))
if protect := config.get(CONF_PROTECT_TIME):
- template_ = await cg.templatable(protect, args, cg.int32)
+ template_ = await cg.templatable(protect, args, cg.int_)
cg.add(var.set_protect_time(template_))
if trig_base := config.get(CONF_TRIGGER_BASE):
- template_ = await cg.templatable(trig_base, args, cg.int32)
+ template_ = await cg.templatable(trig_base, args, cg.int_)
cg.add(var.set_trigger_base(template_))
if trig_keep := config.get(CONF_TRIGGER_KEEP):
- template_ = await cg.templatable(trig_keep, args, cg.int32)
+ template_ = await cg.templatable(trig_keep, args, cg.int_)
cg.add(var.set_trigger_keep(template_))
if (stage_gain := config.get(CONF_STAGE_GAIN)) is not None:
diff --git a/esphome/components/at581x/at581x.cpp b/esphome/components/at581x/at581x.cpp
index 6fc85b079..575a9afd4 100644
--- a/esphome/components/at581x/at581x.cpp
+++ b/esphome/components/at581x/at581x.cpp
@@ -49,8 +49,7 @@ const uint8_t TRIGGER_KEEP_TIME_ADDR = 0x42; // 4 bytes, so up to 0x45
const uint8_t TIME41_VALUE = 1;
const uint8_t SELF_CHECK_TIME_ADDR = 0x38; // 2 bytes, up to 0x39
-namespace esphome {
-namespace at581x {
+namespace esphome::at581x {
static const char *const TAG = "at581x";
@@ -199,5 +198,4 @@ void AT581XComponent::set_rf_mode(bool enable) {
}
}
-} // namespace at581x
-} // namespace esphome
+} // namespace esphome::at581x
diff --git a/esphome/components/at581x/at581x.h b/esphome/components/at581x/at581x.h
index 558a5c8b1..e7f8ee369 100644
--- a/esphome/components/at581x/at581x.h
+++ b/esphome/components/at581x/at581x.h
@@ -10,8 +10,7 @@
#endif
#include "esphome/components/i2c/i2c.h"
-namespace esphome {
-namespace at581x {
+namespace esphome::at581x {
class AT581XComponent : public Component, public i2c::I2CDevice {
public:
@@ -58,5 +57,4 @@ class AT581XComponent : public Component, public i2c::I2CDevice {
int power_; /*!< In µA */
};
-} // namespace at581x
-} // namespace esphome
+} // namespace esphome::at581x
diff --git a/esphome/components/at581x/automation.h b/esphome/components/at581x/automation.h
index b1611a675..eb8b1b256 100644
--- a/esphome/components/at581x/automation.h
+++ b/esphome/components/at581x/automation.h
@@ -5,8 +5,7 @@
#include "at581x.h"
-namespace esphome {
-namespace at581x {
+namespace esphome::at581x {
template class AT581XResetAction : public Action, public Parented {
public:
@@ -67,5 +66,4 @@ template class AT581XSettingsAction : public Action, publ
}
}
};
-} // namespace at581x
-} // namespace esphome
+} // namespace esphome::at581x
diff --git a/esphome/components/at581x/switch/rf_switch.cpp b/esphome/components/at581x/switch/rf_switch.cpp
index f1d03dc8a..743223386 100644
--- a/esphome/components/at581x/switch/rf_switch.cpp
+++ b/esphome/components/at581x/switch/rf_switch.cpp
@@ -1,12 +1,10 @@
#include "rf_switch.h"
-namespace esphome {
-namespace at581x {
+namespace esphome::at581x {
void RFSwitch::write_state(bool state) {
this->publish_state(state);
this->parent_->set_rf_mode(state);
}
-} // namespace at581x
-} // namespace esphome
+} // namespace esphome::at581x
diff --git a/esphome/components/at581x/switch/rf_switch.h b/esphome/components/at581x/switch/rf_switch.h
index 920ddbb66..47367fad4 100644
--- a/esphome/components/at581x/switch/rf_switch.h
+++ b/esphome/components/at581x/switch/rf_switch.h
@@ -3,13 +3,11 @@
#include "esphome/components/switch/switch.h"
#include "../at581x.h"
-namespace esphome {
-namespace at581x {
+namespace esphome::at581x {
class RFSwitch : public switch_::Switch, public Parented {
protected:
void write_state(bool state) override;
};
-} // namespace at581x
-} // namespace esphome
+} // namespace esphome::at581x
diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.cpp b/esphome/components/atc_mithermometer/atc_mithermometer.cpp
index 9afd6334f..f8bbd9d55 100644
--- a/esphome/components/atc_mithermometer/atc_mithermometer.cpp
+++ b/esphome/components/atc_mithermometer/atc_mithermometer.cpp
@@ -3,8 +3,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace atc_mithermometer {
+namespace esphome::atc_mithermometer {
static const char *const TAG = "atc_mithermometer";
@@ -133,7 +132,6 @@ bool ATCMiThermometer::report_results_(const optional &result, cons
return true;
}
-} // namespace atc_mithermometer
-} // namespace esphome
+} // namespace esphome::atc_mithermometer
#endif
diff --git a/esphome/components/atc_mithermometer/atc_mithermometer.h b/esphome/components/atc_mithermometer/atc_mithermometer.h
index e37b5f435..8f62f05bc 100644
--- a/esphome/components/atc_mithermometer/atc_mithermometer.h
+++ b/esphome/components/atc_mithermometer/atc_mithermometer.h
@@ -8,8 +8,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace atc_mithermometer {
+namespace esphome::atc_mithermometer {
struct ParseResult {
optional temperature;
@@ -44,7 +43,6 @@ class ATCMiThermometer : public Component, public esp32_ble_tracker::ESPBTDevice
bool report_results_(const optional &result, const char *address);
};
-} // namespace atc_mithermometer
-} // namespace esphome
+} // namespace esphome::atc_mithermometer
#endif
diff --git a/esphome/components/atm90e26/atm90e26.cpp b/esphome/components/atm90e26/atm90e26.cpp
index e6602411b..46948be47 100644
--- a/esphome/components/atm90e26/atm90e26.cpp
+++ b/esphome/components/atm90e26/atm90e26.cpp
@@ -2,8 +2,7 @@
#include "atm90e26_reg.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace atm90e26 {
+namespace esphome::atm90e26 {
static const char *const TAG = "atm90e26";
@@ -229,5 +228,4 @@ float ATM90E26Component::get_frequency_() {
return freq / 100.0f;
}
-} // namespace atm90e26
-} // namespace esphome
+} // namespace esphome::atm90e26
diff --git a/esphome/components/atm90e26/atm90e26.h b/esphome/components/atm90e26/atm90e26.h
index d15a53ea4..657f8f3c4 100644
--- a/esphome/components/atm90e26/atm90e26.h
+++ b/esphome/components/atm90e26/atm90e26.h
@@ -4,8 +4,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/spi/spi.h"
-namespace esphome {
-namespace atm90e26 {
+namespace esphome::atm90e26 {
class ATM90E26Component : public PollingComponent,
public spi::SPIDevice
#include
+#include
#include
#include "esphome/core/log.h"
-namespace esphome {
-namespace atm90e32 {
+namespace esphome::atm90e32 {
static const char *const TAG = "atm90e32";
+
+static uint32_t pref_hash(const char *prefix, const char *name_space) {
+ auto hash = fnv1_hash(prefix);
+ return fnv1_hash_extend(hash, name_space);
+}
+
+template
+static int migrate_legacy_pref_if_needed(ESPPreferenceObject ¤t_pref, ESPPreferenceObject &legacy_pref,
+ T *scratch) {
+ T current{};
+ if (current_pref.load(¤t)) {
+ return 0;
+ }
+ if (!legacy_pref.load(scratch)) {
+ return 0;
+ }
+ return current_pref.save(scratch) ? 1 : -1;
+}
+
void ATM90E32Component::loop() {
if (this->get_publish_interval_flag_()) {
this->set_publish_interval_flag_(false);
@@ -112,10 +131,14 @@ void ATM90E32Component::get_cs_summary_(std::span bu
this->cs_->dump_summary(buffer.data(), buffer.size());
}
+const char *ATM90E32Component::get_calibration_id_() { return this->instance_id_; }
+
void ATM90E32Component::setup() {
this->spi_setup();
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
+ char legacy_cs[GPIO_SUMMARY_MAX_LEN];
+ this->get_cs_summary_(legacy_cs);
+ const bool has_distinct_legacy_namespace = strcmp(cs, legacy_cs) != 0;
uint16_t mmode0 = 0x87; // 3P4W 50Hz
uint16_t high_thresh = 0;
@@ -162,15 +185,46 @@ void ATM90E32Component::setup() {
if (this->enable_offset_calibration_) {
// Initialize flash storage for offset calibrations
- uint32_t o_hash = fnv1_hash("_offset_calibration_");
- o_hash = fnv1_hash_extend(o_hash, cs);
+ uint32_t o_hash = pref_hash("_offset_calibration_", cs);
this->offset_pref_ = global_preferences->make_preference(o_hash, true);
- this->restore_offset_calibrations_();
+ bool migrated_offset = false;
+ if (has_distinct_legacy_namespace) {
+ uint32_t legacy_o_hash = pref_hash("_offset_calibration_", legacy_cs);
+ auto legacy_offset_pref = global_preferences->make_preference(legacy_o_hash, true);
+ OffsetCalibration offset_data[3]{};
+ int migration_status = migrate_legacy_pref_if_needed(this->offset_pref_, legacy_offset_pref, &offset_data);
+ migrated_offset = migration_status > 0;
+ if (migration_status > 0) {
+ ESP_LOGI(TAG, "[CALIBRATION][%s] Migrated offset calibrations from legacy storage.", cs);
+ } else if (migration_status < 0) {
+ ESP_LOGW(TAG, "[CALIBRATION][%s] Failed to migrate offset calibrations from legacy storage.", cs);
+ }
+ }
// Initialize flash storage for power offset calibrations
- uint32_t po_hash = fnv1_hash("_power_offset_calibration_");
- po_hash = fnv1_hash_extend(po_hash, cs);
+ uint32_t po_hash = pref_hash("_power_offset_calibration_", cs);
this->power_offset_pref_ = global_preferences->make_preference(po_hash, true);
+ bool migrated_power_offset = false;
+ if (has_distinct_legacy_namespace) {
+ uint32_t legacy_po_hash = pref_hash("_power_offset_calibration_", legacy_cs);
+ auto legacy_power_offset_pref =
+ global_preferences->make_preference(legacy_po_hash, true);
+ PowerOffsetCalibration power_offset_data[3]{};
+ int migration_status =
+ migrate_legacy_pref_if_needed(this->power_offset_pref_, legacy_power_offset_pref, &power_offset_data);
+ migrated_power_offset = migration_status > 0;
+ if (migration_status > 0) {
+ ESP_LOGI(TAG, "[CALIBRATION][%s] Migrated power offset calibrations from legacy storage.", cs);
+ } else if (migration_status < 0) {
+ ESP_LOGW(TAG, "[CALIBRATION][%s] Failed to migrate power offset calibrations from legacy storage.", cs);
+ }
+ }
+
+ if (migrated_offset || migrated_power_offset) {
+ global_preferences->sync();
+ }
+
+ this->restore_offset_calibrations_();
this->restore_power_offset_calibrations_();
} else {
ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.",
@@ -189,9 +243,27 @@ void ATM90E32Component::setup() {
if (this->enable_gain_calibration_) {
// Initialize flash storage for gain calibration
- uint32_t g_hash = fnv1_hash("_gain_calibration_");
- g_hash = fnv1_hash_extend(g_hash, cs);
+ uint32_t g_hash = pref_hash("_gain_calibration_", cs);
this->gain_calibration_pref_ = global_preferences->make_preference(g_hash, true);
+ bool migrated_gain = false;
+ if (has_distinct_legacy_namespace) {
+ uint32_t legacy_g_hash = pref_hash("_gain_calibration_", legacy_cs);
+ auto legacy_gain_calibration_pref = global_preferences->make_preference(legacy_g_hash, true);
+ GainCalibration gain_data[3]{};
+ int migration_status =
+ migrate_legacy_pref_if_needed(this->gain_calibration_pref_, legacy_gain_calibration_pref, &gain_data);
+ migrated_gain = migration_status > 0;
+ if (migration_status > 0) {
+ ESP_LOGI(TAG, "[CALIBRATION][%s] Migrated gain calibrations from legacy storage.", cs);
+ } else if (migration_status < 0) {
+ ESP_LOGW(TAG, "[CALIBRATION][%s] Failed to migrate gain calibrations from legacy storage.", cs);
+ }
+ }
+
+ if (migrated_gain) {
+ global_preferences->sync();
+ }
+
this->restore_gain_calibrations_();
if (!this->using_saved_calibrations_) {
@@ -221,8 +293,7 @@ void ATM90E32Component::setup() {
}
void ATM90E32Component::log_calibration_status_() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
bool offset_mismatch = false;
bool power_mismatch = false;
@@ -573,8 +644,7 @@ float ATM90E32Component::get_chip_temperature_() {
}
void ATM90E32Component::run_gain_calibrations() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
if (!this->enable_gain_calibration_) {
ESP_LOGW(TAG, "[CALIBRATION][%s] Gain calibration is disabled! Enable it first with enable_gain_calibration: true",
cs);
@@ -674,8 +744,7 @@ void ATM90E32Component::run_gain_calibrations() {
}
void ATM90E32Component::save_gain_calibration_to_memory_() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
bool success = this->gain_calibration_pref_.save(&this->gain_phase_);
global_preferences->sync();
if (success) {
@@ -688,8 +757,7 @@ void ATM90E32Component::save_gain_calibration_to_memory_() {
}
void ATM90E32Component::save_offset_calibration_to_memory_() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
bool success = this->offset_pref_.save(&this->offset_phase_);
global_preferences->sync();
if (success) {
@@ -705,8 +773,7 @@ void ATM90E32Component::save_offset_calibration_to_memory_() {
}
void ATM90E32Component::save_power_offset_calibration_to_memory_() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
bool success = this->power_offset_pref_.save(&this->power_offset_phase_);
global_preferences->sync();
if (success) {
@@ -722,8 +789,7 @@ void ATM90E32Component::save_power_offset_calibration_to_memory_() {
}
void ATM90E32Component::run_offset_calibrations() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
if (!this->enable_offset_calibration_) {
ESP_LOGW(TAG,
"[CALIBRATION][%s] Offset calibration is disabled! Enable it first with enable_offset_calibration: true",
@@ -753,8 +819,7 @@ void ATM90E32Component::run_offset_calibrations() {
}
void ATM90E32Component::run_power_offset_calibrations() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
if (!this->enable_offset_calibration_) {
ESP_LOGW(
TAG,
@@ -827,15 +892,16 @@ void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t
}
void ATM90E32Component::restore_gain_calibrations_() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
for (uint8_t i = 0; i < 3; ++i) {
this->config_gain_phase_[i].voltage_gain = this->phase_[i].voltage_gain_;
this->config_gain_phase_[i].current_gain = this->phase_[i].ct_gain_;
this->gain_phase_[i] = this->config_gain_phase_[i];
}
- if (this->gain_calibration_pref_.load(&this->gain_phase_)) {
+ bool have_data = this->gain_calibration_pref_.load(&this->gain_phase_);
+
+ if (have_data) {
bool all_zero = true;
bool same_as_config = true;
for (uint8_t phase = 0; phase < 3; ++phase) {
@@ -882,12 +948,12 @@ void ATM90E32Component::restore_gain_calibrations_() {
}
void ATM90E32Component::restore_offset_calibrations_() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
for (uint8_t i = 0; i < 3; ++i)
this->config_offset_phase_[i] = this->offset_phase_[i];
bool have_data = this->offset_pref_.load(&this->offset_phase_);
+
bool all_zero = true;
if (have_data) {
for (auto &phase : this->offset_phase_) {
@@ -925,12 +991,12 @@ void ATM90E32Component::restore_offset_calibrations_() {
}
void ATM90E32Component::restore_power_offset_calibrations_() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
for (uint8_t i = 0; i < 3; ++i)
this->config_power_offset_phase_[i] = this->power_offset_phase_[i];
bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_);
+
bool all_zero = true;
if (have_data) {
for (auto &phase : this->power_offset_phase_) {
@@ -968,8 +1034,7 @@ void ATM90E32Component::restore_power_offset_calibrations_() {
}
void ATM90E32Component::clear_gain_calibrations() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
if (!this->using_saved_calibrations_) {
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored gain calibrations to clear. Current values:", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] ----------------------------------------------------------", cs);
@@ -1018,8 +1083,7 @@ void ATM90E32Component::clear_gain_calibrations() {
}
void ATM90E32Component::clear_offset_calibrations() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
if (!this->restored_offset_calibration_) {
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs);
@@ -1061,8 +1125,7 @@ void ATM90E32Component::clear_offset_calibrations() {
}
void ATM90E32Component::clear_power_offset_calibrations() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
if (!this->restored_power_offset_calibration_) {
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
@@ -1137,8 +1200,7 @@ int16_t ATM90E32Component::calibrate_power_offset(uint8_t phase, bool reactive)
}
bool ATM90E32Component::verify_gain_writes_() {
- char cs[GPIO_SUMMARY_MAX_LEN];
- this->get_cs_summary_(cs);
+ const char *cs = this->get_calibration_id_();
bool success = true;
for (uint8_t phase = 0; phase < 3; phase++) {
uint16_t read_voltage = this->read16_(voltage_gain_registers[phase]);
@@ -1250,5 +1312,4 @@ bool ATM90E32Component::validate_spi_read_(uint16_t expected, const char *contex
return true;
}
-} // namespace atm90e32
-} // namespace esphome
+} // namespace esphome::atm90e32
diff --git a/esphome/components/atm90e32/atm90e32.h b/esphome/components/atm90e32/atm90e32.h
index 95154812c..5fa224b35 100644
--- a/esphome/components/atm90e32/atm90e32.h
+++ b/esphome/components/atm90e32/atm90e32.h
@@ -11,8 +11,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/preferences.h"
-namespace esphome {
-namespace atm90e32 {
+namespace esphome::atm90e32 {
class ATM90E32Component : public PollingComponent,
public spi::SPIDevice buffer);
struct ATM90E32Phase {
@@ -263,6 +264,7 @@ class ATM90E32Component : public PollingComponent,
bool peak_current_signed_{false};
bool enable_offset_calibration_{false};
bool enable_gain_calibration_{false};
+ const char *instance_id_{nullptr};
bool restored_offset_calibration_{false};
bool restored_power_offset_calibration_{false};
bool restored_gain_calibration_{false};
@@ -272,5 +274,4 @@ class ATM90E32Component : public PollingComponent,
bool gain_calibration_mismatch_[3]{false, false, false};
};
-} // namespace atm90e32
-} // namespace esphome
+} // namespace esphome::atm90e32
diff --git a/esphome/components/atm90e32/atm90e32_reg.h b/esphome/components/atm90e32/atm90e32_reg.h
index 86c2d6456..480c2b267 100644
--- a/esphome/components/atm90e32/atm90e32_reg.h
+++ b/esphome/components/atm90e32/atm90e32_reg.h
@@ -2,8 +2,7 @@
#include
-namespace esphome {
-namespace atm90e32 {
+namespace esphome::atm90e32 {
/* STATUS REGISTERS */
static const uint16_t ATM90E32_REGISTER_METEREN = 0x00; // Metering Enable
@@ -268,5 +267,4 @@ static const uint16_t ATM90E32_REGISTER_UANGLEA = 0xFD; // A Voltage Phase Angl
static const uint16_t ATM90E32_REGISTER_UANGLEB = 0xFE; // B Voltage Phase Angle
static const uint16_t ATM90E32_REGISTER_UANGLEC = 0xFF; // C Voltage Phase Angle
-} // namespace atm90e32
-} // namespace esphome
+} // namespace esphome::atm90e32
diff --git a/esphome/components/atm90e32/button/atm90e32_button.cpp b/esphome/components/atm90e32/button/atm90e32_button.cpp
index a89f07199..e120e4536 100644
--- a/esphome/components/atm90e32/button/atm90e32_button.cpp
+++ b/esphome/components/atm90e32/button/atm90e32_button.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/component.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace atm90e32 {
+namespace esphome::atm90e32 {
static const char *const TAG = "atm90e32.button";
@@ -75,5 +74,4 @@ void ATM90E32ClearPowerOffsetCalibrationButton::press_action() {
this->parent_->clear_power_offset_calibrations();
}
-} // namespace atm90e32
-} // namespace esphome
+} // namespace esphome::atm90e32
diff --git a/esphome/components/atm90e32/button/atm90e32_button.h b/esphome/components/atm90e32/button/atm90e32_button.h
index 244958153..0cfce6229 100644
--- a/esphome/components/atm90e32/button/atm90e32_button.h
+++ b/esphome/components/atm90e32/button/atm90e32_button.h
@@ -4,8 +4,7 @@
#include "esphome/components/atm90e32/atm90e32.h"
#include "esphome/components/button/button.h"
-namespace esphome {
-namespace atm90e32 {
+namespace esphome::atm90e32 {
class ATM90E32GainCalibrationButton : public button::Button, public Parented {
public:
@@ -55,5 +54,4 @@ class ATM90E32ClearPowerOffsetCalibrationButton : public button::Button, public
void press_action() override;
};
-} // namespace atm90e32
-} // namespace esphome
+} // namespace esphome::atm90e32
diff --git a/esphome/components/atm90e32/number/atm90e32_number.h b/esphome/components/atm90e32/number/atm90e32_number.h
index 9b6129b26..a575a94ea 100644
--- a/esphome/components/atm90e32/number/atm90e32_number.h
+++ b/esphome/components/atm90e32/number/atm90e32_number.h
@@ -4,13 +4,11 @@
#include "esphome/components/atm90e32/atm90e32.h"
#include "esphome/components/number/number.h"
-namespace esphome {
-namespace atm90e32 {
+namespace esphome::atm90e32 {
class ATM90E32Number : public number::Number, public Parented {
public:
void control(float value) override { this->publish_state(value); }
};
-} // namespace atm90e32
-} // namespace esphome
+} // namespace esphome::atm90e32
diff --git a/esphome/components/atm90e32/sensor.py b/esphome/components/atm90e32/sensor.py
index 7e5d85c57..dc46138ad 100644
--- a/esphome/components/atm90e32/sensor.py
+++ b/esphome/components/atm90e32/sensor.py
@@ -193,6 +193,7 @@ CONFIG_SCHEMA = (
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
+ cg.add(var.set_instance_id(str(config[CONF_ID])))
await cg.register_component(var, config)
await spi.register_spi_device(var, config)
diff --git a/esphome/components/audio/__init__.py b/esphome/components/audio/__init__.py
index fee582ca2..c9775ab60 100644
--- a/esphome/components/audio/__init__.py
+++ b/esphome/components/audio/__init__.py
@@ -1,4 +1,4 @@
-from dataclasses import dataclass
+from dataclasses import dataclass, field
import esphome.codegen as cg
from esphome.components.esp32 import (
@@ -7,10 +7,16 @@ from esphome.components.esp32 import (
include_builtin_idf_component,
)
import esphome.config_validation as cv
-from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE
+from esphome.const import (
+ CONF_BITS_PER_SAMPLE,
+ CONF_NUM_CHANNELS,
+ CONF_SAMPLE_RATE,
+ CONF_SIZE,
+)
from esphome.core import CORE
import esphome.final_validate as fv
+AUTO_LOAD = ["ring_buffer"]
CODEOWNERS = ["@kahrendt"]
DOMAIN = "audio"
audio_ns = cg.esphome_ns.namespace("audio")
@@ -25,13 +31,45 @@ AUDIO_FILE_TYPE_ENUM = {
"OPUS": AudioFileType.OPUS,
}
+MEMORY_PSRAM = "psram"
+MEMORY_INTERNAL = "internal"
+MEMORY_LOCATIONS = [MEMORY_PSRAM, MEMORY_INTERNAL]
+
+
+@dataclass
+class FlacOptions:
+ buffer_memory: str | None = None
+
+
+@dataclass
+class Mp3Options:
+ buffer_memory: str | None = None
+
+
+@dataclass
+class OpusPseudostackOptions:
+ threadsafe: bool | None = None
+ buffer_memory: str | None = None
+ size: int | None = None
+
+
+@dataclass
+class OpusOptions:
+ floating_point: bool | None = None
+ state_memory: str | None = None
+ pseudostack: OpusPseudostackOptions = field(default_factory=OpusPseudostackOptions)
+
@dataclass
class AudioData:
flac_support: bool = False
mp3_support: bool = False
opus_support: bool = False
+ wav_support: bool = False
micro_decoder_support: bool = False
+ flac: FlacOptions = field(default_factory=FlacOptions)
+ mp3: Mp3Options = field(default_factory=Mp3Options)
+ opus: OpusOptions = field(default_factory=OpusOptions)
def _get_data() -> AudioData:
@@ -55,6 +93,11 @@ def request_opus_support() -> None:
_get_data().opus_support = True
+def request_wav_support() -> None:
+ """Request WAV codec support for audio decoding."""
+ _get_data().wav_support = True
+
+
def request_micro_decoder_support() -> None:
"""Request micro-decoder library support for audio decoding."""
_get_data().micro_decoder_support = True
@@ -67,9 +110,78 @@ CONF_MAX_CHANNELS = "max_channels"
CONF_MIN_SAMPLE_RATE = "min_sample_rate"
CONF_MAX_SAMPLE_RATE = "max_sample_rate"
+CONF_CODECS = "codecs"
+CONF_WAV = "wav"
+CONF_FLAC = "flac"
+CONF_MP3 = "mp3"
+CONF_OPUS = "opus"
+CONF_BUFFER_MEMORY = "buffer_memory"
+CONF_FLOATING_POINT = "floating_point"
+CONF_STATE_MEMORY = "state_memory"
+CONF_PSEUDOSTACK = "pseudostack"
+CONF_THREADSAFE = "threadsafe"
+
+
+_MEMORY_LOCATION_VALIDATOR = cv.one_of(*MEMORY_LOCATIONS, lower=True)
+
+
+def _maybe_empty_codec(schema):
+ """Wrap a codec dict schema so that a bare key (None value) is treated as an empty dict."""
+
+ def validator(value):
+ if value is None:
+ value = {}
+ return schema(value)
+
+ return validator
+
+
+CODEC_FLAC_SCHEMA = cv.Schema(
+ {
+ cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
+ }
+)
+
+CODEC_MP3_SCHEMA = cv.Schema(
+ {
+ cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
+ }
+)
+
+OPUS_PSEUDOSTACK_SCHEMA = cv.Schema(
+ {
+ cv.Optional(CONF_THREADSAFE): cv.boolean,
+ cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
+ cv.Optional(CONF_SIZE): cv.int_range(60000, 240000),
+ }
+)
+
+CODEC_OPUS_SCHEMA = cv.Schema(
+ {
+ cv.Optional(CONF_FLOATING_POINT): cv.boolean,
+ cv.Optional(CONF_STATE_MEMORY): _MEMORY_LOCATION_VALIDATOR,
+ cv.Optional(CONF_PSEUDOSTACK): _maybe_empty_codec(OPUS_PSEUDOSTACK_SCHEMA),
+ }
+)
+
+CODEC_WAV_SCHEMA = cv.Schema({})
+
+CODECS_SCHEMA = cv.Schema(
+ {
+ cv.Optional(CONF_FLAC): _maybe_empty_codec(CODEC_FLAC_SCHEMA),
+ cv.Optional(CONF_MP3): _maybe_empty_codec(CODEC_MP3_SCHEMA),
+ cv.Optional(CONF_OPUS): _maybe_empty_codec(CODEC_OPUS_SCHEMA),
+ cv.Optional(CONF_WAV): _maybe_empty_codec(CODEC_WAV_SCHEMA),
+ }
+)
CONFIG_SCHEMA = cv.All(
- cv.Schema({}),
+ cv.Schema(
+ {
+ cv.Optional(CONF_CODECS): _maybe_empty_codec(CODECS_SCHEMA),
+ }
+ ),
+ cv.only_on_esp32,
)
AUDIO_COMPONENT_SCHEMA = cv.Schema(
@@ -208,19 +320,58 @@ def final_validate_audio_schema(
)
+def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> None:
+ if value == MEMORY_PSRAM:
+ add_idf_sdkconfig_option(psram_key, True)
+ add_idf_sdkconfig_option(internal_key, False)
+ elif value == MEMORY_INTERNAL:
+ add_idf_sdkconfig_option(psram_key, False)
+ add_idf_sdkconfig_option(internal_key, True)
+
+
async def to_code(config):
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
include_builtin_idf_component("esp_http_client")
add_idf_component(
name="esphome/esp-audio-libs",
- ref="2.0.4",
+ ref="3.1.0",
)
data = _get_data()
+ # Merge user-supplied codec configuration (additive: presence enables the codec)
+ if codecs_config := config.get(CONF_CODECS):
+ if (flac_config := codecs_config.get(CONF_FLAC)) is not None:
+ data.flac_support = True
+ if (buffer_memory := flac_config.get(CONF_BUFFER_MEMORY)) is not None:
+ data.flac.buffer_memory = buffer_memory
+ if (mp3_config := codecs_config.get(CONF_MP3)) is not None:
+ data.mp3_support = True
+ if (buffer_memory := mp3_config.get(CONF_BUFFER_MEMORY)) is not None:
+ data.mp3.buffer_memory = buffer_memory
+ if (opus_config := codecs_config.get(CONF_OPUS)) is not None:
+ data.opus_support = True
+ floating_point = opus_config.get(CONF_FLOATING_POINT)
+ if floating_point is not None:
+ data.opus.floating_point = floating_point
+ if (state_memory := opus_config.get(CONF_STATE_MEMORY)) is not None:
+ data.opus.state_memory = state_memory
+ if (pseudostack_config := opus_config.get(CONF_PSEUDOSTACK)) is not None:
+ threadsafe = pseudostack_config.get(CONF_THREADSAFE)
+ if threadsafe is not None:
+ data.opus.pseudostack.threadsafe = threadsafe
+ if (
+ buffer_memory := pseudostack_config.get(CONF_BUFFER_MEMORY)
+ ) is not None:
+ data.opus.pseudostack.buffer_memory = buffer_memory
+ if (size := pseudostack_config.get(CONF_SIZE)) is not None:
+ data.opus.pseudostack.size = size
+ if CONF_WAV in codecs_config:
+ data.wav_support = True
+
if data.micro_decoder_support:
- add_idf_component(name="esphome/micro-decoder", ref="0.1.1")
+ add_idf_component(name="esphome/micro-decoder", ref="0.2.0")
# All codecs are enabled by default in micro-decoder, so disable the ones that aren't requested to save flash
if not data.flac_support:
@@ -229,13 +380,54 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
if not data.opus_support:
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
+ if not data.wav_support:
+ add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False)
- # Legacy audio_decoder.cpp support defines and components
+ # Configure each codec library.
+ # Adds a define and IDF component for legacy `audio_decoder.cpp`.
if data.flac_support:
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
- add_idf_component(name="esphome/micro-flac", ref="0.1.1")
+ add_idf_component(name="esphome/micro-flac", ref="0.2.0")
+ _emit_memory_pair(
+ data.flac.buffer_memory,
+ "CONFIG_MICRO_FLAC_PREFER_PSRAM",
+ "CONFIG_MICRO_FLAC_PREFER_INTERNAL",
+ )
if data.mp3_support:
cg.add_define("USE_AUDIO_MP3_SUPPORT")
+ add_idf_component(name="esphome/micro-mp3", ref="0.2.1")
+ _emit_memory_pair(
+ data.mp3.buffer_memory,
+ "CONFIG_MP3_DECODER_PREFER_PSRAM",
+ "CONFIG_MP3_DECODER_PREFER_INTERNAL",
+ )
if data.opus_support:
cg.add_define("USE_AUDIO_OPUS_SUPPORT")
- add_idf_component(name="esphome/micro-opus", ref="0.3.6")
+ add_idf_component(name="esphome/micro-opus", ref="0.4.1")
+ if data.opus.floating_point is not None:
+ add_idf_sdkconfig_option(
+ "CONFIG_OPUS_FLOATING_POINT", data.opus.floating_point
+ )
+ _emit_memory_pair(
+ data.opus.state_memory,
+ "CONFIG_OPUS_STATE_PREFER_PSRAM",
+ "CONFIG_OPUS_STATE_PREFER_INTERNAL",
+ )
+ if data.opus.pseudostack.threadsafe is True:
+ add_idf_sdkconfig_option("CONFIG_OPUS_THREADSAFE_PSEUDOSTACK", True)
+ add_idf_sdkconfig_option("CONFIG_OPUS_NONTHREADSAFE_PSEUDOSTACK", False)
+ elif data.opus.pseudostack.threadsafe is False:
+ add_idf_sdkconfig_option("CONFIG_OPUS_THREADSAFE_PSEUDOSTACK", False)
+ add_idf_sdkconfig_option("CONFIG_OPUS_NONTHREADSAFE_PSEUDOSTACK", True)
+ _emit_memory_pair(
+ data.opus.pseudostack.buffer_memory,
+ "CONFIG_OPUS_PSEUDOSTACK_PREFER_PSRAM",
+ "CONFIG_OPUS_PSEUDOSTACK_PREFER_INTERNAL",
+ )
+ if data.opus.pseudostack.size is not None:
+ add_idf_sdkconfig_option(
+ "CONFIG_OPUS_PSEUDOSTACK_SIZE", data.opus.pseudostack.size
+ )
+ if data.wav_support:
+ cg.add_define("USE_AUDIO_WAV_SUPPORT")
+ add_idf_component(name="esphome/micro-wav", ref="0.2.0")
diff --git a/esphome/components/audio/audio.cpp b/esphome/components/audio/audio.cpp
index 3d675109e..b0aa3c1ab 100644
--- a/esphome/components/audio/audio.cpp
+++ b/esphome/components/audio/audio.cpp
@@ -4,8 +4,7 @@
#include
-namespace esphome {
-namespace audio {
+namespace esphome::audio {
// Euclidean's algorithm for finding the greatest common divisor
static uint32_t gcd(uint32_t a, uint32_t b) {
@@ -55,8 +54,10 @@ const char *audio_file_type_to_string(AudioFileType file_type) {
case AudioFileType::OPUS:
return "OPUS";
#endif
+#ifdef USE_AUDIO_WAV_SUPPORT
case AudioFileType::WAV:
return "WAV";
+#endif
default:
return "unknown";
}
@@ -71,9 +72,11 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url)
return AudioFileType::MP3;
}
#endif
+#ifdef USE_AUDIO_WAV_SUPPORT
if (strcasecmp(content_type, "audio/wav") == 0) {
return AudioFileType::WAV;
}
+#endif
#ifdef USE_AUDIO_FLAC_SUPPORT
if (strcasecmp(content_type, "audio/flac") == 0 || strcasecmp(content_type, "audio/x-flac") == 0) {
return AudioFileType::FLAC;
@@ -91,9 +94,11 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url)
// Fallback to URL extension
if (url != nullptr && url[0] != '\0') {
+#ifdef USE_AUDIO_WAV_SUPPORT
if (str_endswith_ignore_case(url, ".wav")) {
return AudioFileType::WAV;
}
+#endif
#ifdef USE_AUDIO_MP3_SUPPORT
if (str_endswith_ignore_case(url, ".mp3")) {
return AudioFileType::MP3;
@@ -123,5 +128,4 @@ void scale_audio_samples(const int16_t *audio_samples, int16_t *output_buffer, i
}
}
-} // namespace audio
-} // namespace esphome
+} // namespace esphome::audio
diff --git a/esphome/components/audio/audio.h b/esphome/components/audio/audio.h
index d3b41a362..62c57b18c 100644
--- a/esphome/components/audio/audio.h
+++ b/esphome/components/audio/audio.h
@@ -5,8 +5,7 @@
#include
#include
-namespace esphome {
-namespace audio {
+namespace esphome::audio {
class AudioStreamInfo {
/* Class to respresent important parameters of the audio stream that also provides helper function to convert between
@@ -116,7 +115,9 @@ enum class AudioFileType : uint8_t {
#ifdef USE_AUDIO_OPUS_SUPPORT
OPUS,
#endif
+#ifdef USE_AUDIO_WAV_SUPPORT
WAV,
+#endif
};
struct AudioFile {
@@ -193,5 +194,4 @@ inline void pack_q31_as_audio_sample(int32_t sample, uint8_t *data, size_t bytes
}
}
-} // namespace audio
-} // namespace esphome
+} // namespace esphome::audio
diff --git a/esphome/components/audio/audio_decoder.cpp b/esphome/components/audio/audio_decoder.cpp
index baa4c41c0..f709c23fb 100644
--- a/esphome/components/audio/audio_decoder.cpp
+++ b/esphome/components/audio/audio_decoder.cpp
@@ -5,14 +5,16 @@
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace audio {
+namespace esphome::audio {
static const char *const TAG = "audio.decoder";
-static const uint32_t DECODING_TIMEOUT_MS = 50; // The decode function will yield after this duration
static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data
+// Max consecutive decode iterations that consume input but produce no output; e.g., skipping a large metadata block,
+// before yielding and returning.
+static const uint8_t MAX_NO_OUTPUT_ITERATIONS = 32;
+
static const uint32_t MAX_POTENTIALLY_FAILED_COUNT = 10;
AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
@@ -20,20 +22,14 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size);
}
-AudioDecoder::~AudioDecoder() {
-#ifdef USE_AUDIO_MP3_SUPPORT
- if (this->audio_file_type_ == AudioFileType::MP3) {
- esp_audio_libs::helix_decoder::MP3FreeDecoder(this->mp3_decoder_);
- }
-#endif
-}
-
-esp_err_t AudioDecoder::add_source(std::weak_ptr &input_ring_buffer) {
- auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_);
+esp_err_t AudioDecoder::add_source(std::weak_ptr &input_ring_buffer) {
+ // Zero-copy source reading directly from the ring buffer's internal storage. Raw file data is byte
+ // aligned, so no frame alignment is required.
+ auto source = RingBufferAudioSource::create(input_ring_buffer.lock(), this->input_buffer_size_);
if (source == nullptr) {
- return ESP_ERR_NO_MEM;
+ // create() only returns nullptr for invalid arguments (expired ring buffer or zero buffer size)
+ return ESP_ERR_INVALID_ARG;
}
- source->set_source(input_ring_buffer);
this->input_buffer_ = std::move(source);
return ESP_OK;
}
@@ -45,7 +41,7 @@ esp_err_t AudioDecoder::add_source(const uint8_t *data_pointer, size_t length) {
return ESP_OK;
}
-esp_err_t AudioDecoder::add_sink(std::weak_ptr &output_ring_buffer) {
+esp_err_t AudioDecoder::add_sink(std::weak_ptr &output_ring_buffer) {
if (this->output_transfer_buffer_ != nullptr) {
this->output_transfer_buffer_->set_sink(output_ring_buffer);
return ESP_OK;
@@ -87,18 +83,13 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) {
this->flac_decoder_ = make_unique();
this->free_buffer_required_ =
this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header
- this->decoder_buffers_internally_ = true;
break;
#endif
#ifdef USE_AUDIO_MP3_SUPPORT
case AudioFileType::MP3:
- this->mp3_decoder_ = esp_audio_libs::helix_decoder::MP3InitDecoder();
-
- // MP3 always has 1152 samples per chunk
- this->free_buffer_required_ = 1152 * sizeof(int16_t) * 2; // samples * size per sample * channels
-
- // Always reallocate the output transfer buffer to the smallest necessary size
- this->output_transfer_buffer_->reallocate(this->free_buffer_required_);
+ this->mp3_decoder_ = make_unique();
+ this->free_buffer_required_ =
+ this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header
break;
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
@@ -106,20 +97,18 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) {
this->opus_decoder_ = make_unique();
this->free_buffer_required_ =
this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header
- this->decoder_buffers_internally_ = true;
break;
#endif
+#ifdef USE_AUDIO_WAV_SUPPORT
case AudioFileType::WAV:
- this->wav_decoder_ = make_unique();
- this->wav_decoder_->reset();
-
- // Processing WAVs doesn't actually require a specific amount of buffer size, as it is already in PCM format.
- // Thus, we don't reallocate to a minimum size.
+ this->wav_decoder_ = make_unique();
+ // 1 KiB suffices to always make progress while avoiding excessive CPU spinning for decoding
this->free_buffer_required_ = 1024;
if (this->output_transfer_buffer_->capacity() < this->free_buffer_required_) {
this->output_transfer_buffer_->reallocate(this->free_buffer_required_);
}
break;
+#endif
case AudioFileType::NONE:
default:
return ESP_ERR_NOT_SUPPORTED;
@@ -157,13 +146,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
}
FileDecoderState state = FileDecoderState::MORE_TO_PROCESS;
-
- uint32_t decoding_start = millis();
-
- bool first_loop_iteration = true;
-
- size_t bytes_processed = 0;
- size_t bytes_available_before_processing = 0;
+ uint8_t no_output_iterations = 0;
while (state == FileDecoderState::MORE_TO_PROCESS) {
// Transfer decoded out
@@ -177,47 +160,39 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
this->playback_ms_ +=
this->audio_stream_info_.value().frames_to_milliseconds_with_remainder(&this->accumulated_frames_written_);
}
+
+ if ((bytes_written > 0) && (this->output_transfer_buffer_->available() == 0)) {
+ // All decoded audio has been flushed to the sink; return so the caller can react to stop/pause before
+ // decoding the next batch
+ return AudioDecoderState::DECODING;
+ }
} else {
// If paused, block to avoid wasting CPU resources
delay(READ_WRITE_TIMEOUT_MS);
}
- // Verify there is enough space to store more decoded audio and that the function hasn't been running too long
- if ((this->output_transfer_buffer_->free() < this->free_buffer_required_) ||
- (millis() - decoding_start > DECODING_TIMEOUT_MS)) {
+ if (this->output_transfer_buffer_->available() > 0) {
+ // Output transfer buffer indicates backpressure, return so caller can handle other events;
+ // e.g., stop/pause, before trying again
return AudioDecoderState::DECODING;
}
- // Decode more audio
-
- // Only shift data on the first loop iteration to avoid unnecessary, slow moves
- // If the decoder buffers internally, then never shift
- size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS),
- first_loop_iteration && !this->decoder_buffers_internally_);
-
- if (!first_loop_iteration && (this->input_buffer_->available() < bytes_processed)) {
- // Less data is available than what was processed in last iteration, so don't attempt to decode.
- // This attempts to avoid the decoder from consistently trying to decode an incomplete frame. The transfer buffer
- // will shift the remaining data to the start and copy more from the source the next time the decode function is
- // called
- break;
+ // Reaching here means no decoded output is pending (any would have returned above). Bounds long no-output
+ // stretches; e.g., skipping a large metadata block, so a source that keeps the ring buffer full can't spin this
+ // loop without yielding and trip the watchdog. The delay yields allowing other tasks to feed the watchdog and
+ // the return keeps stop/pause responsive.
+ if (++no_output_iterations >= MAX_NO_OUTPUT_ITERATIONS) {
+ delay(1);
+ return AudioDecoderState::DECODING;
}
- bytes_available_before_processing = this->input_buffer_->available();
+ // Expose the next chunk of file data. Every decoder buffers internally and consumes only what it
+ // processed, so the source does not need to accumulate or stitch chunks across fill() calls.
+ this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
- if ((this->potentially_failed_count_ > 0) && (bytes_read == 0)) {
- // Failed to decode in last attempt and there is no new data
+ const size_t available_before_decode = this->input_buffer_->available();
- if ((this->input_buffer_->free() == 0) && first_loop_iteration) {
- // The input buffer is full (or read-only, e.g. const flash source). Since it previously failed on the exact
- // same data, we can never recover. For const sources this is correct: the entire file is already available, so
- // a decode failure is genuine, not a transient out-of-data condition.
- state = FileDecoderState::FAILED;
- } else {
- // Attempt to get more data next time
- state = FileDecoderState::IDLE;
- }
- } else if (this->input_buffer_->available() == 0) {
+ if (available_before_decode == 0) {
// No data to decode, attempt to get more data next time
state = FileDecoderState::IDLE;
} else {
@@ -237,9 +212,11 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
state = this->decode_opus_();
break;
#endif
+#ifdef USE_AUDIO_WAV_SUPPORT
case AudioFileType::WAV:
state = this->decode_wav_();
break;
+#endif
case AudioFileType::NONE:
default:
state = FileDecoderState::IDLE;
@@ -247,9 +224,6 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
}
}
- first_loop_iteration = false;
- bytes_processed = bytes_available_before_processing - this->input_buffer_->available();
-
if (state == FileDecoderState::POTENTIALLY_FAILED) {
++this->potentially_failed_count_;
} else if (state == FileDecoderState::END_OF_FILE) {
@@ -257,7 +231,16 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
} else if (state == FileDecoderState::FAILED) {
return AudioDecoderState::FAILED;
} else if (state == FileDecoderState::MORE_TO_PROCESS) {
- this->potentially_failed_count_ = 0;
+ // Reset the failsafe only when the iteration made forward progress: input was consumed or output was
+ // produced (output_transfer_buffer_ is drained empty above, so any available bytes are new). A
+ // MORE_TO_PROCESS that neither consumes input nor produces output means the decoder is stalled; count it
+ // toward the failsafe so a stuck stream eventually surfaces as FAILED instead of looping forever.
+ if ((this->input_buffer_->available() < available_before_decode) ||
+ (this->output_transfer_buffer_->available() > 0)) {
+ this->potentially_failed_count_ = 0;
+ } else {
+ ++this->potentially_failed_count_;
+ }
}
}
return AudioDecoderState::DECODING;
@@ -312,51 +295,56 @@ FileDecoderState AudioDecoder::decode_flac_() {
#ifdef USE_AUDIO_MP3_SUPPORT
FileDecoderState AudioDecoder::decode_mp3_() {
- // Look for the next sync word
- int buffer_length = (int) this->input_buffer_->available();
- int32_t offset = esp_audio_libs::helix_decoder::MP3FindSyncWord(this->input_buffer_->data(), buffer_length);
+ // microMP3's samples_decoded value is samples per channel; e.g., what ESPHome typically calls an audio frame.
+ // microMP3 uses the term frame to refer to an MP3 frame: an encoded packet that contains multiple audio frames.
+ size_t bytes_consumed = 0;
+ size_t samples_decoded = 0;
- if (offset < 0) {
- // New data may have the sync word
- this->input_buffer_->consume(buffer_length);
+ // microMP3 buffers internally: it consumes from our input buffer at its own pace, emits MP3_STREAM_INFO_READY once
+ // the first frame header is parsed, and only then produces PCM. It handles sync-word search and ID3v2 tag skipping.
+ micro_mp3::Mp3Result result = this->mp3_decoder_->decode(
+ this->input_buffer_->data(), this->input_buffer_->available(), this->output_transfer_buffer_->get_buffer_end(),
+ this->output_transfer_buffer_->free(), bytes_consumed, samples_decoded);
+
+ this->input_buffer_->consume(bytes_consumed);
+
+ if (result == micro_mp3::MP3_OK) {
+ if (samples_decoded > 0 && this->audio_stream_info_.has_value()) {
+ this->output_transfer_buffer_->increase_buffer_length(
+ this->audio_stream_info_.value().frames_to_bytes(samples_decoded));
+ }
+ } else if (result == micro_mp3::MP3_STREAM_INFO_READY) {
+ // First successful header parse: capture stream info and resize the output buffer to fit one full frame.
+ // microMP3 always outputs 16-bit PCM.
+ this->audio_stream_info_ =
+ audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate());
+ this->free_buffer_required_ =
+ this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t);
+ if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) {
+ return FileDecoderState::FAILED;
+ }
+ } else if (result == micro_mp3::MP3_NEED_MORE_DATA) {
+ return FileDecoderState::MORE_TO_PROCESS;
+ } else if (result == micro_mp3::MP3_OUTPUT_BUFFER_TOO_SMALL) {
+ // Reallocate to decode the frame on the next call
+ if (this->mp3_decoder_->get_channels() > 0) {
+ this->free_buffer_required_ =
+ this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t);
+ } else {
+ // Fallback to worst-case size if channel info isn't available
+ this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes();
+ }
+ if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) {
+ return FileDecoderState::FAILED;
+ }
+ } else if (result == micro_mp3::MP3_DECODE_ERROR) {
+ // Corrupt frame skipped; recoverable, retry on next call
+ ESP_LOGW(TAG, "MP3 decoder skipped a corrupt frame");
return FileDecoderState::POTENTIALLY_FAILED;
- }
-
- // Advance read pointer to match the offset for the syncword
- this->input_buffer_->consume(offset);
- const uint8_t *buffer_start = this->input_buffer_->data();
-
- buffer_length = (int) this->input_buffer_->available();
- int err = esp_audio_libs::helix_decoder::MP3Decode(this->mp3_decoder_, &buffer_start, &buffer_length,
- (int16_t *) this->output_transfer_buffer_->get_buffer_end(), 0);
-
- size_t consumed = this->input_buffer_->available() - buffer_length;
- this->input_buffer_->consume(consumed);
-
- if (err) {
- switch (err) {
- case esp_audio_libs::helix_decoder::ERR_MP3_OUT_OF_MEMORY:
- [[fallthrough]];
- case esp_audio_libs::helix_decoder::ERR_MP3_NULL_POINTER:
- return FileDecoderState::FAILED;
- break;
- default:
- // Most errors are recoverable by moving on to the next frame, so mark as potentailly failed
- return FileDecoderState::POTENTIALLY_FAILED;
- break;
- }
} else {
- esp_audio_libs::helix_decoder::MP3FrameInfo mp3_frame_info;
- esp_audio_libs::helix_decoder::MP3GetLastFrameInfo(this->mp3_decoder_, &mp3_frame_info);
- if (mp3_frame_info.outputSamps > 0) {
- int bytes_per_sample = (mp3_frame_info.bitsPerSample / 8);
- this->output_transfer_buffer_->increase_buffer_length(mp3_frame_info.outputSamps * bytes_per_sample);
-
- if (!this->audio_stream_info_.has_value()) {
- this->audio_stream_info_ =
- audio::AudioStreamInfo(mp3_frame_info.bitsPerSample, mp3_frame_info.nChans, mp3_frame_info.samprate);
- }
- }
+ // MP3_ALLOCATION_FAILED, MP3_INPUT_INVALID, or any future error -- not recoverable
+ ESP_LOGE(TAG, "MP3 decoder failed: %d", static_cast(result));
+ return FileDecoderState::FAILED;
}
return FileDecoderState::MORE_TO_PROCESS;
@@ -401,54 +389,43 @@ FileDecoderState AudioDecoder::decode_opus_() {
}
#endif
+#ifdef USE_AUDIO_WAV_SUPPORT
FileDecoderState AudioDecoder::decode_wav_() {
- if (!this->audio_stream_info_.has_value()) {
- // Header hasn't been processed
+ // microWAV's samples_decoded counts individual channel samples; e.g., for
+ // 16-bit stereo, 4 input bytes results in 2 samples_decoded.
+ size_t bytes_consumed = 0;
+ size_t samples_decoded = 0;
- esp_audio_libs::wav_decoder::WAVDecoderResult result =
- this->wav_decoder_->decode_header(this->input_buffer_->data(), this->input_buffer_->available());
+ micro_wav::WAVDecoderResult result = this->wav_decoder_->decode(
+ this->input_buffer_->data(), this->input_buffer_->available(), this->output_transfer_buffer_->get_buffer_end(),
+ this->output_transfer_buffer_->free(), bytes_consumed, samples_decoded);
- if (result == esp_audio_libs::wav_decoder::WAV_DECODER_SUCCESS_IN_DATA) {
- this->input_buffer_->consume(this->wav_decoder_->bytes_processed());
+ this->input_buffer_->consume(bytes_consumed);
- this->audio_stream_info_ = audio::AudioStreamInfo(
- this->wav_decoder_->bits_per_sample(), this->wav_decoder_->num_channels(), this->wav_decoder_->sample_rate());
-
- this->wav_bytes_left_ = this->wav_decoder_->chunk_bytes_left();
- this->wav_has_known_end_ = (this->wav_bytes_left_ > 0);
- return FileDecoderState::MORE_TO_PROCESS;
- } else if (result == esp_audio_libs::wav_decoder::WAV_DECODER_WARNING_INCOMPLETE_DATA) {
- // Available data didn't have the full header
- return FileDecoderState::POTENTIALLY_FAILED;
- } else {
- return FileDecoderState::FAILED;
+ if (result == micro_wav::WAV_DECODER_SUCCESS) {
+ if (samples_decoded > 0 && this->audio_stream_info_.has_value()) {
+ this->output_transfer_buffer_->increase_buffer_length(
+ this->audio_stream_info_.value().samples_to_bytes(samples_decoded));
}
+ } else if (result == micro_wav::WAV_DECODER_HEADER_READY) {
+ // After HEADER_READY, get_bits_per_sample() returns the output bit depth
+ // (16 for A-law/mu-law, 32 for IEEE float, original value for PCM).
+ this->audio_stream_info_ =
+ audio::AudioStreamInfo(this->wav_decoder_->get_bits_per_sample(), this->wav_decoder_->get_channels(),
+ this->wav_decoder_->get_sample_rate());
+ } else if (result == micro_wav::WAV_DECODER_NEED_MORE_DATA) {
+ return FileDecoderState::MORE_TO_PROCESS;
+ } else if (result == micro_wav::WAV_DECODER_END_OF_STREAM) {
+ return FileDecoderState::END_OF_FILE;
} else {
- if (!this->wav_has_known_end_ || (this->wav_bytes_left_ > 0)) {
- size_t bytes_to_copy = this->input_buffer_->available();
-
- if (this->wav_has_known_end_) {
- bytes_to_copy = std::min(bytes_to_copy, this->wav_bytes_left_);
- }
-
- bytes_to_copy = std::min(bytes_to_copy, this->output_transfer_buffer_->free());
-
- if (bytes_to_copy > 0) {
- std::memcpy(this->output_transfer_buffer_->get_buffer_end(), this->input_buffer_->data(), bytes_to_copy);
- this->input_buffer_->consume(bytes_to_copy);
- this->output_transfer_buffer_->increase_buffer_length(bytes_to_copy);
- if (this->wav_has_known_end_) {
- this->wav_bytes_left_ -= bytes_to_copy;
- }
- }
- return FileDecoderState::IDLE;
- }
+ ESP_LOGE(TAG, "WAV decoder failed: %d", static_cast(result));
+ return FileDecoderState::FAILED;
}
- return FileDecoderState::END_OF_FILE;
+ return FileDecoderState::MORE_TO_PROCESS;
}
+#endif
-} // namespace audio
-} // namespace esphome
+} // namespace esphome::audio
#endif
diff --git a/esphome/components/audio/audio_decoder.h b/esphome/components/audio/audio_decoder.h
index 6e3a228a6..e772b7eb5 100644
--- a/esphome/components/audio/audio_decoder.h
+++ b/esphome/components/audio/audio_decoder.h
@@ -5,9 +5,9 @@
#include "audio.h"
#include "audio_transfer_buffer.h"
+#include "esphome/components/ring_buffer/ring_buffer.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
-#include "esphome/core/ring_buffer.h"
#ifdef USE_SPEAKER
#include "esphome/components/speaker/speaker.h"
@@ -15,24 +15,27 @@
#include "esp_err.h"
-// esp-audio-libs
-#ifdef USE_AUDIO_MP3_SUPPORT
-#include
-#endif
-#include
-
// micro-flac
#ifdef USE_AUDIO_FLAC_SUPPORT
#include
#endif
+// micro-mp3
+#ifdef USE_AUDIO_MP3_SUPPORT
+#include
+#endif
+
// micro-opus
#ifdef USE_AUDIO_OPUS_SUPPORT
#include
#endif
-namespace esphome {
-namespace audio {
+// micro-wav
+#ifdef USE_AUDIO_WAV_SUPPORT
+#include
+#endif
+
+namespace esphome::audio {
enum class AudioDecoderState : uint8_t {
DECODING = 0, // More data is available to decode
@@ -54,26 +57,26 @@ class AudioDecoder {
* @brief Class that facilitates decoding an audio file.
* The audio file is read from a source (ring buffer or const data pointer), decoded, and sent to an audio sink
* (ring buffer, speaker component, or callback).
- * Supports wav, flac, mp3, and ogg opus formats.
+ * Supports flac, mp3, ogg opus, and wav formats (each enabled independently at compile time).
*/
public:
/// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source()
- /// @param input_buffer_size Size of the input transfer buffer in bytes.
+ /// @param input_buffer_size Soft cap on the bytes a ring buffer source exposes per fill, in bytes.
/// @param output_buffer_size Size of the output transfer buffer in bytes.
AudioDecoder(size_t input_buffer_size, size_t output_buffer_size);
- /// @brief Deallocates the MP3 decoder (the flac, opus, and wav decoders are deallocated automatically)
- ~AudioDecoder();
+ ~AudioDecoder() = default;
- /// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr.
- /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
- /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
- esp_err_t add_source(std::weak_ptr &input_ring_buffer);
+ /// @brief Adds a source ring buffer for raw file data. Shares ownership of the ring buffer via a shared_ptr.
+ /// The decoder reads directly from the ring buffer's internal storage with a zero-copy RingBufferAudioSource.
+ /// @param input_ring_buffer weak_ptr of the source ring buffer to read from
+ /// @return ESP_OK if successful, ESP_ERR_INVALID_ARG if the ring buffer is expired or the buffer size is zero
+ esp_err_t add_source(std::weak_ptr &input_ring_buffer);
/// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr.
/// @param output_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
- esp_err_t add_sink(std::weak_ptr &output_ring_buffer);
+ esp_err_t add_sink(std::weak_ptr &output_ring_buffer);
#ifdef USE_SPEAKER
/// @brief Adds a sink speaker for decoded audio.
@@ -118,20 +121,22 @@ class AudioDecoder {
void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; }
protected:
- std::unique_ptr wav_decoder_;
#ifdef USE_AUDIO_FLAC_SUPPORT
FileDecoderState decode_flac_();
std::unique_ptr flac_decoder_;
#endif
#ifdef USE_AUDIO_MP3_SUPPORT
FileDecoderState decode_mp3_();
- esp_audio_libs::helix_decoder::HMP3Decoder mp3_decoder_;
+ std::unique_ptr mp3_decoder_;
#endif
#ifdef USE_AUDIO_OPUS_SUPPORT
FileDecoderState decode_opus_();
std::unique_ptr opus_decoder_;
#endif
+#ifdef USE_AUDIO_WAV_SUPPORT
FileDecoderState decode_wav_();
+ std::unique_ptr wav_decoder_;
+#endif
std::unique_ptr input_buffer_;
std::unique_ptr output_transfer_buffer_;
@@ -141,20 +146,15 @@ class AudioDecoder {
size_t input_buffer_size_{0};
size_t free_buffer_required_{0};
- size_t wav_bytes_left_{0};
uint32_t potentially_failed_count_{0};
uint32_t accumulated_frames_written_{0};
uint32_t playback_ms_{0};
bool end_of_file_{false};
- bool wav_has_known_end_{false};
-
- bool decoder_buffers_internally_{false};
bool pause_output_{false};
};
-} // namespace audio
-} // namespace esphome
+} // namespace esphome::audio
#endif
diff --git a/esphome/components/audio/audio_reader.cpp b/esphome/components/audio/audio_reader.cpp
index 79ebf5888..4678ed548 100644
--- a/esphome/components/audio/audio_reader.cpp
+++ b/esphome/components/audio/audio_reader.cpp
@@ -11,8 +11,7 @@
#include "esp_crt_bundle.h"
#endif
-namespace esphome {
-namespace audio {
+namespace esphome::audio {
static const uint32_t READ_WRITE_TIMEOUT_MS = 20;
@@ -55,7 +54,7 @@ enum HttpStatus {
AudioReader::~AudioReader() { this->cleanup_connection_(); }
-esp_err_t AudioReader::add_sink(const std::weak_ptr &output_ring_buffer) {
+esp_err_t AudioReader::add_sink(const std::weak_ptr &output_ring_buffer) {
if (current_audio_file_ != nullptr) {
// A transfer buffer isn't ncessary for a local file
this->file_ring_buffer_ = output_ring_buffer.lock();
@@ -289,7 +288,6 @@ void AudioReader::cleanup_connection_() {
}
}
-} // namespace audio
-} // namespace esphome
+} // namespace esphome::audio
#endif
diff --git a/esphome/components/audio/audio_reader.h b/esphome/components/audio/audio_reader.h
index 753b31021..b1f76172b 100644
--- a/esphome/components/audio/audio_reader.h
+++ b/esphome/components/audio/audio_reader.h
@@ -5,14 +5,13 @@
#include "audio.h"
#include "audio_transfer_buffer.h"
-#include "esphome/core/ring_buffer.h"
+#include "esphome/components/ring_buffer/ring_buffer.h"
#include "esp_err.h"
#include
-namespace esphome {
-namespace audio {
+namespace esphome::audio {
enum class AudioReaderState : uint8_t {
READING = 0, // More data is available to read
@@ -36,7 +35,7 @@ class AudioReader {
/// @brief Adds a sink ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr
/// @param output_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
/// @return ESP_OK if successful, ESP_ERR_INVALID_STATE otherwise
- esp_err_t add_sink(const std::weak_ptr &output_ring_buffer);
+ esp_err_t add_sink(const std::weak_ptr &output_ring_buffer);
/// @brief Starts reading an audio file from an http source. The transfer buffer is allocated here.
/// @param uri Web url to the http file.
@@ -61,7 +60,7 @@ class AudioReader {
AudioReaderState file_read_();
AudioReaderState http_read_();
- std::shared_ptr file_ring_buffer_;
+ std::shared_ptr file_ring_buffer_;
std::unique_ptr output_transfer_buffer_;
void cleanup_connection_();
@@ -74,7 +73,6 @@ class AudioReader {
AudioFileType audio_file_type_{AudioFileType::NONE};
const uint8_t *file_current_{nullptr};
};
-} // namespace audio
-} // namespace esphome
+} // namespace esphome::audio
#endif
diff --git a/esphome/components/audio/audio_resampler.cpp b/esphome/components/audio/audio_resampler.cpp
index 20d246f1e..bef62ce19 100644
--- a/esphome/components/audio/audio_resampler.cpp
+++ b/esphome/components/audio/audio_resampler.cpp
@@ -6,26 +6,26 @@
#include
-namespace esphome {
-namespace audio {
+namespace esphome::audio {
static const uint32_t READ_WRITE_TIMEOUT_MS = 20;
AudioResampler::AudioResampler(size_t input_buffer_size, size_t output_buffer_size)
: input_buffer_size_(input_buffer_size), output_buffer_size_(output_buffer_size) {
- this->input_transfer_buffer_ = AudioSourceTransferBuffer::create(input_buffer_size);
this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size);
}
-esp_err_t AudioResampler::add_source(std::weak_ptr &input_ring_buffer) {
- if (this->input_transfer_buffer_ != nullptr) {
- this->input_transfer_buffer_->set_source(input_ring_buffer);
- return ESP_OK;
+esp_err_t AudioResampler::add_source(std::weak_ptr &input_ring_buffer) {
+ // The zero-copy RingBufferAudioSource is created lazily on the first resample() call, once both the ring
+ // buffer (stored here) and the input stream info (set by start()) are available, in either order.
+ this->source_ring_buffer_ = input_ring_buffer.lock();
+ if (this->source_ring_buffer_ == nullptr) {
+ return ESP_ERR_INVALID_STATE;
}
- return ESP_ERR_NO_MEM;
+ return ESP_OK;
}
-esp_err_t AudioResampler::add_sink(std::weak_ptr &output_ring_buffer) {
+esp_err_t AudioResampler::add_sink(std::weak_ptr &output_ring_buffer) {
if (this->output_transfer_buffer_ != nullptr) {
this->output_transfer_buffer_->set_sink(output_ring_buffer);
return ESP_OK;
@@ -48,7 +48,7 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
this->input_stream_info_ = input_stream_info;
this->output_stream_info_ = output_stream_info;
- if ((this->input_transfer_buffer_ == nullptr) || (this->output_transfer_buffer_ == nullptr)) {
+ if (this->output_transfer_buffer_ == nullptr) {
return ESP_ERR_NO_MEM;
}
@@ -57,6 +57,13 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
return ESP_ERR_NOT_SUPPORTED;
}
+ // Reject frame sizes that can't be used as the zero-copy source's alignment up front, where the caller checks
+ // the return code. The lazy create() in resample() keeps its own guard since it runs before the uint8_t cast.
+ const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1);
+ if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) {
+ return ESP_ERR_NOT_SUPPORTED;
+ }
+
if ((input_stream_info.get_sample_rate() != output_stream_info.get_sample_rate()) ||
(input_stream_info.get_bits_per_sample() != output_stream_info.get_bits_per_sample())) {
this->resampler_ = make_unique(
@@ -88,8 +95,27 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
}
AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_differential) {
+ if (this->audio_source_ == nullptr) {
+ // Lazily create the zero-copy source on first use. Frame-aligned reads ensure multi-channel frames are
+ // never split across the ring buffer's wrap boundary.
+ const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1);
+ if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) {
+ // Stream info is unset or the frame is too large to use as an alignment; the uint8_t cast below would
+ // truncate it and could yield a source that tears frames.
+ return AudioResamplerState::FAILED;
+ }
+ // Pass the shared_ptr by copy so a failed create() leaves source_ring_buffer_ intact; release our
+ // reference only after the source has taken ownership.
+ this->audio_source_ = RingBufferAudioSource::create(this->source_ring_buffer_, this->input_buffer_size_,
+ static_cast(bytes_per_frame));
+ if (this->audio_source_ == nullptr) {
+ return AudioResamplerState::FAILED;
+ }
+ this->source_ring_buffer_.reset();
+ }
+
if (stop_gracefully) {
- if (!this->input_transfer_buffer_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
+ if (!this->audio_source_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
return AudioResamplerState::FINISHED;
}
}
@@ -103,9 +129,11 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
delay(READ_WRITE_TIMEOUT_MS);
}
- this->input_transfer_buffer_->transfer_data_from_source(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS));
+ // Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource
+ // (there is no intermediate transfer buffer to compact).
+ this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
- if (this->input_transfer_buffer_->available() == 0) {
+ if (this->audio_source_->available() == 0) {
// No samples available to process
return AudioResamplerState::RESAMPLING;
}
@@ -113,17 +141,17 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
const size_t bytes_free = this->output_transfer_buffer_->free();
const uint32_t frames_free = this->output_stream_info_.bytes_to_frames(bytes_free);
- const size_t bytes_available = this->input_transfer_buffer_->available();
+ const size_t bytes_available = this->audio_source_->available();
const uint32_t frames_available = this->input_stream_info_.bytes_to_frames(bytes_available);
if ((this->input_stream_info_.get_sample_rate() != this->output_stream_info_.get_sample_rate()) ||
(this->input_stream_info_.get_bits_per_sample() != this->output_stream_info_.get_bits_per_sample())) {
// Adjust gain by -3 dB to avoid clipping due to the resampling process
esp_audio_libs::resampler::ResamplerResults results =
- this->resampler_->resample(this->input_transfer_buffer_->get_buffer_start(),
- this->output_transfer_buffer_->get_buffer_end(), frames_available, frames_free, -3);
+ this->resampler_->resample(this->audio_source_->data(), this->output_transfer_buffer_->get_buffer_end(),
+ frames_available, frames_free, -3);
- this->input_transfer_buffer_->decrease_buffer_length(this->input_stream_info_.frames_to_bytes(results.frames_used));
+ this->audio_source_->consume(this->input_stream_info_.frames_to_bytes(results.frames_used));
this->output_transfer_buffer_->increase_buffer_length(
this->output_stream_info_.frames_to_bytes(results.frames_generated));
@@ -147,17 +175,16 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
const size_t bytes_to_transfer = std::min(this->output_stream_info_.frames_to_bytes(frames_free),
this->input_stream_info_.frames_to_bytes(frames_available));
- std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(),
- (void *) this->input_transfer_buffer_->get_buffer_start(), bytes_to_transfer);
+ std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), (const void *) this->audio_source_->data(),
+ bytes_to_transfer);
- this->input_transfer_buffer_->decrease_buffer_length(bytes_to_transfer);
+ this->audio_source_->consume(bytes_to_transfer);
this->output_transfer_buffer_->increase_buffer_length(bytes_to_transfer);
}
return AudioResamplerState::RESAMPLING;
}
-} // namespace audio
-} // namespace esphome
+} // namespace esphome::audio
#endif
diff --git a/esphome/components/audio/audio_resampler.h b/esphome/components/audio/audio_resampler.h
index 082ade337..c09070c0c 100644
--- a/esphome/components/audio/audio_resampler.h
+++ b/esphome/components/audio/audio_resampler.h
@@ -5,9 +5,9 @@
#include "audio.h"
#include "audio_transfer_buffer.h"
+#include "esphome/components/ring_buffer/ring_buffer.h"
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
-#include "esphome/core/ring_buffer.h"
#ifdef USE_SPEAKER
#include "esphome/components/speaker/speaker.h"
@@ -17,13 +17,12 @@
#include // esp-audio-libs
-namespace esphome {
-namespace audio {
+namespace esphome::audio {
enum class AudioResamplerState : uint8_t {
RESAMPLING, // More data is available to resample
FINISHED, // All file data has been resampled and transferred
- FAILED, // Unused state included for consistency among Audio classes
+ FAILED, // Failed to allocate the audio source
};
class AudioResampler {
@@ -33,20 +32,22 @@ class AudioResampler {
* component). Also supports converting bits per sample.
*/
public:
- /// @brief Allocates the input and output transfer buffers
- /// @param input_buffer_size Size of the input transfer buffer in bytes.
+ /// @brief Allocates the output transfer buffer. The input source is created later in resample().
+ /// @param input_buffer_size Max bytes exposed per fill() call on the zero-copy input source.
/// @param output_buffer_size Size of the output transfer buffer in bytes.
AudioResampler(size_t input_buffer_size, size_t output_buffer_size);
- /// @brief Adds a source ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr.
- /// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
- /// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
- esp_err_t add_source(std::weak_ptr &input_ring_buffer);
+ /// @brief Sets the ring buffer the audio is read from and takes shared ownership of it. The zero-copy
+ /// RingBufferAudioSource that reads directly from its internal storage is created lazily on the first
+ /// resample() call, so add_source() and start() may be called in any order.
+ /// @param input_ring_buffer weak_ptr of a shared_ptr of the source ring buffer to transfer ownership
+ /// @return ESP_OK if successful, ESP_ERR_INVALID_STATE if the ring buffer is no longer alive
+ esp_err_t add_source(std::weak_ptr &input_ring_buffer);
/// @brief Adds a sink ring buffer for resampled audio. Takes ownership of the ring buffer in a shared_ptr.
/// @param output_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
- esp_err_t add_sink(std::weak_ptr &output_ring_buffer);
+ esp_err_t add_sink(std::weak_ptr &output_ring_buffer);
#ifdef USE_SPEAKER
/// @brief Adds a sink speaker for decoded audio.
@@ -79,7 +80,8 @@ class AudioResampler {
void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; }
protected:
- std::unique_ptr input_transfer_buffer_;
+ std::shared_ptr source_ring_buffer_;
+ std::unique_ptr audio_source_;
std::unique_ptr output_transfer_buffer_;
size_t input_buffer_size_;
@@ -96,7 +98,6 @@ class AudioResampler {
std::unique_ptr resampler_;
};
-} // namespace audio
-} // namespace esphome
+} // namespace esphome::audio
#endif
diff --git a/esphome/components/audio/audio_transfer_buffer.cpp b/esphome/components/audio/audio_transfer_buffer.cpp
index 5cd7cf9e6..a611549e5 100644
--- a/esphome/components/audio/audio_transfer_buffer.cpp
+++ b/esphome/components/audio/audio_transfer_buffer.cpp
@@ -6,8 +6,7 @@
#include "esphome/core/helpers.h"
-namespace esphome {
-namespace audio {
+namespace esphome::audio {
AudioTransferBuffer::~AudioTransferBuffer() { this->deallocate_buffer_(); };
@@ -208,7 +207,153 @@ void ConstAudioSourceBuffer::consume(size_t bytes) {
this->data_start_ += bytes;
}
-} // namespace audio
-} // namespace esphome
+std::unique_ptr RingBufferAudioSource::create(
+ std::shared_ptr ring_buffer, size_t max_fill_bytes, uint8_t alignment_bytes) {
+ if (ring_buffer == nullptr || max_fill_bytes == 0 || alignment_bytes == 0 || alignment_bytes > MAX_ALIGNMENT_BYTES) {
+ return nullptr;
+ }
+ return std::unique_ptr(
+ new RingBufferAudioSource(std::move(ring_buffer), max_fill_bytes, alignment_bytes));
+}
+
+RingBufferAudioSource::~RingBufferAudioSource() {
+ if (this->acquired_item_ != nullptr) {
+ this->ring_buffer_->receive_release(this->acquired_item_);
+ this->acquired_item_ = nullptr;
+ }
+}
+
+void RingBufferAudioSource::release_item_() {
+ if (this->acquired_item_ == nullptr) {
+ return;
+ }
+ if (this->item_trailing_length_ > 0) {
+ // Copy the trailing sub-frame bytes into the splice buffer before returning the item; the next
+ // fill() will complete the frame from the head of the next chunk.
+ std::memcpy(this->splice_buffer_, this->item_trailing_ptr_, this->item_trailing_length_);
+ this->splice_length_ = this->item_trailing_length_;
+ this->item_trailing_ptr_ = nullptr;
+ this->item_trailing_length_ = 0;
+ }
+ this->ring_buffer_->receive_release(this->acquired_item_);
+ this->acquired_item_ = nullptr;
+}
+
+void RingBufferAudioSource::consume(size_t bytes) {
+ bytes = std::min(bytes, this->current_available_);
+ this->current_data_ += bytes;
+ this->current_available_ -= bytes;
+ // Promotion of queued data is deferred to fill() so callers see new data as a fresh return value
+ // rather than appearing silently after consume(). When the held item has nothing left depending
+ // on it (no exposed bytes and no queued region), release it now so the ring buffer can be
+ // reclaimed by writers even if fill() is never called again.
+ if (this->current_available_ == 0 && this->queued_length_ == 0) {
+ this->release_item_();
+ }
+}
+
+void RingBufferAudioSource::clear_buffered_data() {
+ // Release the held item before reset() so the source no longer references memory the reset will reclaim.
+ if (this->acquired_item_ != nullptr) {
+ this->ring_buffer_->receive_release(this->acquired_item_);
+ this->acquired_item_ = nullptr;
+ }
+ this->current_data_ = nullptr;
+ this->current_available_ = 0;
+ this->queued_data_ = nullptr;
+ this->queued_length_ = 0;
+ this->item_trailing_ptr_ = nullptr;
+ this->item_trailing_length_ = 0;
+ this->splice_length_ = 0;
+ this->ring_buffer_->reset();
+}
+
+bool RingBufferAudioSource::has_buffered_data() const {
+ // splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
+ // bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
+ // Counting it separately would strand a drain loop when a stream ends mid-frame and those completion
+ // bytes never come.
+ return (this->current_available_ > 0) || (this->queued_length_ > 0) || (this->ring_buffer_->available() > 0);
+}
+
+size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait, bool /*pre_shift*/) {
+ if (this->current_available_ > 0) {
+ // Caller has not finished consuming the current exposure
+ return 0;
+ }
+
+ // If a queued region (the aligned remainder of the new chunk after a splice frame) is waiting,
+ // promote it to the exposed region and report its size as fresh data.
+ if (this->queued_length_ > 0) {
+ this->current_data_ = this->queued_data_;
+ this->current_available_ = this->queued_length_;
+ this->queued_data_ = nullptr;
+ this->queued_length_ = 0;
+ return this->current_available_;
+ }
+
+ // Nothing exposed and nothing queued: release the previously held item (saving any sub-frame tail
+ // to splice_buffer_) and acquire a new chunk.
+ this->release_item_();
+
+ size_t chunk_length = 0;
+ void *item = this->ring_buffer_->receive_acquire(chunk_length, this->max_fill_bytes_, ticks_to_wait);
+ if (item == nullptr) {
+ return 0;
+ }
+
+ uint8_t *chunk_data = static_cast(item);
+ bool exposing_splice_frame = false;
+
+ // Complete any pending splice frame from the head of the new chunk.
+ if (this->splice_length_ > 0) {
+ const size_t needed = static_cast(this->alignment_bytes_) - this->splice_length_;
+ if (chunk_length < needed) {
+ // Not enough data to complete the spliced frame yet; absorb everything and wait for more.
+ std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, chunk_length);
+ this->splice_length_ += chunk_length;
+ this->ring_buffer_->receive_release(item);
+ return 0;
+ }
+ std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, needed);
+ chunk_data += needed;
+ chunk_length -= needed;
+ this->splice_length_ = 0;
+ exposing_splice_frame = true;
+ }
+
+ this->acquired_item_ = item;
+
+ // Split the remaining chunk into its aligned region and a (possibly zero) sub-frame trailing tail.
+ const size_t trailing = (this->alignment_bytes_ > 1) ? (chunk_length % this->alignment_bytes_) : 0;
+ const size_t aligned_bytes = chunk_length - trailing;
+ if (trailing > 0) {
+ this->item_trailing_ptr_ = chunk_data + aligned_bytes;
+ this->item_trailing_length_ = trailing;
+ }
+
+ if (exposing_splice_frame) {
+ // Expose the spliced frame from splice_buffer_, queuing the chunk's aligned region for the next
+ // fill() call.
+ this->current_data_ = this->splice_buffer_;
+ this->current_available_ = this->alignment_bytes_;
+ this->queued_data_ = chunk_data;
+ this->queued_length_ = aligned_bytes;
+ return this->alignment_bytes_;
+ }
+
+ if (aligned_bytes == 0) {
+ // The entire chunk is a sub-frame tail (only possible when alignment exceeds chunk size). Save it
+ // to the splice buffer and release the item so the next fill() can complete the frame.
+ this->release_item_();
+ return 0;
+ }
+
+ this->current_data_ = chunk_data;
+ this->current_available_ = aligned_bytes;
+ return aligned_bytes;
+}
+
+} // namespace esphome::audio
#endif
diff --git a/esphome/components/audio/audio_transfer_buffer.h b/esphome/components/audio/audio_transfer_buffer.h
index c32d4d0e4..074684f06 100644
--- a/esphome/components/audio/audio_transfer_buffer.h
+++ b/esphome/components/audio/audio_transfer_buffer.h
@@ -1,8 +1,8 @@
#pragma once
#ifdef USE_ESP32
+#include "esphome/components/ring_buffer/ring_buffer.h"
#include "esphome/core/defines.h"
-#include "esphome/core/ring_buffer.h"
#ifdef USE_SPEAKER
#include "esphome/components/speaker/speaker.h"
@@ -12,8 +12,7 @@
#include
-namespace esphome {
-namespace audio {
+namespace esphome::audio {
/// @brief Abstract interface for writing decoded audio data to a sink.
class AudioSinkCallback {
@@ -77,7 +76,7 @@ class AudioTransferBuffer {
void deallocate_buffer_();
// A possible source or sink for the transfer buffer
- std::shared_ptr ring_buffer_;
+ std::shared_ptr ring_buffer_;
uint8_t *buffer_{nullptr};
uint8_t *data_start_{nullptr};
@@ -106,7 +105,7 @@ class AudioSinkTransferBuffer : public AudioTransferBuffer {
/// @brief Adds a ring buffer as the transfer buffer's sink.
/// @param ring_buffer weak_ptr to the allocated ring buffer
- void set_sink(const std::weak_ptr &ring_buffer) { this->ring_buffer_ = ring_buffer.lock(); }
+ void set_sink(const std::weak_ptr &ring_buffer) { this->ring_buffer_ = ring_buffer.lock(); }
#ifdef USE_SPEAKER
/// @brief Adds a speaker as the transfer buffer's sink.
@@ -180,7 +179,9 @@ class AudioSourceTransferBuffer : public AudioTransferBuffer, public AudioReadab
/// @brief Adds a ring buffer as the transfer buffer's source.
/// @param ring_buffer weak_ptr to the allocated ring buffer
- void set_source(const std::weak_ptr &ring_buffer) { this->ring_buffer_ = ring_buffer.lock(); };
+ void set_source(const std::weak_ptr &ring_buffer) {
+ this->ring_buffer_ = ring_buffer.lock();
+ };
// AudioReadableBuffer interface
const uint8_t *data() const override { return this->data_start_; }
@@ -213,7 +214,90 @@ class ConstAudioSourceBuffer : public AudioReadableBuffer {
size_t length_{0};
};
-} // namespace audio
-} // namespace esphome
+/// @brief Zero-copy audio source that reads directly from a ring buffer's internal storage.
+///
+/// Optionally enforces a minimum read alignment (e.g. one audio frame). When alignment_bytes > 1, the
+/// source transparently stitches frames that straddle the ring buffer's wrap boundary by buffering the
+/// trailing partial frame from one chunk and joining it with the head of the next chunk in a small
+/// internal splice buffer, so callers always see frame-aligned data.
+///
+/// Not thread-safe. The underlying ring_buffer::RingBuffer supports one producer and one consumer
+/// running concurrently, but a given RingBufferAudioSource (its acquired item, splice buffer, and
+/// queued region) must be used by only one thread, and that thread is the ring buffer's consumer.
+class RingBufferAudioSource : public AudioReadableBuffer {
+ public:
+ /// Maximum supported alignment. Sized to cover 32-bit samples across up to 2 channels (8 bytes).
+ static constexpr size_t MAX_ALIGNMENT_BYTES = 8;
+
+ /// @brief Creates a new ring-buffer-backed audio source after validating its parameters.
+ /// @param ring_buffer The ring buffer to read from. Must be non-null.
+ /// @param max_fill_bytes Soft cap on bytes acquired per fill() call. Must be > 0.
+ /// @param alignment_bytes Minimum exposed-region alignment in bytes (defaults to 1, i.e. byte-aligned).
+ /// Pass bytes_per_frame to make every exposed region a whole number of frames. Must be in
+ /// [1, MAX_ALIGNMENT_BYTES].
+ /// @return unique_ptr if parameters are valid, nullptr otherwise
+ static std::unique_ptr create(std::shared_ptr ring_buffer,
+ size_t max_fill_bytes, uint8_t alignment_bytes = 1);
+
+ ~RingBufferAudioSource() override;
+
+ // AudioReadableBuffer interface
+ const uint8_t *data() const override { return this->current_data_; }
+ size_t available() const override { return this->current_available_; }
+ void consume(size_t bytes) override;
+ bool has_buffered_data() const override;
+ /// pre_shift is ignored: there is no intermediate transfer buffer to compact, so an unconsumed
+ /// exposure stays in place and fill() returns 0 until it is fully consumed.
+ size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
+
+ /// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight
+ /// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread.
+ void clear_buffered_data();
+
+ /// @brief Returns a mutable pointer to the currently exposed audio data.
+ /// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
+ /// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
+ /// should be discarded after use, since the underlying storage will be reused on the next fill().
+ /// Use only when the caller is the sole consumer of this source.
+ uint8_t *mutable_data() { return this->current_data_; }
+
+ protected:
+ /// @brief Constructs a new ring-buffer-backed audio source. Use create() instead, which validates
+ /// arguments before construction.
+ explicit RingBufferAudioSource(std::shared_ptr ring_buffer, size_t max_fill_bytes,
+ uint8_t alignment_bytes)
+ : ring_buffer_(std::move(ring_buffer)), max_fill_bytes_(max_fill_bytes), alignment_bytes_(alignment_bytes) {}
+
+ /// @brief Releases the currently held ring buffer item, first copying any trailing sub-frame bytes
+ /// into the splice buffer so they can be stitched with the next chunk.
+ void release_item_();
+
+ std::shared_ptr ring_buffer_;
+ size_t max_fill_bytes_;
+
+ void *acquired_item_{nullptr};
+ uint8_t *current_data_{nullptr};
+
+ // Sub-frame trailing bytes inside the held item that will be copied to splice_buffer_ on release.
+ uint8_t *item_trailing_ptr_{nullptr};
+
+ // After the currently-exposed splice frame is consumed, fill() will promote this region (the aligned
+ // remainder of the new chunk) to the exposed region. queued_length_ == 0 when nothing is queued.
+ uint8_t *queued_data_{nullptr};
+
+ // Splice buffer holds the start of a partial frame whose remainder lives at the head of the next
+ // chunk. While splice_length_ > 0, the buffer is incomplete and waiting for completion bytes.
+ uint8_t splice_buffer_[MAX_ALIGNMENT_BYTES];
+
+ size_t current_available_{0};
+ size_t queued_length_{0};
+
+ // item_trailing_length_ and splice_length_ are bounded by MAX_ALIGNMENT_BYTES.
+ uint8_t alignment_bytes_;
+ uint8_t item_trailing_length_{0};
+ uint8_t splice_length_{0};
+};
+
+} // namespace esphome::audio
#endif
diff --git a/esphome/components/audio_adc/audio_adc.h b/esphome/components/audio_adc/audio_adc.h
index 94bfb57db..a1da2360a 100644
--- a/esphome/components/audio_adc/audio_adc.h
+++ b/esphome/components/audio_adc/audio_adc.h
@@ -3,8 +3,7 @@
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
-namespace esphome {
-namespace audio_adc {
+namespace esphome::audio_adc {
class AudioAdc {
public:
@@ -13,5 +12,4 @@ class AudioAdc {
virtual float mic_gain() = 0;
};
-} // namespace audio_adc
-} // namespace esphome
+} // namespace esphome::audio_adc
diff --git a/esphome/components/audio_adc/automation.h b/esphome/components/audio_adc/automation.h
index 0c4246847..e74e02320 100644
--- a/esphome/components/audio_adc/automation.h
+++ b/esphome/components/audio_adc/automation.h
@@ -4,8 +4,7 @@
#include "esphome/core/component.h"
#include "audio_adc.h"
-namespace esphome {
-namespace audio_adc {
+namespace esphome::audio_adc {
template class SetMicGainAction : public Action {
public:
@@ -19,5 +18,4 @@ template class SetMicGainAction : public Action {
AudioAdc *audio_adc_;
};
-} // namespace audio_adc
-} // namespace esphome
+} // namespace esphome::audio_adc
diff --git a/esphome/components/audio_dac/audio_dac.h b/esphome/components/audio_dac/audio_dac.h
index a62d17b84..16a422f4a 100644
--- a/esphome/components/audio_dac/audio_dac.h
+++ b/esphome/components/audio_dac/audio_dac.h
@@ -3,8 +3,7 @@
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
-namespace esphome {
-namespace audio_dac {
+namespace esphome::audio_dac {
class AudioDac {
public:
@@ -19,5 +18,4 @@ class AudioDac {
bool is_muted_{false};
};
-} // namespace audio_dac
-} // namespace esphome
+} // namespace esphome::audio_dac
diff --git a/esphome/components/audio_dac/automation.h b/esphome/components/audio_dac/automation.h
index 3eb3441f3..67bbc78ac 100644
--- a/esphome/components/audio_dac/automation.h
+++ b/esphome/components/audio_dac/automation.h
@@ -4,8 +4,7 @@
#include "esphome/core/component.h"
#include "audio_dac.h"
-namespace esphome {
-namespace audio_dac {
+namespace esphome::audio_dac {
template class MuteOffAction : public Action {
public:
@@ -39,5 +38,4 @@ template class SetVolumeAction : public Action {
AudioDac *audio_dac_;
};
-} // namespace audio_dac
-} // namespace esphome
+} // namespace esphome::audio_dac
diff --git a/esphome/components/audio_file/__init__.py b/esphome/components/audio_file/__init__.py
index bb1ce257d..53193c800 100644
--- a/esphome/components/audio_file/__init__.py
+++ b/esphome/components/audio_file/__init__.py
@@ -1,4 +1,5 @@
from dataclasses import dataclass, field
+from functools import partial
import hashlib
import logging
from pathlib import Path
@@ -19,7 +20,7 @@ from esphome.const import (
)
from esphome.core import CORE, ID, HexInt
from esphome.cpp_generator import MockObj
-from esphome.external_files import download_content
+from esphome.external_files import download_web_files_in_config
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
@@ -63,15 +64,6 @@ def _compute_local_file_path(value: ConfigType) -> Path:
return base_dir / key
-def _download_web_file(value: ConfigType) -> ConfigType:
- url = value[CONF_URL]
- path = _compute_local_file_path(value)
-
- download_content(url, path)
- _LOGGER.debug("download_web_file: path=%s", path)
- return value
-
-
def _file_schema(value: ConfigType | str) -> ConfigType:
if isinstance(value, str):
return _validate_file_shorthand(value)
@@ -80,7 +72,7 @@ def _file_schema(value: ConfigType | str) -> ConfigType:
def _validate_file_shorthand(value: str) -> ConfigType:
value = cv.string_strict(value)
- if value.startswith("http://") or value.startswith("https://"):
+ if value.startswith(("http://", "https://")):
return _file_schema(
{
CONF_TYPE: TYPE_WEB,
@@ -106,7 +98,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
else:
raise cv.Invalid("Unsupported file source")
- with open(path, "rb") as f:
+ with path.open("rb") as f:
data = f.read()
try:
@@ -142,11 +134,10 @@ LOCAL_SCHEMA = cv.Schema(
}
)
-WEB_SCHEMA = cv.All(
+WEB_SCHEMA = cv.Schema(
{
cv.Required(CONF_URL): cv.url,
- },
- _download_web_file,
+ }
)
@@ -202,54 +193,66 @@ def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType]
audio.request_mp3_support()
elif media_file_type_str == str(audio.AUDIO_FILE_TYPE_ENUM["OPUS"]):
audio.request_opus_support()
+ elif media_file_type_str == str(audio.AUDIO_FILE_TYPE_ENUM["WAV"]):
+ audio.request_wav_support()
return config
+def audio_files_schema() -> cv.All:
+ """Schema for a list of audio file entries.
+
+ Validates each entry, downloads any web files, and detects the audio file
+ type while requesting codec support. Reusable by other components (e.g.
+ speaker media_player) that embed audio files in firmware without going
+ through the audio_file component's C++ registry.
+ """
+ return cv.All(
+ cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA),
+ partial(download_web_files_in_config, path_for=_compute_local_file_path),
+ _validate_supported_local_file,
+ )
+
+
+def generate_audio_file_code(file_config: ConfigType) -> MockObj:
+ """Generate the progmem data, AudioFile struct, and Pvariable for one file.
+
+ Returns the created Pvariable. Caller is responsible for any further
+ registration (the audio_file component additionally registers each file in
+ its named C++ registry; other consumers may skip that).
+ """
+ cache = _get_data().file_cache
+ file_id = str(file_config[CONF_ID])
+ if file_id in cache:
+ data, media_file_type = cache[file_id]
+ else:
+ data, media_file_type = read_audio_file_and_type(file_config)
+
+ rhs = [HexInt(x) for x in data]
+ prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs)
+
+ media_files_struct = cg.StructInitializer(
+ audio.AudioFile,
+ ("data", prog_arr),
+ ("length", len(rhs)),
+ ("file_type", media_file_type),
+ )
+
+ return cg.new_Pvariable(file_config[CONF_ID], media_files_struct)
+
+
CONFIG_SCHEMA = cv.All(
cv.only_on_esp32,
- cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA),
- _validate_supported_local_file,
+ audio_files_schema(),
)
async def to_code(config: list[ConfigType]) -> None:
- cache = _get_data().file_cache
-
for file_config in config:
file_id = str(file_config[CONF_ID])
- data, media_file_type = cache[file_id]
-
- rhs = [HexInt(x) for x in data]
- prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs)
-
- media_files_struct = cg.StructInitializer(
- audio.AudioFile,
- (
- "data",
- prog_arr,
- ),
- (
- "length",
- len(rhs),
- ),
- (
- "file_type",
- media_file_type,
- ),
- )
-
- cg.new_Pvariable(
- file_config[CONF_ID],
- media_files_struct,
- )
-
- # Store file ID for cross-component access
+ file_var = generate_audio_file_code(file_config)
_get_data().file_ids[file_id] = file_config[CONF_ID]
+ cg.add(audio_file_ns.add_named_audio_file(file_var, file_id))
# Register all files in the shared C++ registry
cg.add_define("AUDIO_FILE_MAX_FILES", len(config))
- for file_config in config:
- file_id = str(file_config[CONF_ID])
- file_var = await cg.get_variable(file_config[CONF_ID])
- cg.add(audio_file_ns.add_named_audio_file(file_var, file_id))
diff --git a/esphome/components/audio_file/media_source/__init__.py b/esphome/components/audio_file/media_source/__init__.py
index e9e292a2b..071058281 100644
--- a/esphome/components/audio_file/media_source/__init__.py
+++ b/esphome/components/audio_file/media_source/__init__.py
@@ -1,5 +1,5 @@
import esphome.codegen as cg
-from esphome.components import media_source, psram
+from esphome.components import audio, media_source, psram
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM
from esphome.types import ConfigType
@@ -13,19 +13,24 @@ AudioFileMediaSource = audio_file_ns.class_(
"AudioFileMediaSource", cg.Component, media_source.MediaSource
)
+
+def _request_micro_decoder(config: ConfigType) -> ConfigType:
+ audio.request_micro_decoder_support()
+ return config
+
+
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
AudioFileMediaSource,
)
.extend(
{
- cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All(
- cv.boolean, cv.requires_component(psram.DOMAIN)
- ),
+ cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
}
)
.extend(cv.COMPONENT_SCHEMA),
cv.only_on_esp32,
+ _request_micro_decoder,
)
@@ -34,5 +39,6 @@ async def to_code(config: ConfigType) -> None:
await cg.register_component(var, config)
await media_source.register_media_source(var, config)
- if CONF_TASK_STACK_IN_PSRAM in config:
- cg.add(var.set_task_stack_in_psram(config[CONF_TASK_STACK_IN_PSRAM]))
+ if config.get(CONF_TASK_STACK_IN_PSRAM):
+ cg.add(var.set_task_stack_in_psram(True))
+ psram.request_external_task_stack()
diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.cpp b/esphome/components/audio_file/media_source/audio_file_media_source.cpp
index fbb5ecd88..0cda1eca9 100644
--- a/esphome/components/audio_file/media_source/audio_file_media_source.cpp
+++ b/esphome/components/audio_file/media_source/audio_file_media_source.cpp
@@ -2,281 +2,185 @@
#ifdef USE_ESP32
-#include "esphome/components/audio/audio_decoder.h"
+#include "esphome/core/log.h"
+
+#include
+#include
-#include
#include
namespace esphome::audio_file {
-namespace { // anonymous namespace for internal linkage
-struct AudioSinkAdapter : public audio::AudioSinkCallback {
- media_source::MediaSource *source;
- audio::AudioStreamInfo stream_info;
-
- size_t audio_sink_write(uint8_t *data, size_t length, TickType_t ticks_to_wait) override {
- return this->source->write_output(data, length, pdTICKS_TO_MS(ticks_to_wait), this->stream_info);
- }
-};
-} // namespace
-
-#if defined(USE_AUDIO_OPUS_SUPPORT)
-static constexpr uint32_t DECODE_TASK_STACK_SIZE = 5 * 1024;
-#else
-static constexpr uint32_t DECODE_TASK_STACK_SIZE = 3 * 1024;
-#endif
-
static const char *const TAG = "audio_file_media_source";
-enum EventGroupBits : uint32_t {
- // Requests to start playback (set by play_uri, handled by loop)
- REQUEST_START = (1 << 0),
- // Commands from main loop to decode task
- COMMAND_STOP = (1 << 1),
- COMMAND_PAUSE = (1 << 2),
- // Decode task lifecycle signals (one-shot, cleared by loop)
- TASK_STARTING = (1 << 7),
- TASK_RUNNING = (1 << 8),
- TASK_STOPPING = (1 << 9),
- TASK_STOPPED = (1 << 10),
- TASK_ERROR = (1 << 11),
- // Decode task state (level-triggered, set/cleared by decode task)
- TASK_PAUSED = (1 << 12),
- ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
-};
+static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50;
+static constexpr size_t DECODER_TASK_STACK_SIZE = 5120;
+static constexpr uint8_t DECODER_TASK_PRIORITY = 2;
+static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20;
+static constexpr char URI_PREFIX[] = "audio-file://";
+
+namespace { // anonymous namespace for internal linkage
+
+// audio::AudioFileType and micro_decoder::AudioFileType use different numeric layouts (audio's
+// values shift with USE_AUDIO_*_SUPPORT defines; micro_decoder's are fixed and guarded by
+// MICRO_DECODER_CODEC_*). The codec request flow in audio/__init__.py keeps the two sets of
+// guards aligned, so a switch with matching #ifdefs covers all reachable cases.
+micro_decoder::AudioFileType to_micro_decoder_type(audio::AudioFileType type) {
+ switch (type) {
+#ifdef USE_AUDIO_FLAC_SUPPORT
+ case audio::AudioFileType::FLAC:
+ return micro_decoder::AudioFileType::FLAC;
+#endif
+#ifdef USE_AUDIO_MP3_SUPPORT
+ case audio::AudioFileType::MP3:
+ return micro_decoder::AudioFileType::MP3;
+#endif
+#ifdef USE_AUDIO_OPUS_SUPPORT
+ case audio::AudioFileType::OPUS:
+ return micro_decoder::AudioFileType::OPUS;
+#endif
+#ifdef USE_AUDIO_WAV_SUPPORT
+ case audio::AudioFileType::WAV:
+ return micro_decoder::AudioFileType::WAV;
+#endif
+ default:
+ return micro_decoder::AudioFileType::NONE;
+ }
+}
+
+} // namespace
void AudioFileMediaSource::dump_config() {
- ESP_LOGCONFIG(TAG, "Audio File Media Source:");
- ESP_LOGCONFIG(TAG, " Task Stack in PSRAM: %s", this->task_stack_in_psram_ ? "Yes" : "No");
+ ESP_LOGCONFIG(TAG,
+ "Audio File Media Source:\n"
+ " Decoder Task Stack in PSRAM: %s",
+ YESNO(this->decoder_task_stack_in_psram_));
}
void AudioFileMediaSource::setup() {
this->disable_loop();
- this->event_group_ = xEventGroupCreate();
- if (this->event_group_ == nullptr) {
- ESP_LOGE(TAG, "Failed to create event group");
+ micro_decoder::DecoderConfig config;
+ config.audio_write_timeout_ms = AUDIO_WRITE_TIMEOUT_MS;
+ config.decoder_priority = DECODER_TASK_PRIORITY;
+ config.decoder_stack_size = DECODER_TASK_STACK_SIZE;
+ config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_;
+
+ this->decoder_ = std::make_unique(config);
+ if (this->decoder_ == nullptr) {
+ ESP_LOGE(TAG, "Failed to allocate decoder");
this->mark_failed();
return;
}
+ this->decoder_->set_listener(this);
}
-void AudioFileMediaSource::loop() {
- EventBits_t event_bits = xEventGroupGetBits(this->event_group_);
+void AudioFileMediaSource::loop() { this->decoder_->loop(); }
- if (event_bits & REQUEST_START) {
- xEventGroupClearBits(this->event_group_, REQUEST_START);
- this->decoding_state_ = AudioFileDecodingState::START_TASK;
- }
-
- switch (this->decoding_state_) {
- case AudioFileDecodingState::START_TASK: {
- if (!this->decode_task_.is_created()) {
- xEventGroupClearBits(this->event_group_, ALL_BITS);
- if (!this->decode_task_.create(decode_task, "AudioFileDec", DECODE_TASK_STACK_SIZE, this, 1,
- this->task_stack_in_psram_)) {
- ESP_LOGE(TAG, "Failed to create task");
- this->status_momentary_error("task_create", 1000);
- this->set_state_(media_source::MediaSourceState::ERROR);
- this->decoding_state_ = AudioFileDecodingState::IDLE;
- return;
- }
- }
- this->decoding_state_ = AudioFileDecodingState::DECODING;
- break;
- }
- case AudioFileDecodingState::DECODING: {
- if (event_bits & TASK_STARTING) {
- ESP_LOGD(TAG, "Starting");
- xEventGroupClearBits(this->event_group_, TASK_STARTING);
- }
-
- if (event_bits & TASK_RUNNING) {
- ESP_LOGV(TAG, "Started");
- xEventGroupClearBits(this->event_group_, TASK_RUNNING);
- this->set_state_(media_source::MediaSourceState::PLAYING);
- }
-
- if ((event_bits & TASK_PAUSED) && this->get_state() != media_source::MediaSourceState::PAUSED) {
- this->set_state_(media_source::MediaSourceState::PAUSED);
- } else if (!(event_bits & TASK_PAUSED) && this->get_state() == media_source::MediaSourceState::PAUSED) {
- this->set_state_(media_source::MediaSourceState::PLAYING);
- }
-
- if (event_bits & TASK_STOPPING) {
- ESP_LOGV(TAG, "Stopping");
- xEventGroupClearBits(this->event_group_, TASK_STOPPING);
- }
-
- if (event_bits & TASK_ERROR) {
- // Report error so the orchestrator knows playback failed; task will have already logged the specific error
- this->set_state_(media_source::MediaSourceState::ERROR);
- }
-
- if (event_bits & TASK_STOPPED) {
- ESP_LOGD(TAG, "Stopped");
- xEventGroupClearBits(this->event_group_, ALL_BITS);
-
- this->decode_task_.deallocate();
- this->set_state_(media_source::MediaSourceState::IDLE);
- this->decoding_state_ = AudioFileDecodingState::IDLE;
- }
- break;
- }
- case AudioFileDecodingState::IDLE: {
- if (this->get_state() == media_source::MediaSourceState::ERROR && !this->status_has_error()) {
- this->set_state_(media_source::MediaSourceState::IDLE);
- }
- break;
- }
- }
-
- if ((this->decoding_state_ == AudioFileDecodingState::IDLE) &&
- (this->get_state() == media_source::MediaSourceState::IDLE)) {
- this->disable_loop();
- }
-}
+bool AudioFileMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); }
// Called from the orchestrator's main loop, so no synchronization needed with loop()
bool AudioFileMediaSource::play_uri(const std::string &uri) {
- if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener() ||
- xEventGroupGetBits(this->event_group_) & REQUEST_START) {
+ if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener()) {
return false;
}
- // Check if source is already playing
if (this->get_state() != media_source::MediaSourceState::IDLE) {
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
return false;
}
- // Validate URI starts with "audio-file://"
- if (!uri.starts_with("audio-file://")) {
+ if (!uri.starts_with(URI_PREFIX)) {
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
return false;
}
- // Strip "audio-file://" prefix and find the file
- const char *file_id = uri.c_str() + 13; // "audio-file://" is 13 characters
-
+ const char *file_id = uri.c_str() + sizeof(URI_PREFIX) - 1;
+ this->current_file_ = nullptr;
for (const auto &named_file : get_named_audio_files()) {
if (strcmp(named_file.file_id, file_id) == 0) {
this->current_file_ = named_file.file;
- xEventGroupSetBits(this->event_group_, EventGroupBits::REQUEST_START);
- this->enable_loop();
- return true;
+ break;
}
}
- ESP_LOGE(TAG, "Unknown file: '%s'", file_id);
+ if (this->current_file_ == nullptr) {
+ ESP_LOGE(TAG, "Unknown file: '%s'", file_id);
+ return false;
+ }
+
+ micro_decoder::AudioFileType type = to_micro_decoder_type(this->current_file_->file_type);
+ if (this->decoder_->play_buffer(this->current_file_->data, this->current_file_->length, type)) {
+ this->pause_.store(false, std::memory_order_relaxed);
+ this->enable_loop();
+ return true;
+ }
+
+ ESP_LOGE(TAG, "Failed to start playback of '%s'", file_id);
return false;
}
// Called from the orchestrator's main loop, so no synchronization needed with loop()
void AudioFileMediaSource::handle_command(media_source::MediaSourceCommand command) {
- if (this->decoding_state_ != AudioFileDecodingState::DECODING) {
- return;
- }
-
switch (command) {
case media_source::MediaSourceCommand::STOP:
- xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_STOP);
+ this->decoder_->stop();
break;
case media_source::MediaSourceCommand::PAUSE:
- xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_PAUSE);
+ // Only valid while actively playing; ignoring from IDLE/ERROR/PAUSED prevents the state
+ // machine from getting stuck in PAUSED when no playback is active (which would block the
+ // next play_uri() call via its IDLE-state precondition).
+ if (this->get_state() != media_source::MediaSourceState::PLAYING)
+ break;
+ // PAUSE does not stop the decoder task. Instead, on_audio_write() returns 0 and temporarily
+ // yields, which fills any internal buffering and applies back pressure that effectively
+ // pauses the decoder task.
+ this->set_state_(media_source::MediaSourceState::PAUSED);
+ this->pause_.store(true, std::memory_order_relaxed);
break;
case media_source::MediaSourceCommand::PLAY:
- xEventGroupClearBits(this->event_group_, EventGroupBits::COMMAND_PAUSE);
+ if (this->get_state() != media_source::MediaSourceState::PAUSED)
+ break;
+ this->set_state_(media_source::MediaSourceState::PLAYING);
+ this->pause_.store(false, std::memory_order_relaxed);
break;
default:
break;
}
}
-void AudioFileMediaSource::decode_task(void *params) {
- AudioFileMediaSource *this_source = static_cast(params);
+// Called from the decoder task. Forwards to the orchestrator's listener, which is responsible for
+// being thread-safe with respect to its own audio writer.
+size_t AudioFileMediaSource::on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) {
+ if (this->pause_.load(std::memory_order_relaxed)) {
+ vTaskDelay(pdMS_TO_TICKS(PAUSE_POLL_DELAY_MS));
+ return 0;
+ }
+ return this->write_output(data, length, timeout_ms, this->stream_info_);
+}
- do { // do-while(false) ensures RAII objects are destroyed on all exit paths via break
+// Called from the decoder task before the first on_audio_write().
+void AudioFileMediaSource::on_stream_info(const micro_decoder::AudioStreamInfo &info) {
+ this->stream_info_ = audio::AudioStreamInfo(info.get_bits_per_sample(), info.get_channels(), info.get_sample_rate());
+}
- xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STARTING);
-
- // 0 bytes for input transfer buffer makes it an inplace buffer
- std::unique_ptr decoder = make_unique(0, 4096);
-
- esp_err_t err = decoder->start(this_source->current_file_->file_type);
- if (err != ESP_OK) {
- ESP_LOGE(TAG, "Failed to start decoder: %s", esp_err_to_name(err));
- xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR | EventGroupBits::TASK_STOPPING);
+// microDecoder invokes on_state_change() from inside decoder_->loop(), so this runs on the main
+// loop thread and it's safe to call set_state_() directly.
+void AudioFileMediaSource::on_state_change(micro_decoder::DecoderState state) {
+ switch (state) {
+ case micro_decoder::DecoderState::IDLE:
+ this->set_state_(media_source::MediaSourceState::IDLE);
+ this->disable_loop();
break;
- }
-
- // Add the file as a const data source
- decoder->add_source(this_source->current_file_->data, this_source->current_file_->length);
-
- xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_RUNNING);
-
- AudioSinkAdapter audio_sink;
- bool has_stream_info = false;
-
- while (true) {
- EventBits_t event_bits = xEventGroupGetBits(this_source->event_group_);
-
- if (event_bits & EventGroupBits::COMMAND_STOP) {
- break;
- }
-
- bool paused = event_bits & EventGroupBits::COMMAND_PAUSE;
- decoder->set_pause_output_state(paused);
- if (paused) {
- xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_PAUSED);
- vTaskDelay(pdMS_TO_TICKS(20));
- } else {
- xEventGroupClearBits(this_source->event_group_, EventGroupBits::TASK_PAUSED);
- }
-
- // Will stop gracefully once finished with the current file
- audio::AudioDecoderState decoder_state = decoder->decode(true);
-
- if (decoder_state == audio::AudioDecoderState::FINISHED) {
- break;
- } else if (decoder_state == audio::AudioDecoderState::FAILED) {
- ESP_LOGE(TAG, "Decoder failed");
- xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
- break;
- }
-
- if (!has_stream_info && decoder->get_audio_stream_info().has_value()) {
- has_stream_info = true;
-
- audio::AudioStreamInfo stream_info = decoder->get_audio_stream_info().value();
-
- ESP_LOGD(TAG, "Bits per sample: %d, Channels: %d, Sample rate: %" PRIu32, stream_info.get_bits_per_sample(),
- stream_info.get_channels(), stream_info.get_sample_rate());
-
- if (stream_info.get_bits_per_sample() != 16 || stream_info.get_channels() > 2) {
- ESP_LOGE(TAG, "Incompatible audio stream. Only 16 bits per sample and 1 or 2 channels are supported");
- xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
- break;
- }
-
- audio_sink.source = this_source;
- audio_sink.stream_info = stream_info;
- esp_err_t err = decoder->add_sink(&audio_sink);
- if (err != ESP_OK) {
- ESP_LOGE(TAG, "Failed to add sink: %s", esp_err_to_name(err));
- xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
- break;
- }
- }
- }
-
- xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STOPPING);
- } while (false);
-
- // All RAII objects from the do-while block (decoder, audio_sink, etc.) are now destroyed.
-
- xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STOPPED);
- vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it
+ case micro_decoder::DecoderState::PLAYING:
+ this->set_state_(media_source::MediaSourceState::PLAYING);
+ break;
+ case micro_decoder::DecoderState::FAILED:
+ this->set_state_(media_source::MediaSourceState::ERROR);
+ break;
+ default:
+ break;
+ }
}
} // namespace esphome::audio_file
diff --git a/esphome/components/audio_file/media_source/audio_file_media_source.h b/esphome/components/audio_file/media_source/audio_file_media_source.h
index 75e18c13b..2c6189f27 100644
--- a/esphome/components/audio_file/media_source/audio_file_media_source.h
+++ b/esphome/components/audio_file/media_source/audio_file_media_source.h
@@ -8,41 +8,48 @@
#include "esphome/components/audio_file/audio_file.h"
#include "esphome/components/media_source/media_source.h"
#include "esphome/core/component.h"
-#include "esphome/core/static_task.h"
-#include
-#include
+#include
+#include
+
+#include
+#include
+#include
namespace esphome::audio_file {
-enum class AudioFileDecodingState : uint8_t {
- START_TASK,
- DECODING,
- IDLE,
-};
-
-class AudioFileMediaSource : public Component, public media_source::MediaSource {
+// Inherits from two unrelated listener-style interfaces:
+// - media_source::MediaSource: this source reports state and writes audio *to* an orchestrator
+// (the orchestrator calls set_listener() on us with a MediaSourceListener*).
+// - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded
+// audio and state changes (we call decoder_->set_listener(this) in setup()).
+class AudioFileMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener {
public:
void setup() override;
void loop() override;
void dump_config() override;
+ void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
+
// MediaSource interface implementation
bool play_uri(const std::string &uri) override;
void handle_command(media_source::MediaSourceCommand command) override;
- bool can_handle(const std::string &uri) const override { return uri.starts_with("audio-file://"); }
+ bool can_handle(const std::string &uri) const override;
- void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
+ // DecoderListener interface implementation
+ size_t on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) override;
+ void on_stream_info(const micro_decoder::AudioStreamInfo &info) override;
+ void on_state_change(micro_decoder::DecoderState state) override;
protected:
- static void decode_task(void *params);
-
+ std::unique_ptr decoder_;
+ audio::AudioStreamInfo stream_info_;
audio::AudioFile *current_file_{nullptr};
- AudioFileDecodingState decoding_state_{AudioFileDecodingState::IDLE};
- EventGroupHandle_t event_group_{nullptr};
- StaticTask decode_task_;
- bool task_stack_in_psram_{false};
+ // Written from the main loop in handle_command(), read from the decoder task in
+ // on_audio_write(). Must be atomic to avoid a data race.
+ std::atomic pause_{false};
+ bool decoder_task_stack_in_psram_{false};
};
} // namespace esphome::audio_file
diff --git a/esphome/components/audio_http/media_source.py b/esphome/components/audio_http/media_source.py
index 519d8df69..e8acbc81a 100644
--- a/esphome/components/audio_http/media_source.py
+++ b/esphome/components/audio_http/media_source.py
@@ -1,7 +1,5 @@
-from typing import Any
-
import esphome.codegen as cg
-from esphome.components import audio, esp32, media_source, psram
+from esphome.components import audio, media_source, psram
import esphome.config_validation as cv
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
from esphome.types import ConfigType
@@ -20,14 +18,6 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
return config
-def _validate_task_stack_in_psram(value: Any) -> bool:
- # Only require the psram component when actually enabling PSRAM stacks; validating
- # the boolean first means `false` doesn't trigger the requires_component check.
- if value := cv.boolean(value):
- return cv.requires_component(psram.DOMAIN)(value)
- return value
-
-
CONFIG_SCHEMA = cv.All(
media_source.media_source_schema(
AudioHTTPMediaSource,
@@ -37,7 +27,7 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range(
min=5000, max=1000000
),
- cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
+ cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
}
)
.extend(cv.COMPONENT_SCHEMA),
@@ -53,7 +43,5 @@ async def to_code(config: ConfigType) -> None:
if config.get(CONF_TASK_STACK_IN_PSRAM):
cg.add(var.set_task_stack_in_psram(True))
- esp32.add_idf_sdkconfig_option(
- "CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
- )
+ psram.request_external_task_stack()
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp
index ab3f1dad4..3869224b9 100644
--- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp
+++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.cpp
@@ -3,8 +3,7 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace axs15231 {
+namespace esphome::axs15231 {
static const char *const TAG = "ax15231.touchscreen";
@@ -64,5 +63,4 @@ void AXS15231Touchscreen::dump_config() {
this->x_raw_max_, this->y_raw_max_);
}
-} // namespace axs15231
-} // namespace esphome
+} // namespace esphome::axs15231
diff --git a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h
index a55c5c0d3..94d232777 100644
--- a/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h
+++ b/esphome/components/axs15231/touchscreen/axs15231_touchscreen.h
@@ -5,8 +5,7 @@
#include "esphome/core/component.h"
#include "esphome/core/hal.h"
-namespace esphome {
-namespace axs15231 {
+namespace esphome::axs15231 {
class AXS15231Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevice {
public:
@@ -23,5 +22,4 @@ class AXS15231Touchscreen : public touchscreen::Touchscreen, public i2c::I2CDevi
GPIOPin *reset_pin_{};
};
-} // namespace axs15231
-} // namespace esphome
+} // namespace esphome::axs15231
diff --git a/esphome/components/b_parasite/b_parasite.cpp b/esphome/components/b_parasite/b_parasite.cpp
index 7be26efa7..160d22a5b 100644
--- a/esphome/components/b_parasite/b_parasite.cpp
+++ b/esphome/components/b_parasite/b_parasite.cpp
@@ -3,8 +3,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace b_parasite {
+namespace esphome::b_parasite {
static const char *const TAG = "b_parasite";
@@ -113,7 +112,6 @@ bool BParasite::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
return true;
}
-} // namespace b_parasite
-} // namespace esphome
+} // namespace esphome::b_parasite
#endif // USE_ESP32
diff --git a/esphome/components/b_parasite/b_parasite.h b/esphome/components/b_parasite/b_parasite.h
index 7dd08968e..c719599b9 100644
--- a/esphome/components/b_parasite/b_parasite.h
+++ b/esphome/components/b_parasite/b_parasite.h
@@ -6,8 +6,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace b_parasite {
+namespace esphome::b_parasite {
class BParasite : public Component, public esp32_ble_tracker::ESPBTDeviceListener {
public:
@@ -35,7 +34,6 @@ class BParasite : public Component, public esp32_ble_tracker::ESPBTDeviceListene
sensor::Sensor *illuminance_{nullptr};
};
-} // namespace b_parasite
-} // namespace esphome
+} // namespace esphome::b_parasite
#endif // USE_ESP32
diff --git a/esphome/components/ballu/ballu.cpp b/esphome/components/ballu/ballu.cpp
index deb742f8c..eebc97079 100644
--- a/esphome/components/ballu/ballu.cpp
+++ b/esphome/components/ballu/ballu.cpp
@@ -1,8 +1,7 @@
#include "ballu.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace ballu {
+namespace esphome::ballu {
static const char *const TAG = "ballu.climate";
@@ -235,5 +234,4 @@ bool BalluClimate::on_receive(remote_base::RemoteReceiveData data) {
return true;
}
-} // namespace ballu
-} // namespace esphome
+} // namespace esphome::ballu
diff --git a/esphome/components/ballu/ballu.h b/esphome/components/ballu/ballu.h
index 80a4699cf..8a45d39c7 100644
--- a/esphome/components/ballu/ballu.h
+++ b/esphome/components/ballu/ballu.h
@@ -2,8 +2,7 @@
#include "esphome/components/climate_ir/climate_ir.h"
-namespace esphome {
-namespace ballu {
+namespace esphome::ballu {
// Support for Ballu air conditioners with YKR-K/002E remote
@@ -27,5 +26,4 @@ class BalluClimate : public climate_ir::ClimateIR {
bool on_receive(remote_base::RemoteReceiveData data) override;
};
-} // namespace ballu
-} // namespace esphome
+} // namespace esphome::ballu
diff --git a/esphome/components/bang_bang/bang_bang_climate.cpp b/esphome/components/bang_bang/bang_bang_climate.cpp
index 1058bce6a..5dfb12134 100644
--- a/esphome/components/bang_bang/bang_bang_climate.cpp
+++ b/esphome/components/bang_bang/bang_bang_climate.cpp
@@ -1,8 +1,7 @@
#include "bang_bang_climate.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace bang_bang {
+namespace esphome::bang_bang {
static const char *const TAG = "bang_bang.climate";
@@ -231,5 +230,4 @@ BangBangClimateTargetTempConfig::BangBangClimateTargetTempConfig(float default_t
float default_temperature_high)
: default_temperature_low(default_temperature_low), default_temperature_high(default_temperature_high) {}
-} // namespace bang_bang
-} // namespace esphome
+} // namespace esphome::bang_bang
diff --git a/esphome/components/bang_bang/bang_bang_climate.h b/esphome/components/bang_bang/bang_bang_climate.h
index d0ddef284..1e5ff8488 100644
--- a/esphome/components/bang_bang/bang_bang_climate.h
+++ b/esphome/components/bang_bang/bang_bang_climate.h
@@ -5,8 +5,7 @@
#include "esphome/components/climate/climate.h"
#include "esphome/components/sensor/sensor.h"
-namespace esphome {
-namespace bang_bang {
+namespace esphome::bang_bang {
struct BangBangClimateTargetTempConfig {
public:
@@ -84,5 +83,4 @@ class BangBangClimate : public climate::Climate, public Component {
BangBangClimateTargetTempConfig away_config_{};
};
-} // namespace bang_bang
-} // namespace esphome
+} // namespace esphome::bang_bang
diff --git a/esphome/components/bedjet/bedjet_child.h b/esphome/components/bedjet/bedjet_child.h
index 4e07745c6..5b6c5f7f2 100644
--- a/esphome/components/bedjet/bedjet_child.h
+++ b/esphome/components/bedjet/bedjet_child.h
@@ -3,8 +3,7 @@
#include "bedjet_codec.h"
#include "esphome/core/helpers.h"
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
// Forward declare BedJetHub
class BedJetHub;
@@ -19,5 +18,4 @@ class BedJetClient : public Parented {
virtual std::string describe() = 0;
};
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
diff --git a/esphome/components/bedjet/bedjet_codec.cpp b/esphome/components/bedjet/bedjet_codec.cpp
index 7a959390f..6f6242a4c 100644
--- a/esphome/components/bedjet/bedjet_codec.cpp
+++ b/esphome/components/bedjet/bedjet_codec.cpp
@@ -3,8 +3,7 @@
#include
#include
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
/// Converts a BedJet temp step into degrees Fahrenheit.
float bedjet_temp_to_f(const uint8_t temp) {
@@ -177,5 +176,4 @@ float bedjet_temp_to_c(uint8_t temp) {
return temp / 2.0f;
}
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
diff --git a/esphome/components/bedjet/bedjet_codec.h b/esphome/components/bedjet/bedjet_codec.h
index 3936ba231..7cf463b56 100644
--- a/esphome/components/bedjet/bedjet_codec.h
+++ b/esphome/components/bedjet/bedjet_codec.h
@@ -5,8 +5,7 @@
#include "bedjet_const.h"
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
struct BedjetPacket {
uint8_t data_length;
@@ -190,5 +189,4 @@ class BedjetCodec {
/// Converts a BedJet temp step into degrees Celsius.
float bedjet_temp_to_c(uint8_t temp);
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
diff --git a/esphome/components/bedjet/bedjet_const.h b/esphome/components/bedjet/bedjet_const.h
index 10f403dd1..eb777a6a8 100644
--- a/esphome/components/bedjet/bedjet_const.h
+++ b/esphome/components/bedjet/bedjet_const.h
@@ -2,8 +2,7 @@
#include
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
static const char *const TAG = "bedjet";
@@ -101,5 +100,4 @@ static const uint8_t BEDJET_FAN_SPEED_COUNT = 20;
static constexpr const char *const BEDJET_FAN_STEP_NAMES[BEDJET_FAN_SPEED_COUNT] = BEDJET_FAN_STEP_NAMES_;
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
diff --git a/esphome/components/bedjet/bedjet_hub.cpp b/esphome/components/bedjet/bedjet_hub.cpp
index fec34c5b2..b04603b8c 100644
--- a/esphome/components/bedjet/bedjet_hub.cpp
+++ b/esphome/components/bedjet/bedjet_hub.cpp
@@ -6,8 +6,7 @@
#include "esphome/core/application.h"
#include
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
static const LogString *bedjet_button_to_string(BedjetButton button) {
switch (button) {
@@ -551,7 +550,6 @@ void BedJetHub::register_child(BedJetClient *obj) {
obj->set_parent(this);
}
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
#endif
diff --git a/esphome/components/bedjet/bedjet_hub.h b/esphome/components/bedjet/bedjet_hub.h
index 59b0af93a..9f25f7a46 100644
--- a/esphome/components/bedjet/bedjet_hub.h
+++ b/esphome/components/bedjet/bedjet_hub.h
@@ -18,8 +18,7 @@
#include
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
namespace espbt = esphome::esp32_ble_tracker;
@@ -172,7 +171,6 @@ class BedJetHub : public esphome::ble_client::BLEClientNode, public PollingCompo
uint8_t write_notify_config_descriptor_(bool enable);
};
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
#endif
diff --git a/esphome/components/bedjet/climate/bedjet_climate.cpp b/esphome/components/bedjet/climate/bedjet_climate.cpp
index 88ed902a1..196d4785e 100644
--- a/esphome/components/bedjet/climate/bedjet_climate.cpp
+++ b/esphome/components/bedjet/climate/bedjet_climate.cpp
@@ -3,8 +3,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
using namespace esphome::climate;
@@ -359,7 +358,6 @@ void BedJetClimate::update() {
ESP_LOGD(TAG, "[%s] update_status result=%s", this->get_name().c_str(), result ? "true" : "false");
}
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
#endif
diff --git a/esphome/components/bedjet/climate/bedjet_climate.h b/esphome/components/bedjet/climate/bedjet_climate.h
index d12c2a825..f59e67eeb 100644
--- a/esphome/components/bedjet/climate/bedjet_climate.h
+++ b/esphome/components/bedjet/climate/bedjet_climate.h
@@ -10,8 +10,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
class BedJetClimate : public climate::Climate, public BedJetClient, public PollingComponent {
public:
@@ -72,7 +71,6 @@ class BedJetClimate : public climate::Climate, public BedJetClient, public Polli
}
};
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
#endif
diff --git a/esphome/components/bedjet/fan/bedjet_fan.cpp b/esphome/components/bedjet/fan/bedjet_fan.cpp
index 9539e169a..4b1bd14ae 100644
--- a/esphome/components/bedjet/fan/bedjet_fan.cpp
+++ b/esphome/components/bedjet/fan/bedjet_fan.cpp
@@ -3,8 +3,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
using namespace esphome::fan;
@@ -109,7 +108,6 @@ void BedJetFan::reset_state_() {
this->state = false;
this->publish_state();
}
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
#endif
diff --git a/esphome/components/bedjet/fan/bedjet_fan.h b/esphome/components/bedjet/fan/bedjet_fan.h
index 19db06e9d..03f42f143 100644
--- a/esphome/components/bedjet/fan/bedjet_fan.h
+++ b/esphome/components/bedjet/fan/bedjet_fan.h
@@ -10,8 +10,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
class BedJetFan : public fan::Fan, public BedJetClient, public PollingComponent {
public:
@@ -34,7 +33,6 @@ class BedJetFan : public fan::Fan, public BedJetClient, public PollingComponent
bool update_status_();
};
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
#endif
diff --git a/esphome/components/bedjet/sensor/bedjet_sensor.cpp b/esphome/components/bedjet/sensor/bedjet_sensor.cpp
index 2fda8c927..05417bd51 100644
--- a/esphome/components/bedjet/sensor/bedjet_sensor.cpp
+++ b/esphome/components/bedjet/sensor/bedjet_sensor.cpp
@@ -1,8 +1,7 @@
#include "bedjet_sensor.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
std::string BedjetSensor::describe() { return "BedJet Sensor"; }
@@ -30,5 +29,4 @@ void BedjetSensor::on_status(const BedjetStatusPacket *data) {
}
}
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
diff --git a/esphome/components/bedjet/sensor/bedjet_sensor.h b/esphome/components/bedjet/sensor/bedjet_sensor.h
index 8cbaa863e..0c3f71357 100644
--- a/esphome/components/bedjet/sensor/bedjet_sensor.h
+++ b/esphome/components/bedjet/sensor/bedjet_sensor.h
@@ -5,8 +5,7 @@
#include "esphome/components/bedjet/bedjet_child.h"
#include "esphome/components/bedjet/bedjet_codec.h"
-namespace esphome {
-namespace bedjet {
+namespace esphome::bedjet {
class BedjetSensor : public BedJetClient, public Component {
public:
@@ -28,5 +27,4 @@ class BedjetSensor : public BedJetClient, public Component {
sensor::Sensor *ambient_temperature_sensor_{nullptr};
};
-} // namespace bedjet
-} // namespace esphome
+} // namespace esphome::bedjet
diff --git a/esphome/components/beken_spi_led_strip/led_strip.cpp b/esphome/components/beken_spi_led_strip/led_strip.cpp
index f425f3ca5..4e2248984 100644
--- a/esphome/components/beken_spi_led_strip/led_strip.cpp
+++ b/esphome/components/beken_spi_led_strip/led_strip.cpp
@@ -33,8 +33,7 @@ static const uint32_t CTRL_NSSMD_3 = 1 << 17;
static const uint32_t SPI_TX_FINISH_EN = 1 << 2;
static const uint32_t SPI_RX_FINISH_EN = 1 << 3;
-namespace esphome {
-namespace beken_spi_led_strip {
+namespace esphome::beken_spi_led_strip {
static const char *const TAG = "beken_spi_led_strip";
@@ -382,7 +381,6 @@ void BekenSPILEDStripLightOutput::dump_config() {
float BekenSPILEDStripLightOutput::get_setup_priority() const { return setup_priority::HARDWARE; }
-} // namespace beken_spi_led_strip
-} // namespace esphome
+} // namespace esphome::beken_spi_led_strip
#endif // USE_BK72XX
diff --git a/esphome/components/beken_spi_led_strip/led_strip.h b/esphome/components/beken_spi_led_strip/led_strip.h
index 705f9102a..4ed640a3b 100644
--- a/esphome/components/beken_spi_led_strip/led_strip.h
+++ b/esphome/components/beken_spi_led_strip/led_strip.h
@@ -8,8 +8,7 @@
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
-namespace esphome {
-namespace beken_spi_led_strip {
+namespace esphome::beken_spi_led_strip {
enum RGBOrder : uint8_t {
ORDER_RGB,
@@ -79,7 +78,6 @@ class BekenSPILEDStripLightOutput : public light::AddressableLight {
optional max_refresh_rate_{};
};
-} // namespace beken_spi_led_strip
-} // namespace esphome
+} // namespace esphome::beken_spi_led_strip
#endif // USE_BK72XX
diff --git a/esphome/components/beken_spi_led_strip/light.py b/esphome/components/beken_spi_led_strip/light.py
index 31572cd80..9093b08b6 100644
--- a/esphome/components/beken_spi_led_strip/light.py
+++ b/esphome/components/beken_spi_led_strip/light.py
@@ -62,6 +62,7 @@ CONF_IS_WRGB = "is_wrgb"
SUPPORTED_PINS = {
libretiny.const.FAMILY_BK7231N: [16],
libretiny.const.FAMILY_BK7231T: [16],
+ libretiny.const.FAMILY_BK7238: [16],
libretiny.const.FAMILY_BK7251: [16],
}
diff --git a/esphome/components/bh1750/bh1750.cpp b/esphome/components/bh1750/bh1750.cpp
index 045fb7cf4..ab952895a 100644
--- a/esphome/components/bh1750/bh1750.cpp
+++ b/esphome/components/bh1750/bh1750.cpp
@@ -154,7 +154,7 @@ void BH1750Sensor::loop() {
break;
}
- ESP_LOGD(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx);
+ ESP_LOGV(TAG, "'%s': Illuminance=%.1flx", this->get_name().c_str(), lx);
this->status_clear_warning();
this->publish_state(lx);
this->state_ = IDLE;
diff --git a/esphome/components/bh1900nux/bh1900nux.cpp b/esphome/components/bh1900nux/bh1900nux.cpp
index 0e71bd653..4f746a17d 100644
--- a/esphome/components/bh1900nux/bh1900nux.cpp
+++ b/esphome/components/bh1900nux/bh1900nux.cpp
@@ -1,8 +1,7 @@
#include "esphome/core/log.h"
#include "bh1900nux.h"
-namespace esphome {
-namespace bh1900nux {
+namespace esphome::bh1900nux {
static const char *const TAG = "bh1900nux.sensor";
@@ -50,5 +49,4 @@ void BH1900NUXSensor::dump_config() {
LOG_UPDATE_INTERVAL(this);
}
-} // namespace bh1900nux
-} // namespace esphome
+} // namespace esphome::bh1900nux
diff --git a/esphome/components/bh1900nux/bh1900nux.h b/esphome/components/bh1900nux/bh1900nux.h
index fd7f8848d..61d1bac26 100644
--- a/esphome/components/bh1900nux/bh1900nux.h
+++ b/esphome/components/bh1900nux/bh1900nux.h
@@ -4,8 +4,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
-namespace esphome {
-namespace bh1900nux {
+namespace esphome::bh1900nux {
class BH1900NUXSensor : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice {
public:
@@ -14,5 +13,4 @@ class BH1900NUXSensor : public sensor::Sensor, public PollingComponent, public i
void dump_config() override;
};
-} // namespace bh1900nux
-} // namespace esphome
+} // namespace esphome::bh1900nux
diff --git a/esphome/components/binary/fan/binary_fan.cpp b/esphome/components/binary/fan/binary_fan.cpp
index 17d4df095..118d87c09 100644
--- a/esphome/components/binary/fan/binary_fan.cpp
+++ b/esphome/components/binary/fan/binary_fan.cpp
@@ -1,8 +1,7 @@
#include "binary_fan.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace binary {
+namespace esphome::binary {
static const char *const TAG = "binary.fan";
@@ -39,5 +38,4 @@ void BinaryFan::write_state_() {
this->direction_->set_state(this->direction == fan::FanDirection::REVERSE);
}
-} // namespace binary
-} // namespace esphome
+} // namespace esphome::binary
diff --git a/esphome/components/binary/fan/binary_fan.h b/esphome/components/binary/fan/binary_fan.h
index 16bce2e6a..17157dd29 100644
--- a/esphome/components/binary/fan/binary_fan.h
+++ b/esphome/components/binary/fan/binary_fan.h
@@ -4,8 +4,7 @@
#include "esphome/components/output/binary_output.h"
#include "esphome/components/fan/fan.h"
-namespace esphome {
-namespace binary {
+namespace esphome::binary {
class BinaryFan : public Component, public fan::Fan {
public:
@@ -27,5 +26,4 @@ class BinaryFan : public Component, public fan::Fan {
output::BinaryOutput *direction_{nullptr};
};
-} // namespace binary
-} // namespace esphome
+} // namespace esphome::binary
diff --git a/esphome/components/binary/light/binary_light_output.h b/esphome/components/binary/light/binary_light_output.h
index 8346a82cf..f6be7e162 100644
--- a/esphome/components/binary/light/binary_light_output.h
+++ b/esphome/components/binary/light/binary_light_output.h
@@ -4,8 +4,7 @@
#include "esphome/components/output/binary_output.h"
#include "esphome/components/light/light_output.h"
-namespace esphome {
-namespace binary {
+namespace esphome::binary {
class BinaryLightOutput : public light::LightOutput {
public:
@@ -29,5 +28,4 @@ class BinaryLightOutput : public light::LightOutput {
output::BinaryOutput *output_;
};
-} // namespace binary
-} // namespace esphome
+} // namespace esphome::binary
diff --git a/esphome/components/binary_sensor/__init__.py b/esphome/components/binary_sensor/__init__.py
index 29ddbab02..a9a09363f 100644
--- a/esphome/components/binary_sensor/__init__.py
+++ b/esphome/components/binary_sensor/__init__.py
@@ -62,6 +62,7 @@ from esphome.const import (
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.core.entity_helpers import (
entity_duplicate_validator,
+ queue_entity_register,
setup_device_class,
setup_entity,
)
@@ -142,15 +143,15 @@ BinarySensorCondition = binary_sensor_ns.class_("BinarySensorCondition", Conditi
# Filters
Filter = binary_sensor_ns.class_("Filter")
-TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter, cg.Component)
-DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter, cg.Component)
-DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter, cg.Component)
-DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter, cg.Component)
+TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter)
+DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter)
+DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter)
+DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter)
InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter)
-AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter, cg.Component)
+AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter)
LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter)
StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter)
-SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter, cg.Component)
+SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter)
_LOGGER = getLogger(__name__)
@@ -174,7 +175,6 @@ async def invert_filter_to_code(config, filter_id):
)
async def timeout_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id)
- await cg.register_component(var, {})
template_ = await cg.templatable(config, [], cg.uint32)
cg.add(var.set_timeout_value(template_))
return var
@@ -202,7 +202,6 @@ async def timeout_filter_to_code(config, filter_id):
)
async def delayed_on_off_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id)
- await cg.register_component(var, {})
if isinstance(config, dict):
template_ = await cg.templatable(config[CONF_TIME_ON], [], cg.uint32)
cg.add(var.set_on_delay(template_))
@@ -220,7 +219,6 @@ async def delayed_on_off_filter_to_code(config, filter_id):
)
async def delayed_on_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id)
- await cg.register_component(var, {})
template_ = await cg.templatable(config, [], cg.uint32)
cg.add(var.set_delay(template_))
return var
@@ -233,7 +231,6 @@ async def delayed_on_filter_to_code(config, filter_id):
)
async def delayed_off_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id)
- await cg.register_component(var, {})
template_ = await cg.templatable(config, [], cg.uint32)
cg.add(var.set_delay(template_))
return var
@@ -285,9 +282,7 @@ async def autorepeat_filter_to_code(config, filter_id):
),
)
]
- var = cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings)
- await cg.register_component(var, {})
- return var
+ return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings)
@register_filter("lambda", LambdaFilter, cv.returning_lambda)
@@ -305,7 +300,6 @@ async def lambda_filter_to_code(config, filter_id):
)
async def settle_filter_to_code(config, filter_id):
var = cg.new_Pvariable(filter_id)
- await cg.register_component(var, {})
template_ = await cg.templatable(config, [], cg.uint32)
cg.add(var.set_delay(template_))
return var
@@ -624,7 +618,7 @@ async def setup_binary_sensor_core_(var, config):
async def register_binary_sensor(var, config):
if not CORE.has_id(config[CONF_ID]):
var = cg.Pvariable(config[CONF_ID], var)
- cg.add(cg.App.register_binary_sensor(var))
+ queue_entity_register("binary_sensor", config)
CORE.register_platform_component("binary_sensor", var)
await setup_binary_sensor_core_(var, config)
diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp
index eb68abce3..b13e4a88d 100644
--- a/esphome/components/binary_sensor/automation.cpp
+++ b/esphome/components/binary_sensor/automation.cpp
@@ -50,29 +50,31 @@ void MultiClickTriggerBase::on_state_(bool state) {
return;
}
- if (*this->at_index_ == this->timing_count_) {
+ // at_index_ has a value here (the !has_value() branch above returns).
+ size_t at_index = *this->at_index_;
+ if (at_index == this->timing_count_) {
this->trigger_();
return;
}
- MultiClickTriggerEvent evt = this->timing_[*this->at_index_];
+ MultiClickTriggerEvent evt = this->timing_[at_index];
if (evt.max_length != 4294967294UL) {
- ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, *this->at_index_, evt.min_length, evt.max_length); // NOLINT
+ ESP_LOGV(TAG, "A i=%zu min=%" PRIu32 " max=%" PRIu32, at_index, evt.min_length, evt.max_length); // NOLINT
this->schedule_is_valid_(evt.min_length);
this->schedule_is_not_valid_(evt.max_length);
- } else if (*this->at_index_ + 1 != this->timing_count_) {
- ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT
+ } else if (at_index + 1 != this->timing_count_) {
+ ESP_LOGV(TAG, "B i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
this->schedule_is_valid_(evt.min_length);
} else {
- ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, *this->at_index_, evt.min_length); // NOLINT
+ ESP_LOGV(TAG, "C i=%zu min=%" PRIu32, at_index, evt.min_length); // NOLINT
this->is_valid_ = false;
this->cancel_timeout(MULTICLICK_IS_NOT_VALID_ID);
this->set_timeout(MULTICLICK_TRIGGER_ID, evt.min_length, [this]() { this->trigger_(); });
}
- *this->at_index_ = *this->at_index_ + 1;
+ this->at_index_ = at_index + 1;
}
void MultiClickTriggerBase::schedule_cooldown_() {
ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_);
diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp
index 914060ce1..8b882212c 100644
--- a/esphome/components/binary_sensor/filter.cpp
+++ b/esphome/components/binary_sensor/filter.cpp
@@ -4,19 +4,12 @@
#include "filter.h"
#include "binary_sensor.h"
+#include "esphome/core/application.h"
namespace esphome::binary_sensor {
static const char *const TAG = "sensor.filter";
-// Timeout IDs for filter classes.
-// Each filter is its own Component instance, so the scheduler scopes
-// IDs by component pointer — no risk of collisions between instances.
-constexpr uint32_t FILTER_TIMEOUT_ID = 0;
-// AutorepeatFilter needs two distinct IDs (both timeouts on the same component)
-constexpr uint32_t AUTOREPEAT_TIMING_ID = 0;
-constexpr uint32_t AUTOREPEAT_ON_OFF_ID = 1;
-
void Filter::output(bool value) {
if (this->next_ == nullptr) {
this->parent_->send_state_internal(value);
@@ -34,49 +27,47 @@ void Filter::input(bool value) {
}
void TimeoutFilter::input(bool value) {
- this->set_timeout(FILTER_TIMEOUT_ID, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); });
+ App.scheduler.set_timeout(this, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); });
// we do not de-dup here otherwise changes from invalid to valid state will not be output
this->output(value);
}
optional DelayedOnOffFilter::new_value(bool value) {
if (value) {
- this->set_timeout(FILTER_TIMEOUT_ID, this->on_delay_.value(), [this]() { this->output(true); });
+ App.scheduler.set_timeout(this, this->on_delay_.value(), [this]() { this->output(true); });
} else {
- this->set_timeout(FILTER_TIMEOUT_ID, this->off_delay_.value(), [this]() { this->output(false); });
+ App.scheduler.set_timeout(this, this->off_delay_.value(), [this]() { this->output(false); });
}
return {};
}
-float DelayedOnOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
-
optional DelayedOnFilter::new_value(bool value) {
if (value) {
- this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(true); });
+ App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(true); });
return {};
} else {
- this->cancel_timeout(FILTER_TIMEOUT_ID);
+ App.scheduler.cancel_timeout(this);
return false;
}
}
-float DelayedOnFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
-
optional DelayedOffFilter::new_value(bool value) {
if (!value) {
- this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(false); });
+ App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(false); });
return {};
} else {
- this->cancel_timeout(FILTER_TIMEOUT_ID);
+ App.scheduler.cancel_timeout(this);
return true;
}
}
-float DelayedOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
-
optional InvertFilter::new_value(bool value) { return !value; }
// AutorepeatFilterBase
+// Two independent timers per instance, keyed off two stable addresses inside
+// the filter: `this` for the timing-step timer, `&active_timing_` for the
+// on/off timer. Both are unique per instance and don't collide with anything
+// else, so the self-keyed scheduler API is sufficient.
optional AutorepeatFilterBase::new_value(bool value) {
if (value) {
if (this->active_timing_ != 0)
@@ -84,8 +75,8 @@ optional AutorepeatFilterBase::new_value(bool value) {
this->next_timing_();
return true;
} else {
- this->cancel_timeout(AUTOREPEAT_TIMING_ID);
- this->cancel_timeout(AUTOREPEAT_ON_OFF_ID);
+ App.scheduler.cancel_timeout(this);
+ App.scheduler.cancel_timeout(&this->active_timing_);
this->active_timing_ = 0;
return false;
}
@@ -93,8 +84,7 @@ optional AutorepeatFilterBase::new_value(bool value) {
void AutorepeatFilterBase::next_timing_() {
if (this->active_timing_ < this->timings_count_) {
- this->set_timeout(AUTOREPEAT_TIMING_ID, this->timings_[this->active_timing_].delay,
- [this]() { this->next_timing_(); });
+ App.scheduler.set_timeout(this, this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); });
}
if (this->active_timing_ <= this->timings_count_) {
this->active_timing_++;
@@ -106,32 +96,28 @@ void AutorepeatFilterBase::next_timing_() {
void AutorepeatFilterBase::next_value_(bool val) {
const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2];
this->output(val);
- this->set_timeout(AUTOREPEAT_ON_OFF_ID, val ? timing.time_on : timing.time_off,
- [this, val]() { this->next_value_(!val); });
+ App.scheduler.set_timeout(&this->active_timing_, val ? timing.time_on : timing.time_off,
+ [this, val]() { this->next_value_(!val); });
}
-float AutorepeatFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; }
-
LambdaFilter::LambdaFilter(std::function(bool)> f) : f_(std::move(f)) {}
optional LambdaFilter::new_value(bool value) { return this->f_(value); }
optional SettleFilter::new_value(bool value) {
if (!this->steady_) {
- this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this, value]() {
+ App.scheduler.set_timeout(this, this->delay_.value(), [this, value]() {
this->steady_ = true;
this->output(value);
});
return {};
} else {
this->steady_ = false;
- this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->steady_ = true; });
+ App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->steady_ = true; });
return value;
}
}
-float SettleFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
-
} // namespace esphome::binary_sensor
#endif // USE_BINARY_SENSOR_FILTER
diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h
index 2e45554f8..6887de35e 100644
--- a/esphome/components/binary_sensor/filter.h
+++ b/esphome/components/binary_sensor/filter.h
@@ -29,7 +29,7 @@ class Filter {
Deduplicator dedup_;
};
-class TimeoutFilter : public Filter, public Component {
+class TimeoutFilter : public Filter {
public:
optional new_value(bool value) override { return value; }
void input(bool value) override;
@@ -39,12 +39,10 @@ class TimeoutFilter : public Filter, public Component {
TemplatableFn timeout_delay_{};
};
-class DelayedOnOffFilter final : public Filter, public Component {
+class DelayedOnOffFilter final : public Filter {
public:
optional new_value(bool value) override;
- float get_setup_priority() const override;
-
template void set_on_delay(T delay) { this->on_delay_ = delay; }
template void set_off_delay(T delay) { this->off_delay_ = delay; }
@@ -53,24 +51,20 @@ class DelayedOnOffFilter final : public Filter, public Component {
TemplatableFn off_delay_{};
};
-class DelayedOnFilter : public Filter, public Component {
+class DelayedOnFilter : public Filter {
public:
optional new_value(bool value) override;
- float get_setup_priority() const override;
-
template void set_delay(T delay) { this->delay_ = delay; }
protected:
TemplatableFn delay_{};
};
-class DelayedOffFilter : public Filter, public Component {
+class DelayedOffFilter : public Filter {
public:
optional new_value(bool value) override;
- float get_setup_priority() const override;
-
template void set_delay(T delay) { this->delay_ = delay; }
protected:
@@ -90,10 +84,11 @@ struct AutorepeatFilterTiming {
/// Non-template base for AutorepeatFilter — all methods in filter.cpp.
/// Lambdas capture this base pointer, so set_timeout/cancel_timeout are instantiated once.
-class AutorepeatFilterBase : public Filter, public Component {
+/// The two scheduled timers are keyed off `this` and `&active_timing_`; since the address
+/// of `active_timing_` is taken as a scheduler key, the class must not be copied or moved.
+class AutorepeatFilterBase : public Filter {
public:
optional new_value(bool value) override;
- float get_setup_priority() const override;
AutorepeatFilterBase(const AutorepeatFilterBase &) = delete;
AutorepeatFilterBase &operator=(const AutorepeatFilterBase &) = delete;
@@ -146,12 +141,10 @@ class StatelessLambdaFilter : public Filter {
optional (*f_)(bool);
};
-class SettleFilter : public Filter, public Component {
+class SettleFilter : public Filter {
public:
optional new_value(bool value) override;
- float get_setup_priority() const override;
-
template void set_delay(T delay) { this->delay_ = delay; }
protected:
diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.cpp b/esphome/components/binary_sensor_map/binary_sensor_map.cpp
index 0bf620289..316d44ba5 100644
--- a/esphome/components/binary_sensor_map/binary_sensor_map.cpp
+++ b/esphome/components/binary_sensor_map/binary_sensor_map.cpp
@@ -1,8 +1,7 @@
#include "binary_sensor_map.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace binary_sensor_map {
+namespace esphome::binary_sensor_map {
static const char *const TAG = "binary_sensor_map";
@@ -138,5 +137,4 @@ void BinarySensorMap::add_channel(binary_sensor::BinarySensor *sensor, float pro
};
this->channels_.push_back(sensor_channel);
}
-} // namespace binary_sensor_map
-} // namespace esphome
+} // namespace esphome::binary_sensor_map
diff --git a/esphome/components/binary_sensor_map/binary_sensor_map.h b/esphome/components/binary_sensor_map/binary_sensor_map.h
index a07154c0e..60224242d 100644
--- a/esphome/components/binary_sensor_map/binary_sensor_map.h
+++ b/esphome/components/binary_sensor_map/binary_sensor_map.h
@@ -6,8 +6,7 @@
#include
-namespace esphome {
-namespace binary_sensor_map {
+namespace esphome::binary_sensor_map {
enum BinarySensorMapType {
BINARY_SENSOR_MAP_TYPE_GROUP,
@@ -96,5 +95,4 @@ class BinarySensorMap : public sensor::Sensor, public Component {
float bayesian_predicate_(bool sensor_state, float prior, float prob_given_true, float prob_given_false);
};
-} // namespace binary_sensor_map
-} // namespace esphome
+} // namespace esphome::binary_sensor_map
diff --git a/esphome/components/bk72xx/boards.py b/esphome/components/bk72xx/boards.py
index 4bee69fe6..f8bedce32 100644
--- a/esphome/components/bk72xx/boards.py
+++ b/esphome/components/bk72xx/boards.py
@@ -16,6 +16,7 @@ from esphome.components.libretiny.const import (
FAMILY_BK7231N,
FAMILY_BK7231Q,
FAMILY_BK7231T,
+ FAMILY_BK7238,
FAMILY_BK7251,
)
@@ -24,16 +25,32 @@ BK72XX_BOARDS = {
"name": "WB2L_M1 Wi-Fi Module",
"family": FAMILY_BK7231N,
},
+ "xh-wb3s": {
+ "name": "NiceMCU XH-WB3S",
+ "family": FAMILY_BK7238,
+ },
"cbu": {
"name": "CBU Wi-Fi Module",
"family": FAMILY_BK7231N,
},
+ "t1-u": {
+ "name": "T1-U Wi-Fi Module",
+ "family": FAMILY_BK7238,
+ },
+ "generic-bk7238-tuya": {
+ "name": "Generic - BK7238 (Tuya T1)",
+ "family": FAMILY_BK7238,
+ },
+ "t1-m": {
+ "name": "T1-M Wi-Fi Module",
+ "family": FAMILY_BK7238,
+ },
"generic-bk7231t-qfn32-tuya": {
- "name": "Generic - BK7231T (Tuya QFN32)",
+ "name": "Generic - BK7231T (Tuya)",
"family": FAMILY_BK7231T,
},
"generic-bk7231n-qfn32-tuya": {
- "name": "Generic - BK7231N (Tuya QFN32)",
+ "name": "Generic - BK7231N (Tuya)",
"family": FAMILY_BK7231N,
},
"cb1s": {
@@ -64,6 +81,10 @@ BK72XX_BOARDS = {
"name": "Generic - BK7252",
"family": FAMILY_BK7251,
},
+ "t1-3s": {
+ "name": "T1-3S Wi-Fi Module",
+ "family": FAMILY_BK7238,
+ },
"wb2l": {
"name": "WB2L Wi-Fi Module",
"family": FAMILY_BK7231T,
@@ -80,6 +101,10 @@ BK72XX_BOARDS = {
"name": "CB2S Wi-Fi Module",
"family": FAMILY_BK7231N,
},
+ "generic-bk7238": {
+ "name": "Generic - BK7238",
+ "family": FAMILY_BK7238,
+ },
"wa2": {
"name": "WA2 Wi-Fi Module",
"family": FAMILY_BK7231Q,
@@ -100,6 +125,10 @@ BK72XX_BOARDS = {
"name": "WB3L Wi-Fi Module",
"family": FAMILY_BK7231T,
},
+ "t1-2s": {
+ "name": "T1-2S Wi-Fi Module",
+ "family": FAMILY_BK7238,
+ },
"wb2s": {
"name": "WB2S Wi-Fi Module",
"family": FAMILY_BK7231T,
@@ -158,6 +187,83 @@ BK72XX_BOARD_PINS = {
"D12": 22,
"A0": 23,
},
+ "xh-wb3s": {
+ "SPI0_CS": 15,
+ "SPI0_MISO": 17,
+ "SPI0_MOSI": 16,
+ "SPI0_SCK": 14,
+ "WIRE2_SCL_0": 15,
+ "WIRE2_SCL_1": 24,
+ "WIRE2_SDA_0": 17,
+ "WIRE2_SDA_1": 26,
+ "SERIAL1_RX": 10,
+ "SERIAL1_TX": 11,
+ "SERIAL2_RX": 1,
+ "SERIAL2_TX": 0,
+ "ADC1": 26,
+ "ADC2": 24,
+ "ADC3": 20,
+ "ADC4": 28,
+ "ADC5": 1,
+ "ADC6": 10,
+ "CS": 15,
+ "MISO": 17,
+ "MOSI": 16,
+ "P0": 0,
+ "P1": 1,
+ "P6": 6,
+ "P7": 7,
+ "P8": 8,
+ "P9": 9,
+ "P10": 10,
+ "P11": 11,
+ "P14": 14,
+ "P15": 15,
+ "P16": 16,
+ "P17": 17,
+ "P20": 20,
+ "P21": 21,
+ "P22": 22,
+ "P23": 23,
+ "P24": 24,
+ "P26": 26,
+ "P28": 28,
+ "PWM0": 6,
+ "PWM1": 7,
+ "PWM2": 8,
+ "PWM3": 9,
+ "PWM4": 24,
+ "PWM5": 26,
+ "RX1": 10,
+ "RX2": 1,
+ "SCK": 14,
+ "TX1": 11,
+ "TX2": 0,
+ "D0": 7,
+ "D1": 23,
+ "D2": 14,
+ "D3": 26,
+ "D4": 24,
+ "D5": 6,
+ "D6": 9,
+ "D7": 0,
+ "D8": 1,
+ "D9": 8,
+ "D10": 10,
+ "D11": 11,
+ "D12": 16,
+ "D13": 20,
+ "D14": 21,
+ "D15": 22,
+ "D16": 15,
+ "D17": 17,
+ "A0": 28,
+ "A1": 26,
+ "A2": 24,
+ "A3": 1,
+ "A4": 10,
+ "A5": 20,
+ },
"cbu": {
"SPI0_CS": 15,
"SPI0_MISO": 17,
@@ -230,6 +336,204 @@ BK72XX_BOARD_PINS = {
"D18": 21,
"A0": 23,
},
+ "t1-u": {
+ "SPI0_CS": 15,
+ "SPI0_MISO": 17,
+ "SPI0_MOSI": 16,
+ "SPI0_SCK": 14,
+ "WIRE2_SCL_0": 15,
+ "WIRE2_SCL_1": 24,
+ "WIRE2_SDA_0": 17,
+ "WIRE2_SDA_1": 26,
+ "SERIAL1_RX": 10,
+ "SERIAL1_TX": 11,
+ "SERIAL2_RX": 1,
+ "SERIAL2_TX": 0,
+ "ADC1": 26,
+ "ADC2": 24,
+ "ADC3": 20,
+ "ADC4": 28,
+ "ADC5": 1,
+ "ADC6": 10,
+ "CS": 15,
+ "MISO": 17,
+ "MOSI": 16,
+ "P0": 0,
+ "P1": 1,
+ "P6": 6,
+ "P8": 8,
+ "P9": 9,
+ "P10": 10,
+ "P11": 11,
+ "P14": 14,
+ "P15": 15,
+ "P16": 16,
+ "P17": 17,
+ "P20": 20,
+ "P21": 21,
+ "P22": 22,
+ "P23": 23,
+ "P24": 24,
+ "P26": 26,
+ "P28": 28,
+ "PWM0": 6,
+ "PWM2": 8,
+ "PWM3": 9,
+ "PWM4": 24,
+ "PWM5": 26,
+ "RX1": 10,
+ "RX2": 1,
+ "SCK": 14,
+ "TX1": 11,
+ "TX2": 0,
+ "D0": 14,
+ "D1": 16,
+ "D2": 23,
+ "D3": 22,
+ "D4": 20,
+ "D5": 1,
+ "D6": 0,
+ "D7": 24,
+ "D8": 9,
+ "D9": 26,
+ "D10": 6,
+ "D11": 8,
+ "D12": 11,
+ "D13": 10,
+ "D14": 28,
+ "D15": 21,
+ "D16": 17,
+ "D17": 15,
+ "A0": 20,
+ "A1": 1,
+ "A2": 24,
+ "A3": 26,
+ "A4": 10,
+ "A5": 28,
+ },
+ "generic-bk7238-tuya": {
+ "SPI0_CS": 15,
+ "SPI0_MISO": 17,
+ "SPI0_MOSI": 16,
+ "SPI0_SCK": 14,
+ "WIRE2_SCL_0": 15,
+ "WIRE2_SCL_1": 24,
+ "WIRE2_SDA_0": 17,
+ "WIRE2_SDA_1": 26,
+ "SERIAL1_RX": 10,
+ "SERIAL1_TX": 11,
+ "SERIAL2_RX": 1,
+ "SERIAL2_TX": 0,
+ "ADC1": 26,
+ "ADC2": 24,
+ "ADC3": 20,
+ "ADC4": 28,
+ "ADC5": 1,
+ "ADC6": 10,
+ "CS": 15,
+ "MISO": 17,
+ "MOSI": 16,
+ "P0": 0,
+ "P1": 1,
+ "P6": 6,
+ "P7": 7,
+ "P8": 8,
+ "P9": 9,
+ "P10": 10,
+ "P11": 11,
+ "P14": 14,
+ "P15": 15,
+ "P16": 16,
+ "P17": 17,
+ "P20": 20,
+ "P21": 21,
+ "P22": 22,
+ "P23": 23,
+ "P24": 24,
+ "P26": 26,
+ "P28": 28,
+ "PWM0": 6,
+ "PWM1": 7,
+ "PWM2": 8,
+ "PWM3": 9,
+ "PWM4": 24,
+ "PWM5": 26,
+ "RX1": 10,
+ "RX2": 1,
+ "SCK": 14,
+ "TX1": 11,
+ "TX2": 0,
+ "D0": 0,
+ "D1": 1,
+ "D2": 6,
+ "D3": 7,
+ "D4": 8,
+ "D5": 9,
+ "D6": 10,
+ "D7": 11,
+ "D8": 14,
+ "D9": 15,
+ "D10": 16,
+ "D11": 17,
+ "D12": 20,
+ "D13": 21,
+ "D14": 22,
+ "D15": 23,
+ "D16": 24,
+ "D17": 26,
+ "D18": 28,
+ "A0": 1,
+ "A1": 10,
+ "A2": 20,
+ "A3": 24,
+ "A4": 26,
+ "A5": 28,
+ },
+ "t1-m": {
+ "WIRE2_SCL": 24,
+ "WIRE2_SDA": 26,
+ "SERIAL1_RX": 10,
+ "SERIAL1_TX": 11,
+ "SERIAL2_RX": 1,
+ "SERIAL2_TX": 0,
+ "ADC1": 26,
+ "ADC2": 24,
+ "ADC5": 1,
+ "ADC6": 10,
+ "P0": 0,
+ "P1": 1,
+ "P6": 6,
+ "P8": 8,
+ "P9": 9,
+ "P10": 10,
+ "P11": 11,
+ "P24": 24,
+ "P26": 26,
+ "PWM0": 6,
+ "PWM2": 8,
+ "PWM3": 9,
+ "PWM4": 24,
+ "PWM5": 26,
+ "RX1": 10,
+ "RX2": 1,
+ "SCL2": 24,
+ "SDA2": 26,
+ "TX1": 11,
+ "TX2": 0,
+ "D0": 26,
+ "D1": 6,
+ "D2": 8,
+ "D3": 1,
+ "D4": 10,
+ "D5": 11,
+ "D6": 9,
+ "D7": 24,
+ "D11": 0,
+ "A0": 26,
+ "A1": 10,
+ "A2": 1,
+ "A3": 24,
+ },
"generic-bk7231t-qfn32-tuya": {
"SPI0_CS": 15,
"SPI0_MISO": 17,
@@ -781,6 +1085,75 @@ BK72XX_BOARD_PINS = {
"A6": 12,
"A7": 13,
},
+ "t1-3s": {
+ "SPI0_CS": 15,
+ "SPI0_MISO": 17,
+ "SPI0_MOSI": 16,
+ "SPI0_SCK": 14,
+ "WIRE2_SCL_0": 15,
+ "WIRE2_SCL_1": 24,
+ "WIRE2_SDA_0": 17,
+ "WIRE2_SDA_1": 26,
+ "SERIAL1_RX": 10,
+ "SERIAL1_TX": 11,
+ "SERIAL2_RX": 1,
+ "SERIAL2_TX": 0,
+ "ADC1": 26,
+ "ADC2": 24,
+ "ADC3": 20,
+ "ADC5": 1,
+ "ADC6": 10,
+ "CS": 15,
+ "MISO": 17,
+ "MOSI": 16,
+ "P0": 0,
+ "P1": 1,
+ "P6": 6,
+ "P8": 8,
+ "P9": 9,
+ "P10": 10,
+ "P11": 11,
+ "P14": 14,
+ "P15": 15,
+ "P16": 16,
+ "P17": 17,
+ "P20": 20,
+ "P22": 22,
+ "P23": 23,
+ "P24": 24,
+ "P26": 26,
+ "PWM0": 6,
+ "PWM2": 8,
+ "PWM3": 9,
+ "PWM4": 24,
+ "PWM5": 26,
+ "RX1": 10,
+ "RX2": 1,
+ "SCK": 14,
+ "TX1": 11,
+ "TX2": 0,
+ "D0": 20,
+ "D1": 22,
+ "D2": 6,
+ "D3": 8,
+ "D4": 9,
+ "D5": 23,
+ "D6": 0,
+ "D7": 1,
+ "D8": 24,
+ "D9": 26,
+ "D10": 10,
+ "D11": 11,
+ "D12": 17,
+ "D13": 16,
+ "D14": 15,
+ "D15": 14,
+ "A0": 20,
+ "A1": 1,
+ "A2": 24,
+ "A3": 26,
+ "A4": 10,
+ },
"wb2l": {
"WIRE1_SCL": 20,
"WIRE1_SDA": 21,
@@ -965,6 +1338,84 @@ BK72XX_BOARD_PINS = {
"D10": 21,
"A0": 23,
},
+ "generic-bk7238": {
+ "SPI0_CS": 15,
+ "SPI0_MISO": 17,
+ "SPI0_MOSI": 16,
+ "SPI0_SCK": 14,
+ "WIRE2_SCL_0": 15,
+ "WIRE2_SCL_1": 24,
+ "WIRE2_SDA_0": 17,
+ "WIRE2_SDA_1": 26,
+ "SERIAL1_RX": 10,
+ "SERIAL1_TX": 11,
+ "SERIAL2_RX": 1,
+ "SERIAL2_TX": 0,
+ "ADC1": 26,
+ "ADC2": 24,
+ "ADC3": 20,
+ "ADC4": 28,
+ "ADC5": 1,
+ "ADC6": 10,
+ "CS": 15,
+ "MISO": 17,
+ "MOSI": 16,
+ "P0": 0,
+ "P1": 1,
+ "P6": 6,
+ "P7": 7,
+ "P8": 8,
+ "P9": 9,
+ "P10": 10,
+ "P11": 11,
+ "P14": 14,
+ "P15": 15,
+ "P16": 16,
+ "P17": 17,
+ "P20": 20,
+ "P21": 21,
+ "P22": 22,
+ "P23": 23,
+ "P24": 24,
+ "P26": 26,
+ "P28": 28,
+ "PWM0": 6,
+ "PWM1": 7,
+ "PWM2": 8,
+ "PWM3": 9,
+ "PWM4": 24,
+ "PWM5": 26,
+ "RX1": 10,
+ "RX2": 1,
+ "SCK": 14,
+ "TX1": 11,
+ "TX2": 0,
+ "D0": 0,
+ "D1": 1,
+ "D2": 6,
+ "D3": 7,
+ "D4": 8,
+ "D5": 9,
+ "D6": 10,
+ "D7": 11,
+ "D8": 14,
+ "D9": 15,
+ "D10": 16,
+ "D11": 17,
+ "D12": 20,
+ "D13": 21,
+ "D14": 22,
+ "D15": 23,
+ "D16": 24,
+ "D17": 26,
+ "D18": 28,
+ "A0": 1,
+ "A1": 10,
+ "A2": 20,
+ "A3": 24,
+ "A4": 26,
+ "A5": 28,
+ },
"wa2": {
"WIRE1_SCL": 20,
"WIRE1_SDA": 21,
@@ -1235,6 +1686,51 @@ BK72XX_BOARD_PINS = {
"D15": 1,
"A0": 23,
},
+ "t1-2s": {
+ "WIRE2_SCL": 24,
+ "WIRE2_SDA": 26,
+ "SERIAL1_RX": 10,
+ "SERIAL1_TX": 11,
+ "SERIAL2_RX": 1,
+ "SERIAL2_TX": 0,
+ "ADC1": 26,
+ "ADC2": 24,
+ "ADC5": 1,
+ "ADC6": 10,
+ "P0": 0,
+ "P1": 1,
+ "P6": 6,
+ "P8": 8,
+ "P9": 9,
+ "P10": 10,
+ "P11": 11,
+ "P24": 24,
+ "P26": 26,
+ "PWM0": 6,
+ "PWM2": 8,
+ "PWM3": 9,
+ "PWM4": 24,
+ "PWM5": 26,
+ "RX1": 10,
+ "RX2": 1,
+ "SCL2": 24,
+ "SDA2": 26,
+ "TX1": 11,
+ "TX2": 0,
+ "D0": 26,
+ "D1": 6,
+ "D2": 8,
+ "D3": 1,
+ "D4": 10,
+ "D5": 11,
+ "D6": 9,
+ "D7": 24,
+ "D11": 0,
+ "A0": 26,
+ "A1": 10,
+ "A2": 1,
+ "A3": 24,
+ },
"wb2s": {
"WIRE1_SCL": 20,
"WIRE1_SDA": 21,
diff --git a/esphome/components/bl0906/bl0906.cpp b/esphome/components/bl0906/bl0906.cpp
index d554057f7..d38775705 100644
--- a/esphome/components/bl0906/bl0906.cpp
+++ b/esphome/components/bl0906/bl0906.cpp
@@ -3,8 +3,7 @@
#include "esphome/core/log.h"
-namespace esphome {
-namespace bl0906 {
+namespace esphome::bl0906 {
static const char *const TAG = "bl0906";
@@ -262,5 +261,4 @@ void BL0906::dump_config() {
LOG_SENSOR(" ", "Temperature", this->temperature_sensor_);
}
-} // namespace bl0906
-} // namespace esphome
+} // namespace esphome::bl0906
diff --git a/esphome/components/bl0906/bl0906.h b/esphome/components/bl0906/bl0906.h
index f7ba5423d..821aac476 100644
--- a/esphome/components/bl0906/bl0906.h
+++ b/esphome/components/bl0906/bl0906.h
@@ -9,8 +9,7 @@
// https://www.belling.com.cn/media/file_object/bel_product/BL0906/datasheet/BL0906_V1.02_cn.pdf
// https://www.belling.com.cn/media/file_object/bel_product/BL0906/guide/BL0906%20APP%20Note_V1.02.pdf
-namespace esphome {
-namespace bl0906 {
+namespace esphome::bl0906 {
// Stage values for the read state machine. After STAGE_CHANNEL_6 the state machine
// jumps to the two sentinel stages below, then to STAGE_IDLE which marks the cycle
@@ -109,5 +108,4 @@ template class ResetEnergyAction : public Action, public
void play(const Ts &...x) override { this->parent_->enqueue_action_(&BL0906::reset_energy_); }
};
-} // namespace bl0906
-} // namespace esphome
+} // namespace esphome::bl0906
diff --git a/esphome/components/bl0906/constants.h b/esphome/components/bl0906/constants.h
index a174e54bb..423d46021 100644
--- a/esphome/components/bl0906/constants.h
+++ b/esphome/components/bl0906/constants.h
@@ -1,8 +1,7 @@
#pragma once
#include
-namespace esphome {
-namespace bl0906 {
+namespace esphome::bl0906 {
// Total power conversion
static const float BL0906_WATT = 16 * 1.097 * 1.097 * (20000 + 20000 + 20000 + 20000 + 20000) /
@@ -118,5 +117,4 @@ const uint8_t BL0906_INIT[2][6] = {
// Enable User Operation Write
{BL0906_WRITE_COMMAND, BL0906_USR_WRPROT, 0x55, 0x55, 0x00, 0xB7}};
-} // namespace bl0906
-} // namespace esphome
+} // namespace esphome::bl0906
diff --git a/esphome/components/bl0939/bl0939.cpp b/esphome/components/bl0939/bl0939.cpp
index 7428e4874..9ae3360e3 100644
--- a/esphome/components/bl0939/bl0939.cpp
+++ b/esphome/components/bl0939/bl0939.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/log.h"
#include
-namespace esphome {
-namespace bl0939 {
+namespace esphome::bl0939 {
static const char *const TAG = "bl0939";
@@ -142,5 +141,4 @@ uint32_t BL0939::to_uint32_t(ube24_t input) { return input.h << 16 | input.m <<
int32_t BL0939::to_int32_t(sbe24_t input) { return input.h << 16 | input.m << 8 | input.l; }
-} // namespace bl0939
-} // namespace esphome
+} // namespace esphome::bl0939
diff --git a/esphome/components/bl0939/bl0939.h b/esphome/components/bl0939/bl0939.h
index 673d4ff35..b4f6d42e7 100644
--- a/esphome/components/bl0939/bl0939.h
+++ b/esphome/components/bl0939/bl0939.h
@@ -4,8 +4,7 @@
#include "esphome/components/uart/uart.h"
#include "esphome/components/sensor/sensor.h"
-namespace esphome {
-namespace bl0939 {
+namespace esphome::bl0939 {
// https://datasheet.lcsc.com/lcsc/2108071830_BL-Shanghai-Belling-BL0939_C2841044.pdf
// (unfortunately chinese, but the formulas can be easily understood)
@@ -103,5 +102,4 @@ class BL0939 : public PollingComponent, public uart::UARTDevice {
void received_package_(const DataPacket *data) const;
};
-} // namespace bl0939
-} // namespace esphome
+} // namespace esphome::bl0939
diff --git a/esphome/components/bl0940/bl0940.cpp b/esphome/components/bl0940/bl0940.cpp
index 31625ebf6..b7df603f2 100644
--- a/esphome/components/bl0940/bl0940.cpp
+++ b/esphome/components/bl0940/bl0940.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/log.h"
#include
-namespace esphome {
-namespace bl0940 {
+namespace esphome::bl0940 {
static const char *const TAG = "bl0940";
@@ -274,5 +273,4 @@ void BL0940::dump_config() { // NOLINT(readability-function-cognitive-complexit
LOG_SENSOR("", "External temperature", this->external_temperature_sensor_);
}
-} // namespace bl0940
-} // namespace esphome
+} // namespace esphome::bl0940
diff --git a/esphome/components/bl0940/bl0940.h b/esphome/components/bl0940/bl0940.h
index e0ca748a2..14cb69d0b 100644
--- a/esphome/components/bl0940/bl0940.h
+++ b/esphome/components/bl0940/bl0940.h
@@ -12,8 +12,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/uart/uart.h"
-namespace esphome {
-namespace bl0940 {
+namespace esphome::bl0940 {
// Caveat: All these values are big endian (low - middle - high)
struct DataPacket {
@@ -148,5 +147,4 @@ class BL0940 : public PollingComponent, public uart::UARTDevice {
void recalibrate_();
};
-} // namespace bl0940
-} // namespace esphome
+} // namespace esphome::bl0940
diff --git a/esphome/components/bl0940/button/calibration_reset_button.cpp b/esphome/components/bl0940/button/calibration_reset_button.cpp
index 79a6b872d..a0b41b9a5 100644
--- a/esphome/components/bl0940/button/calibration_reset_button.cpp
+++ b/esphome/components/bl0940/button/calibration_reset_button.cpp
@@ -4,8 +4,7 @@
#include "esphome/core/log.h"
#include "esphome/core/application.h"
-namespace esphome {
-namespace bl0940 {
+namespace esphome::bl0940 {
static const char *const TAG = "bl0940.button.calibration_reset";
@@ -16,5 +15,4 @@ void CalibrationResetButton::press_action() {
this->parent_->reset_calibration();
}
-} // namespace bl0940
-} // namespace esphome
+} // namespace esphome::bl0940
diff --git a/esphome/components/bl0940/button/calibration_reset_button.h b/esphome/components/bl0940/button/calibration_reset_button.h
index 6ea3b35cb..d528992d5 100644
--- a/esphome/components/bl0940/button/calibration_reset_button.h
+++ b/esphome/components/bl0940/button/calibration_reset_button.h
@@ -3,8 +3,7 @@
#include "esphome/core/component.h"
#include "esphome/components/button/button.h"
-namespace esphome {
-namespace bl0940 {
+namespace esphome::bl0940 {
class BL0940; // Forward declaration of BL0940 class
@@ -15,5 +14,4 @@ class CalibrationResetButton : public button::Button, public Component, public P
void press_action() override;
};
-} // namespace bl0940
-} // namespace esphome
+} // namespace esphome::bl0940
diff --git a/esphome/components/bl0940/number/calibration_number.cpp b/esphome/components/bl0940/number/calibration_number.cpp
index 5e775004b..6f054443a 100644
--- a/esphome/components/bl0940/number/calibration_number.cpp
+++ b/esphome/components/bl0940/number/calibration_number.cpp
@@ -1,8 +1,7 @@
#include "calibration_number.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace bl0940 {
+namespace esphome::bl0940 {
static const char *const TAG = "bl0940.number";
@@ -25,5 +24,4 @@ void CalibrationNumber::control(float value) {
void CalibrationNumber::dump_config() { LOG_NUMBER("", "Calibration Number", this); }
-} // namespace bl0940
-} // namespace esphome
+} // namespace esphome::bl0940
diff --git a/esphome/components/bl0940/number/calibration_number.h b/esphome/components/bl0940/number/calibration_number.h
index 3a19e36dc..062890d91 100644
--- a/esphome/components/bl0940/number/calibration_number.h
+++ b/esphome/components/bl0940/number/calibration_number.h
@@ -4,8 +4,7 @@
#include "esphome/core/component.h"
#include "esphome/core/preferences.h"
-namespace esphome {
-namespace bl0940 {
+namespace esphome::bl0940 {
class CalibrationNumber : public number::Number, public Component {
public:
@@ -22,5 +21,4 @@ class CalibrationNumber : public number::Number, public Component {
ESPPreferenceObject pref_;
};
-} // namespace bl0940
-} // namespace esphome
+} // namespace esphome::bl0940
diff --git a/esphome/components/bl0942/bl0942.cpp b/esphome/components/bl0942/bl0942.cpp
index 7d3859742..1c57616c8 100644
--- a/esphome/components/bl0942/bl0942.cpp
+++ b/esphome/components/bl0942/bl0942.cpp
@@ -4,8 +4,7 @@
// Datasheet: https://www.belling.com.cn/media/file_object/bel_product/BL0942/datasheet/BL0942_V1.06_en.pdf
-namespace esphome {
-namespace bl0942 {
+namespace esphome::bl0942 {
static const char *const TAG = "bl0942";
@@ -161,13 +160,9 @@ void BL0942::received_package_(DataPacket *data) {
return;
}
- // cf_cnt is only 24 bits, so track overflows
+ // cf_cnt wraps at 24 bits; total_increasing on the energy sensor handles the
+ // wrap (and any spurious chip resets) downstream.
uint32_t cf_cnt = (uint24_t) data->cf_cnt;
- cf_cnt |= this->prev_cf_cnt_ & 0xff000000;
- if (cf_cnt < this->prev_cf_cnt_) {
- cf_cnt += 0x1000000;
- }
- this->prev_cf_cnt_ = cf_cnt;
float v_rms = (uint24_t) data->v_rms / voltage_reference_;
float i_rms = (uint24_t) data->i_rms / current_reference_;
@@ -214,5 +209,4 @@ void BL0942::dump_config() { // NOLINT(readability-function-cognitive-complexit
LOG_SENSOR("", "Frequency", this->frequency_sensor_);
}
-} // namespace bl0942
-} // namespace esphome
+} // namespace esphome::bl0942
diff --git a/esphome/components/bl0942/bl0942.h b/esphome/components/bl0942/bl0942.h
index 3c013f86e..c36687863 100644
--- a/esphome/components/bl0942/bl0942.h
+++ b/esphome/components/bl0942/bl0942.h
@@ -5,8 +5,7 @@
#include "esphome/components/uart/uart.h"
#include "esphome/components/sensor/sensor.h"
-namespace esphome {
-namespace bl0942 {
+namespace esphome::bl0942 {
// The BL0942 IC is "calibration-free", which means that it doesn't care
// at all about calibration, and that's left to software. It measures a
@@ -141,12 +140,10 @@ class BL0942 : public PollingComponent, public uart::UARTDevice {
bool reset_ = false;
LineFrequency line_freq_ = LINE_FREQUENCY_50HZ;
optional rx_start_{};
- uint32_t prev_cf_cnt_ = 0;
bool validate_checksum_(DataPacket *data);
int read_reg_(uint8_t reg);
void write_reg_(uint8_t reg, uint32_t val);
void received_package_(DataPacket *data);
};
-} // namespace bl0942
-} // namespace esphome
+} // namespace esphome::bl0942
diff --git a/esphome/components/ble_presence/ble_presence_device.cpp b/esphome/components/ble_presence/ble_presence_device.cpp
index e482bb9a7..4a70648ac 100644
--- a/esphome/components/ble_presence/ble_presence_device.cpp
+++ b/esphome/components/ble_presence/ble_presence_device.cpp
@@ -3,14 +3,12 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace ble_presence {
+namespace esphome::ble_presence {
static const char *const TAG = "ble_presence";
void BLEPresenceDevice::dump_config() { LOG_BINARY_SENSOR("", "BLE Presence", this); }
-} // namespace ble_presence
-} // namespace esphome
+} // namespace esphome::ble_presence
#endif
diff --git a/esphome/components/ble_presence/ble_presence_device.h b/esphome/components/ble_presence/ble_presence_device.h
index 8ae5edab3..76e807994 100644
--- a/esphome/components/ble_presence/ble_presence_device.h
+++ b/esphome/components/ble_presence/ble_presence_device.h
@@ -6,8 +6,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace ble_presence {
+namespace esphome::ble_presence {
class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff,
public esp32_ble_tracker::ESPBTDeviceListener,
@@ -137,7 +136,6 @@ class BLEPresenceDevice : public binary_sensor::BinarySensorInitiallyOff,
uint32_t timeout_{};
};
-} // namespace ble_presence
-} // namespace esphome
+} // namespace esphome::ble_presence
#endif
diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.cpp b/esphome/components/ble_rssi/ble_rssi_sensor.cpp
index 4b37fcc6e..f678865f4 100644
--- a/esphome/components/ble_rssi/ble_rssi_sensor.cpp
+++ b/esphome/components/ble_rssi/ble_rssi_sensor.cpp
@@ -3,14 +3,12 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace ble_rssi {
+namespace esphome::ble_rssi {
static const char *const TAG = "ble_rssi";
void BLERSSISensor::dump_config() { LOG_SENSOR("", "BLE RSSI Sensor", this); }
-} // namespace ble_rssi
-} // namespace esphome
+} // namespace esphome::ble_rssi
#endif
diff --git a/esphome/components/ble_rssi/ble_rssi_sensor.h b/esphome/components/ble_rssi/ble_rssi_sensor.h
index 81f21c94d..a876fa51d 100644
--- a/esphome/components/ble_rssi/ble_rssi_sensor.h
+++ b/esphome/components/ble_rssi/ble_rssi_sensor.h
@@ -6,8 +6,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace ble_rssi {
+namespace esphome::ble_rssi {
class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component {
public:
@@ -120,7 +119,6 @@ class BLERSSISensor : public sensor::Sensor, public esp32_ble_tracker::ESPBTDevi
bool check_ibeacon_minor_;
};
-} // namespace ble_rssi
-} // namespace esphome
+} // namespace esphome::ble_rssi
#endif
diff --git a/esphome/components/ble_scanner/ble_scanner.cpp b/esphome/components/ble_scanner/ble_scanner.cpp
index f2cda227b..d85894edc 100644
--- a/esphome/components/ble_scanner/ble_scanner.cpp
+++ b/esphome/components/ble_scanner/ble_scanner.cpp
@@ -3,14 +3,12 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace ble_scanner {
+namespace esphome::ble_scanner {
static const char *const TAG = "ble_scanner";
void BLEScanner::dump_config() { LOG_TEXT_SENSOR("", "BLE Scanner", this); }
-} // namespace ble_scanner
-} // namespace esphome
+} // namespace esphome::ble_scanner
#endif
diff --git a/esphome/components/ble_scanner/ble_scanner.h b/esphome/components/ble_scanner/ble_scanner.h
index c6d7f24cc..c2d48741b 100644
--- a/esphome/components/ble_scanner/ble_scanner.h
+++ b/esphome/components/ble_scanner/ble_scanner.h
@@ -10,8 +10,7 @@
#ifdef USE_ESP32
-namespace esphome {
-namespace ble_scanner {
+namespace esphome::ble_scanner {
class BLEScanner : public text_sensor::TextSensor, public esp32_ble_tracker::ESPBTDeviceListener, public Component {
public:
@@ -43,7 +42,6 @@ class BLEScanner : public text_sensor::TextSensor, public esp32_ble_tracker::ESP
void dump_config() override;
};
-} // namespace ble_scanner
-} // namespace esphome
+} // namespace esphome::ble_scanner
#endif
diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp
index 21573f018..7ba9e61e1 100644
--- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp
+++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp
@@ -135,12 +135,26 @@ void BluetoothConnection::loop() {
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
- if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
- this->send_service_ == DONE_SENDING_SERVICES)) {
+ // Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
+ // 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
+ if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
+ (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
+ this->send_service_ == DONE_SENDING_SERVICES)) {
this->disable_loop();
}
}
+void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
+ // Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
+ // base class. Free the proxy slot, notify the API client, and reset send_service_.
+ // address_ may already be 0 if reset_connection_ ran earlier on this teardown.
+ if (this->address_ == 0) {
+ return;
+ }
+ ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
+ this->reset_connection_(reason);
+}
+
void BluetoothConnection::reset_connection_(esp_err_t reason) {
// Send disconnection notification
this->proxy_->send_device_connection(this->address_, false, 0, reason);
@@ -372,14 +386,6 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
break;
}
- case ESP_GATTC_CLOSE_EVT: {
- ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
- param->close.reason);
- // Now the GATT connection is fully closed and controller resources are freed
- // Safe to mark the connection slot as available
- this->reset_connection_(param->close.reason);
- break;
- }
case ESP_GATTC_OPEN_EVT: {
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->reset_connection_(param->open.status);
diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.h b/esphome/components/bluetooth_proxy/bluetooth_connection.h
index b50ea2d6a..e5600f6af 100644
--- a/esphome/components/bluetooth_proxy/bluetooth_connection.h
+++ b/esphome/components/bluetooth_proxy/bluetooth_connection.h
@@ -33,6 +33,8 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
protected:
friend class BluetoothProxy;
+ void on_disconnect_complete(esp_err_t reason) override;
+
bool supports_efficient_uuids_() const;
void send_service_for_discovery_();
void reset_connection_(esp_err_t reason);
diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
index c69163b1f..ca30aab94 100644
--- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
+++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp
@@ -1,5 +1,6 @@
#include "bluetooth_proxy.h"
+#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
#include "esphome/core/macros.h"
#include "esphome/core/application.h"
@@ -30,19 +31,6 @@ void BluetoothProxy::setup() {
this->configured_scan_active_ = this->parent_->get_scan_active();
this->parent_->add_scanner_state_listener(this);
-
- this->set_interval(100, [this]() {
- if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) {
- this->flush_pending_advertisements_();
- return;
- }
- for (uint8_t i = 0; i < this->connection_count_; i++) {
- auto *connection = this->connections_[i];
- if (connection->get_address() != 0 && !connection->disconnect_pending()) {
- connection->disconnect();
- }
- }
- });
}
void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) {
@@ -133,6 +121,25 @@ void BluetoothProxy::dump_config() {
YESNO(this->active_), this->connection_count_);
}
+void BluetoothProxy::loop() {
+ // Run advertisement flush / connection cleanup every 100ms
+ uint32_t now = App.get_loop_component_start_time();
+ if (now - this->last_advertisement_flush_time_ < 100)
+ return;
+ this->last_advertisement_flush_time_ = now;
+
+ if (api::global_api_server->is_connected() && this->api_connection_ != nullptr) {
+ this->flush_pending_advertisements_();
+ return;
+ }
+ for (uint8_t i = 0; i < this->connection_count_; i++) {
+ auto *connection = this->connections_[i];
+ if (connection->get_address() != 0 && !connection->disconnect_pending()) {
+ connection->disconnect();
+ }
+ }
+}
+
esp32_ble_tracker::AdvertisementParserType BluetoothProxy::get_advertisement_parser_type() {
return esp32_ble_tracker::AdvertisementParserType::RAW_ADVERTISEMENTS;
}
@@ -201,7 +208,6 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest
connection->set_connection_type(espbt::ConnectionType::V3_WITHOUT_CACHE);
this->log_connection_info_(connection, "v3 without cache");
}
- uint64_to_bd_addr(msg.address, connection->remote_bda_);
connection->set_remote_addr_type(static_cast(msg.address_type));
connection->set_state(espbt::ClientState::DISCOVERED);
this->send_connections_free();
diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h
index 6680ab0e8..10449f21f 100644
--- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h
+++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h
@@ -65,6 +65,7 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
bool parse_devices(const esp32_ble::BLEScanResult *scan_results, size_t count) override;
void dump_config() override;
void setup() override;
+ void loop() override;
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
void register_connection(BluetoothConnection *connection) {
@@ -176,6 +177,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
// BLE advertisement batching
api::BluetoothLERawAdvertisementsResponse response_;
+ // Group 3: 4-byte types
+ uint32_t last_advertisement_flush_time_{0};
+
// Pre-allocated response message - always ready to send
api::BluetoothConnectionsFreeResponse connections_free_response_;
diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp
index f31940df1..0f7e42cce 100644
--- a/esphome/components/bme280_base/bme280_base.cpp
+++ b/esphome/components/bme280_base/bme280_base.cpp
@@ -9,8 +9,7 @@
#define BME280_ERROR_WRONG_CHIP_ID "Wrong chip ID or no response"
-namespace esphome {
-namespace bme280_base {
+namespace esphome::bme280_base {
static const char *const TAG = "bme280.sensor";
@@ -355,5 +354,4 @@ uint16_t BME280Component::read_u16_le_(uint8_t a_register) {
}
int16_t BME280Component::read_s16_le_(uint8_t a_register) { return this->read_u16_le_(a_register); }
-} // namespace bme280_base
-} // namespace esphome
+} // namespace esphome::bme280_base
diff --git a/esphome/components/bme280_base/bme280_base.h b/esphome/components/bme280_base/bme280_base.h
index 00781d05b..7fe5f7401 100644
--- a/esphome/components/bme280_base/bme280_base.h
+++ b/esphome/components/bme280_base/bme280_base.h
@@ -3,8 +3,7 @@
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
-namespace esphome {
-namespace bme280_base {
+namespace esphome::bme280_base {
/// Internal struct storing the calibration values of an BME280.
struct BME280CalibrationData {
@@ -109,5 +108,4 @@ class BME280Component : public PollingComponent {
} error_code_{NONE};
};
-} // namespace bme280_base
-} // namespace esphome
+} // namespace esphome::bme280_base
diff --git a/esphome/components/bme280_i2c/bme280_i2c.cpp b/esphome/components/bme280_i2c/bme280_i2c.cpp
index e29675b5b..f4380b0d3 100644
--- a/esphome/components/bme280_i2c/bme280_i2c.cpp
+++ b/esphome/components/bme280_i2c/bme280_i2c.cpp
@@ -5,8 +5,7 @@
#include "esphome/components/i2c/i2c.h"
#include "../bme280_base/bme280_base.h"
-namespace esphome {
-namespace bme280_i2c {
+namespace esphome::bme280_i2c {
bool BME280I2CComponent::read_byte(uint8_t a_register, uint8_t *data) {
return I2CDevice::read_byte(a_register, data);
@@ -26,5 +25,4 @@ void BME280I2CComponent::dump_config() {
BME280Component::dump_config();
}
-} // namespace bme280_i2c
-} // namespace esphome
+} // namespace esphome::bme280_i2c
diff --git a/esphome/components/bme280_i2c/bme280_i2c.h b/esphome/components/bme280_i2c/bme280_i2c.h
index c5e2f7e34..ad4a283fc 100644
--- a/esphome/components/bme280_i2c/bme280_i2c.h
+++ b/esphome/components/bme280_i2c/bme280_i2c.h
@@ -3,8 +3,7 @@
#include "esphome/components/bme280_base/bme280_base.h"
#include "esphome/components/i2c/i2c.h"
-namespace esphome {
-namespace bme280_i2c {
+namespace esphome::bme280_i2c {
static const char *const TAG = "bme280_i2c.sensor";
@@ -16,5 +15,4 @@ class BME280I2CComponent : public esphome::bme280_base::BME280Component, public
void dump_config() override;
};
-} // namespace bme280_i2c
-} // namespace esphome
+} // namespace esphome::bme280_i2c
diff --git a/esphome/components/bme280_spi/bme280_spi.cpp b/esphome/components/bme280_spi/bme280_spi.cpp
index c6ebfdfd0..a4e7b7d95 100644
--- a/esphome/components/bme280_spi/bme280_spi.cpp
+++ b/esphome/components/bme280_spi/bme280_spi.cpp
@@ -4,8 +4,7 @@
#include "bme280_spi.h"
#include
-namespace esphome {
-namespace bme280_spi {
+namespace esphome::bme280_spi {
uint8_t set_bit(uint8_t num, int position) {
int mask = 1 << position;
@@ -61,5 +60,4 @@ bool BME280SPIComponent::read_byte_16(uint8_t a_register, uint16_t *data) {
return true;
}
-} // namespace bme280_spi
-} // namespace esphome
+} // namespace esphome::bme280_spi
diff --git a/esphome/components/bme280_spi/bme280_spi.h b/esphome/components/bme280_spi/bme280_spi.h
index b6b8997fa..4e842e959 100644
--- a/esphome/components/bme280_spi/bme280_spi.h
+++ b/esphome/components/bme280_spi/bme280_spi.h
@@ -3,8 +3,7 @@
#include "esphome/components/bme280_base/bme280_base.h"
#include "esphome/components/spi/spi.h"
-namespace esphome {
-namespace bme280_spi {
+namespace esphome::bme280_spi {
class BME280SPIComponent : public esphome::bme280_base::BME280Component,
public spi::SPIDeviceread_bytes(BME680_REGISTER_COEFF1, cal1, 25)) {
+ uint8_t coeff1[25];
+ if (!this->read_bytes(BME680_REGISTER_COEFF1, coeff1, 25)) {
this->mark_failed();
return;
}
- uint8_t cal2[16];
- if (!this->read_bytes(BME680_REGISTER_COEFF2, cal2, 16)) {
+ uint8_t coeff2[16];
+ if (!this->read_bytes(BME680_REGISTER_COEFF2, coeff2, 16)) {
this->mark_failed();
return;
}
- this->calibration_.t1 = cal2[9] << 8 | cal2[8];
- this->calibration_.t2 = cal1[2] << 8 | cal1[1];
- this->calibration_.t3 = cal1[3];
+ this->calibration_.t1 = coeff2[9] << 8 | coeff2[8];
+ this->calibration_.t2 = coeff1[2] << 8 | coeff1[1];
+ this->calibration_.t3 = coeff1[3];
- this->calibration_.h1 = cal2[2] << 4 | (cal2[1] & 0x0F);
- this->calibration_.h2 = cal2[0] << 4 | cal2[1] >> 4;
- this->calibration_.h3 = cal2[3];
- this->calibration_.h4 = cal2[4];
- this->calibration_.h5 = cal2[5];
- this->calibration_.h6 = cal2[6];
- this->calibration_.h7 = cal2[7];
+ this->calibration_.h1 = coeff2[2] << 4 | (coeff2[1] & 0x0F);
+ this->calibration_.h2 = coeff2[0] << 4 | coeff2[1] >> 4;
+ this->calibration_.h3 = coeff2[3];
+ this->calibration_.h4 = coeff2[4];
+ this->calibration_.h5 = coeff2[5];
+ this->calibration_.h6 = coeff2[6];
+ this->calibration_.h7 = coeff2[7];
- this->calibration_.p1 = cal1[6] << 8 | cal1[5];
- this->calibration_.p2 = cal1[8] << 8 | cal1[7];
- this->calibration_.p3 = cal1[9];
- this->calibration_.p4 = cal1[12] << 8 | cal1[11];
- this->calibration_.p5 = cal1[14] << 8 | cal1[13];
- this->calibration_.p6 = cal1[16];
- this->calibration_.p7 = cal1[15];
- this->calibration_.p8 = cal1[20] << 8 | cal1[19];
- this->calibration_.p9 = cal1[22] << 8 | cal1[21];
- this->calibration_.p10 = cal1[23];
+ this->calibration_.p1 = coeff1[6] << 8 | coeff1[5];
+ this->calibration_.p2 = coeff1[8] << 8 | coeff1[7];
+ this->calibration_.p3 = coeff1[9];
+ this->calibration_.p4 = coeff1[12] << 8 | coeff1[11];
+ this->calibration_.p5 = coeff1[14] << 8 | coeff1[13];
+ this->calibration_.p6 = coeff1[16];
+ this->calibration_.p7 = coeff1[15];
+ this->calibration_.p8 = coeff1[20] << 8 | coeff1[19];
+ this->calibration_.p9 = coeff1[22] << 8 | coeff1[21];
+ this->calibration_.p10 = coeff1[23];
- this->calibration_.gh1 = cal2[14];
- this->calibration_.gh2 = cal2[12] << 8 | cal2[13];
- this->calibration_.gh3 = cal2[15];
+ this->calibration_.gh1 = coeff2[14];
+ this->calibration_.gh2 = coeff2[12] << 8 | coeff2[13];
+ this->calibration_.gh3 = coeff2[15];
uint8_t temp_var = 0;
if (!this->read_byte(0x02, &temp_var)) {
@@ -508,5 +507,4 @@ void BME680Component::set_heater(uint16_t heater_temperature, uint16_t heater_du
this->heater_duration_ = heater_duration;
}
-} // namespace bme680
-} // namespace esphome
+} // namespace esphome::bme680
diff --git a/esphome/components/bme680/bme680.h b/esphome/components/bme680/bme680.h
index 239823fa8..e40daf872 100644
--- a/esphome/components/bme680/bme680.h
+++ b/esphome/components/bme680/bme680.h
@@ -4,8 +4,7 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
-namespace esphome {
-namespace bme680 {
+namespace esphome::bme680 {
/// Enum listing all IIR Filter options for the BME680.
enum BME680IIRFilter {
@@ -134,5 +133,4 @@ class BME680Component : public PollingComponent, public i2c::I2CDevice {
sensor::Sensor *gas_resistance_sensor_{nullptr};
};
-} // namespace bme680
-} // namespace esphome
+} // namespace esphome::bme680
diff --git a/esphome/components/bme680_bsec/bme680_bsec.cpp b/esphome/components/bme680_bsec/bme680_bsec.cpp
index bb0417b82..823f32c44 100644
--- a/esphome/components/bme680_bsec/bme680_bsec.cpp
+++ b/esphome/components/bme680_bsec/bme680_bsec.cpp
@@ -3,8 +3,7 @@
#include "esphome/core/log.h"
#include
-namespace esphome {
-namespace bme680_bsec {
+namespace esphome::bme680_bsec {
#ifdef USE_BSEC
static const char *const TAG = "bme680_bsec.sensor";
@@ -162,7 +161,7 @@ void BME680BSECComponent::dump_config() {
" IAQ Mode: %s\n"
" Supply Voltage: %sV\n"
" Sample Rate: %s\n"
- " State Save Interval: %ims",
+ " State Save Interval: %" PRIu32 "ms",
this->temperature_offset_, this->iaq_mode_ == IAQ_MODE_STATIC ? "Static" : "Mobile",
this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? "3.3" : "1.8",
BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_);
@@ -462,7 +461,7 @@ int8_t BME680BSECComponent::write_bytes_wrapper(uint8_t devid, uint8_t a_registe
}
void BME680BSECComponent::delay_ms(uint32_t period) {
- ESP_LOGV(TAG, "Delaying for %ums", period);
+ ESP_LOGV(TAG, "Delaying for %" PRIu32 "ms", period);
delay(period);
}
@@ -565,5 +564,4 @@ void BME680BSECComponent::save_state_(uint8_t accuracy) {
ESP_LOGI(TAG, "Saved state");
}
#endif
-} // namespace bme680_bsec
-} // namespace esphome
+} // namespace esphome::bme680_bsec
diff --git a/esphome/components/bme680_bsec/bme680_bsec.h b/esphome/components/bme680_bsec/bme680_bsec.h
index 22aa2789e..742b07b59 100644
--- a/esphome/components/bme680_bsec/bme680_bsec.h
+++ b/esphome/components/bme680_bsec/bme680_bsec.h
@@ -13,8 +13,7 @@
#include