diff --git a/.clang-tidy b/.clang-tidy
index 5878028f48..6dab84fbd9 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
deleted file mode 100644
index ab526134f8..0000000000
--- a/.clang-tidy.hash
+++ /dev/null
@@ -1 +0,0 @@
-dc8ad5472d9fb44ce1ca29a0601afd65705642799a2819704dfc8459fbaf9815
diff --git a/.claude/skills/pr-workflow/SKILL.md b/.claude/skills/pr-workflow/SKILL.md
index 4ec2551804..2c529dcd0f 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/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 51e2232d24..6f7e892284 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,4 +1,4 @@
-ARG BUILD_BASE_VERSION=2025.04.0
+ARG BUILD_BASE_VERSION=2026.06.1
FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 5a7a02a266..9181275269 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -12,10 +12,9 @@
"--privileged",
"-e",
"GIT_EDITOR=code --wait"
- // uncomment and edit the path in order to pass though local USB serial to the conatiner
+ // uncomment and edit the path in order to pass through local USB serial to the container
// , "--device=/dev/ttyACM0"
],
- "appPort": 6052,
// if you are using avahi in the host device, uncomment these to allow the
// devcontainer to find devices via mdns
//"mounts": [
@@ -41,7 +40,11 @@
],
"settings": {
"python.languageServer": "Pylance",
- "python.pythonPath": "/usr/bin/python3",
+ // Use the container's pre-provisioned venv (built by the Dockerfile, outside the
+ // bind-mounted workspace) rather than a ./venv that may leak in from the host and
+ // mismatch the container's Python. See .devcontainer/Dockerfile (esphome-venv).
+ "python.defaultInterpreterPath": "/home/esphome/.local/esphome-venv/bin/python",
+ "python.terminal.activateEnvironment": true,
"pylint.args": [
"--rcfile=${workspaceFolder}/pyproject.toml"
],
diff --git a/.dockerignore b/.dockerignore
index ccd466d8cb..d6fb5e82ae 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -115,4 +115,4 @@ examples/
Dockerfile
.git/
tests/
-.*
+.?*
diff --git a/.gitattributes b/.gitattributes
index 1b3fd332b4..8171cd910f 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1,3 +1,5 @@
# Normalize line endings to LF in the repository
* text eol=lf
*.png binary
+*.gif binary
+*.apng binary
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 19f52349a6..3b39d519c4 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 72013e411e..08def88577 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 52d72544d3..133d7ca8d8 100644
--- a/.github/actions/build-image/action.yaml
+++ b/.github/actions/build-image/action.yaml
@@ -15,11 +15,6 @@ inputs:
description: "Version to build"
required: true
example: "2023.12.0"
- base_os:
- description: "Base OS to use"
- required: false
- default: "debian"
- example: "debian"
runs:
using: "composite"
steps:
@@ -47,7 +42,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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -60,7 +55,6 @@ runs:
build-args: |
BUILD_TYPE=${{ inputs.build_type }}
BUILD_VERSION=${{ inputs.version }}
- BUILD_OS=${{ inputs.base_os }}
outputs: |
type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
@@ -73,7 +67,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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -86,7 +80,6 @@ runs:
build-args: |
BUILD_TYPE=${{ inputs.build_type }}
BUILD_VERSION=${{ inputs.version }}
- BUILD_OS=${{ inputs.base_os }}
outputs: |
type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml
new file mode 100644
index 0000000000..f566ba4c43
--- /dev/null
+++ b/.github/actions/cache-esp-idf/action.yml
@@ -0,0 +1,52 @@
+name: Cache ESP-IDF
+description: >
+ Resolve the pinned ESP-IDF version and cache the native ESP-IDF install
+ (toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF
+ natively (clang-tidy for IDF/Arduino and the component test batches) shares
+ one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS
+ defaults to "all", so all toolchains are present regardless of the chip).
+ Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the
+ Python venv already restored.
+inputs:
+ framework:
+ description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".'
+ default: espidf
+ restore-only:
+ description: >
+ When "true", only restore -- never save the cache, even on dev. Use from
+ jobs that may not produce an ESP-IDF install (e.g. a component batch with
+ no esp32 target), so a partial/empty install is never written to the key.
+ default: "false"
+runs:
+ using: composite
+ steps:
+ - name: Resolve ESP-IDF version for cache key
+ # The native-IDF version is pinned in code, not in any file that feeds the
+ # other cache keys, so resolve it explicitly. Keying on it means the cache
+ # invalidates on a version bump (actions/cache never overwrites a key).
+ id: version
+ shell: bash
+ run: |
+ . venv/bin/activate
+ if [ "${{ inputs.framework }}" = "arduino" ]; then
+ version=$(python -c 'from esphome.components.esp32 import ARDUINO_FRAMEWORK_VERSION_LOOKUP as A, ARDUINO_IDF_VERSION_LOOKUP as L; print(L[A["recommended"]])')
+ else
+ version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])')
+ fi
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ # Mirror the adjacent PlatformIO cache: only dev-branch runs write the
+ # shared cache (so it lives in the default-branch scope readable by all
+ # PRs), and PRs are restore-only -- they never push multi-GB artifacts into
+ # their own scope / the repo quota (e.g. on a version-bump PR).
+ - name: Cache ESP-IDF install (write on dev)
+ if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ~/.esphome-idf
+ key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
+ - name: Cache ESP-IDF install (restore-only off dev)
+ if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ~/.esphome-idf
+ key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
diff --git a/.github/actions/cache-sdk-nrf/action.yml b/.github/actions/cache-sdk-nrf/action.yml
new file mode 100644
index 0000000000..6cbb87cc66
--- /dev/null
+++ b/.github/actions/cache-sdk-nrf/action.yml
@@ -0,0 +1,49 @@
+name: Cache nRF Connect SDK
+description: >
+ Resolve the pinned sdk-nrf version and cache the native sdk-nrf install
+ (west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf.
+ Every job that installs sdk-nrf natively (the nrf52 clang-tidy job and,
+ once the component tests build natively, their batches) shares one cache.
+ Callers must set env ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf and have
+ the Python venv already restored.
+inputs:
+ restore-only:
+ description: >
+ When "true", only restore -- never save the cache, even on dev. Use from
+ jobs that may not produce a complete install (e.g. a component batch
+ that fails mid-install), so a partial install is never written.
+ default: "false"
+runs:
+ using: composite
+ steps:
+ - name: Resolve sdk-nrf and toolchain versions for cache key
+ # Both versions are pinned in code, not in any file that feeds the
+ # other cache keys, so resolve them explicitly. Keying on them means
+ # the cache invalidates when either is bumped (actions/cache never
+ # overwrites a key).
+ id: version
+ shell: bash
+ run: |
+ . venv/bin/activate
+ version=$(python -c '
+ from esphome.components.nrf52 import RECOMMENDED_SDK_NRF_VERSION
+ from esphome.components.nrf52.framework import TOOLCHAIN_VERSION
+ print(f"{RECOMMENDED_SDK_NRF_VERSION}-{TOOLCHAIN_VERSION}")')
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ # Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it
+ # lives in the default-branch scope readable by all PRs); PRs are
+ # restore-only and never push multi-GB artifacts into their own scope.
+ - name: Cache nRF Connect SDK install (write on dev)
+ if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.esphome-sdk-nrf
+ # yamllint disable-line rule:line-length
+ key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }}
+ - name: Cache nRF Connect SDK install (restore-only off dev)
+ if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.esphome-sdk-nrf
+ # yamllint disable-line rule:line-length
+ key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }}
diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml
index 21393f2aba..9d6dc5301c 100644
--- a/.github/actions/restore-python/action.yml
+++ b/.github/actions/restore-python/action.yml
@@ -17,16 +17,31 @@ runs:
steps:
- name: Set up Python ${{ inputs.python-version }}
id: python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ with:
+ enable-cache: true
+ # Pull request saves land in per-PR scopes nothing else can
+ # reuse; dev pushes seed the shared copy instead.
+ save-cache: ${{ github.event_name != 'pull_request' }}
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
shell: bash
@@ -34,8 +49,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 +58,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 a4b2fa310c..be77ac83a1 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 528e69c478..e87939f824 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 e02b450bf0..2938fd923c 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 25c0ba49af..4406370a27 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
@@ -103,19 +147,9 @@ async function detectCoreChanges(changedFiles) {
}
// Strategy: PR size detection
-async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) {
+async function detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) {
const labels = new Set();
- if (totalChanges <= SMALL_PR_THRESHOLD) {
- labels.add('small-pr');
- return labels;
- }
-
- if (totalChanges <= MEDIUM_PR_THRESHOLD) {
- labels.add('medium-pr');
- return labels;
- }
-
const testAdditions = prFiles
.filter(file => file.filename.startsWith('tests/'))
.reduce((sum, file) => sum + (file.additions || 0), 0);
@@ -123,7 +157,24 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChange
.filter(file => file.filename.startsWith('tests/'))
.reduce((sum, file) => sum + (file.deletions || 0), 0);
- const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions);
+ const nonTestAdditions = totalAdditions - testAdditions;
+ const nonTestDeletions = totalDeletions - testDeletions;
+
+ // small/medium count churn (additions + deletions) so a balanced refactor isn't undersized.
+ const nonTestChurn = nonTestAdditions + nonTestDeletions;
+
+ if (nonTestChurn <= SMALL_PR_THRESHOLD) {
+ labels.add('small-pr');
+ return labels;
+ }
+
+ if (nonTestChurn <= MEDIUM_PR_THRESHOLD) {
+ labels.add('medium-pr');
+ return labels;
+ }
+
+ // too-big uses net line delta (additions - deletions), matching the review message in reviews.js.
+ const nonTestChanges = nonTestAdditions - nonTestDeletions;
// Don't add too-big if mega-pr label is already present
if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) {
@@ -300,7 +351,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 +359,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 021e91a9ee..c8bdcfb2f3 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,10 +120,10 @@ 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),
+ detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD),
detectDashboardChanges(changedFiles),
detectGitHubActionsChanges(changedFiles),
detectCodeOwner(github, context, 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 0000000000..401b376db6
--- /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/reviews.js b/.github/scripts/auto-label-pr/reviews.js
index 7ac136515d..e9e848da6f 100644
--- a/.github/scripts/auto-label-pr/reviews.js
+++ b/.github/scripts/auto-label-pr/reviews.js
@@ -41,16 +41,36 @@ function generateReviewMessages(finalLabels, originalLabelCount, deprecatedInfo,
let message = `${TOO_BIG_MARKER}\n### 📦 Pull Request Size\n\n`;
+ message +=
+ `Hey @${prAuthor}, thanks for the contribution! Just a heads up, ` +
+ `this PR is on the large side `;
+
if (tooManyLabels && tooManyChanges) {
- message += `This PR is too large with ${nonTestChanges} line changes (excluding tests) and affects ${originalLabelCount} different components/areas.`;
+ message +=
+ `(${nonTestChanges} line changes excluding tests, across ` +
+ `${originalLabelCount} different components/areas)`;
} else if (tooManyLabels) {
- message += `This PR affects ${originalLabelCount} different components/areas.`;
+ message +=
+ `(it touches ${originalLabelCount} different components/areas)`;
} else {
- message += `This PR is too large with ${nonTestChanges} line changes (excluding tests).`;
+ message += `(${nonTestChanges} line changes excluding tests)`;
}
- message += ` Please consider breaking it down into smaller, focused PRs to make review easier and reduce the risk of conflicts.\n\n`;
- message += `For guidance on breaking down large PRs, see: https://developers.esphome.io/contributing/submitting-your-work/#how-to-approach-large-submissions`;
+ message += `, which makes it harder for maintainers to review.\n\n`;
+ message +=
+ `Smaller, focused PRs tend to be reviewed much faster since they ` +
+ `fit into the short gaps between other maintainer work; large ones ` +
+ `often have to wait for a rare long uninterrupted block of time. ` +
+ `If you can break this up into smaller pieces that can be reviewed ` +
+ `independently, it will almost certainly land faster overall.\n\n`;
+ message +=
+ `Before putting more time in, it's also worth popping into ` +
+ `\`#devs\` on [Discord](https://esphome.io/chat) so we can help ` +
+ `you scope things and flag anything already in flight.\n\n`;
+ message +=
+ `For more details (including how to split the work up), see: ` +
+ `https://developers.esphome.io/contributing/submitting-your-work/` +
+ `#how-to-approach-large-submissions`;
messages.push(message);
}
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 0000000000..aab1827c44
--- /dev/null
+++ b/.github/scripts/auto-label-pr/tests/detectors.test.js
@@ -0,0 +1,223 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const { detectNewPlatforms, detectNewComponents, detectPRSize } = 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);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// detectPRSize
+// ---------------------------------------------------------------------------
+
+describe('detectPRSize', () => {
+ const SMALL = 30;
+ const MEDIUM = 100;
+ const TOO_BIG = 1000;
+
+ function size(prFiles, isMegaPR = false) {
+ const totalAdditions = prFiles.reduce((sum, file) => sum + (file.additions || 0), 0);
+ const totalDeletions = prFiles.reduce((sum, file) => sum + (file.deletions || 0), 0);
+ return detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL, MEDIUM, TOO_BIG);
+ }
+
+ it('counts only non-test changes toward small-pr', async () => {
+ // 10 source + 5000 test lines -> non-test churn of 10 is still small.
+ const labels = await size([
+ { filename: 'esphome/components/foo/foo.cpp', additions: 10, deletions: 0 },
+ { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 },
+ ]);
+ assert.ok(labels.has('small-pr'));
+ assert.equal(labels.size, 1);
+ });
+
+ it('counts additions and deletions as churn (not net delta)', async () => {
+ // A balanced refactor (40 added, 40 removed) is 80 lines of churn -> medium, not small.
+ const labels = await size([
+ { filename: 'esphome/components/foo/foo.cpp', additions: 40, deletions: 40 },
+ ]);
+ assert.ok(labels.has('medium-pr'));
+ assert.equal(labels.size, 1);
+ });
+
+ it('labels medium-pr when non-test changes exceed small threshold', async () => {
+ const labels = await size([
+ { filename: 'esphome/components/foo/foo.cpp', additions: 60, deletions: 0 },
+ { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 },
+ ]);
+ assert.ok(labels.has('medium-pr'));
+ assert.equal(labels.size, 1);
+ });
+
+ it('uses net delta (not churn) for too-big', async () => {
+ // 600 added + 600 removed: 1200 churn (above too-big) but 0 net delta -> not too-big.
+ const labels = await size([
+ { filename: 'esphome/components/foo/foo.cpp', additions: 600, deletions: 600 },
+ ]);
+ assert.equal(labels.size, 0);
+ });
+
+ it('labels too-big when non-test changes exceed the big threshold', async () => {
+ const labels = await size([
+ { filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 },
+ { filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 },
+ ]);
+ assert.ok(labels.has('too-big'));
+ assert.equal(labels.size, 1);
+ });
+
+ it('does not label too-big when mega-pr is set', async () => {
+ const labels = await size([
+ { filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 },
+ ], true);
+ assert.equal(labels.size, 0);
+ });
+
+ it('produces no size label for a large mega-pr in the gap above medium', async () => {
+ // Non-test changes land between MEDIUM and TOO_BIG: not small/medium, and mega-pr suppresses too-big.
+ const labels = await size([
+ { filename: 'esphome/components/foo/foo.cpp', additions: 500, deletions: 0 },
+ ], true);
+ assert.equal(labels.size, 0);
+ });
+});
diff --git a/.github/scripts/detect-tags.js b/.github/scripts/detect-tags.js
index 3933776c61..99caccc2f8 100644
--- a/.github/scripts/detect-tags.js
+++ b/.github/scripts/detect-tags.js
@@ -41,7 +41,6 @@ function hasCoreChanges(changedFiles) {
*/
function hasDashboardChanges(changedFiles) {
return changedFiles.some(file =>
- file.startsWith('esphome/dashboard/') ||
file.startsWith('esphome/components/dashboard_import/')
);
}
diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml
index 0e5ceb9346..d034227ef6 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
@@ -23,14 +24,18 @@ jobs:
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- 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 e5143911d9..58fc83e3f5 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:
@@ -21,11 +21,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
- python-version: "3.11"
+ python-version: "3.12"
+ - name: Set up uv
+ # ``--system`` (below) installs into the setup-python interpreter;
+ # no venv is created or restored by this workflow.
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ with:
+ enable-cache: true
+ # Pull-request-only workflow: a save could never be shared and
+ # would only consume quota.
+ save-cache: "false"
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Install apt dependencies
run: |
@@ -34,7 +47,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
deleted file mode 100644
index 40cdff0cba..0000000000
--- a/.github/workflows/ci-clang-tidy-hash.yml
+++ /dev/null
@@ -1,76 +0,0 @@
-name: Clang-tidy Hash CI
-
-on:
- pull_request:
- paths:
- - ".clang-tidy"
- - "platformio.ini"
- - "requirements_dev.txt"
- - "sdkconfig.defaults"
- - ".clang-tidy.hash"
- - "script/clang_tidy_hash.py"
- - ".github/workflows/ci-clang-tidy-hash.yml"
-
-permissions:
- contents: read
- pull-requests: write
-
-jobs:
- verify-hash:
- name: Verify clang-tidy hash
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
- - name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- with:
- python-version: "3.11"
-
- - name: Verify hash
- run: |
- python script/clang_tidy_hash.py --verify
-
- - if: failure()
- name: Show hash details
- run: |
- python script/clang_tidy_hash.py
- echo "## Job Failed" | tee -a $GITHUB_STEP_SUMMARY
- echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY
- echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY
-
- - if: failure() && github.event.pull_request.head.repo.full_name == github.repository
- name: Request changes
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- await github.rest.pulls.createReview({
- pull_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- event: 'REQUEST_CHANGES',
- body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.'
- })
-
- - if: success() && github.event.pull_request.head.repo.full_name == github.repository
- name: Dismiss review
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- let reviews = await github.rest.pulls.listReviews({
- pull_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo
- });
- for (let review of reviews.data) {
- if (review.user.login === 'github-actions[bot]' && review.state === 'CHANGES_REQUESTED') {
- await github.rest.pulls.dismissReview({
- pull_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- review_id: review.id,
- message: 'Clang-tidy hash now matches configuration.'
- });
- }
- }
diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml
index 4009ac1e17..2740ca76ca 100644
--- a/.github/workflows/ci-docker.yml
+++ b/.github/workflows/ci-docker.yml
@@ -1,29 +1,41 @@
---
name: CI for docker images
-# Only run when docker paths change
+# Only run on PRs that touch the docker image, its build inputs, or any code
+# whose toolchain the compile smoke test exercises (core + target platforms).
on:
- push:
- branches: [dev, beta, release]
- paths:
- - "docker/**"
- - ".github/workflows/ci-docker.yml"
- - "requirements*.txt"
- - "platformio.ini"
- - "script/platformio_install_deps.py"
-
pull_request:
paths:
+ # Docker image and its build inputs.
- "docker/**"
- ".github/workflows/ci-docker.yml"
- "requirements*.txt"
+ - "pyproject.toml"
- "platformio.ini"
+ - "esphome/idf_component.yml"
- "script/platformio_install_deps.py"
+ # Core, build pipeline, toolchain, and target-platform changes can change
+ # how a toolchain is set up or built, so re-run the per-toolchain compile
+ # smoke test when they change.
+ - "esphome/core/**"
+ - "esphome/writer.py"
+ - "esphome/build_gen/**"
+ - "esphome/espidf/**"
+ - "esphome/platformio/**"
+ - "esphome/components/bk72xx/**"
+ - "esphome/components/esp32/**"
+ - "esphome/components/esp8266/**"
+ - "esphome/components/host/**"
+ - "esphome/components/libretiny/**"
+ - "esphome/components/ln882x/**"
+ - "esphome/components/nrf52/**"
+ - "esphome/components/rp2040/**"
+ - "esphome/components/rtl87xx/**"
+ - "esphome/components/zephyr/**"
permissions:
- contents: read
- packages: read
+ contents: read # actions/checkout only
concurrency:
# yamllint disable-line rule:line-length
@@ -34,6 +46,9 @@ jobs:
check-docker:
name: Build docker containers
runs-on: ${{ matrix.os }}
+ permissions:
+ contents: read # actions/checkout to load Dockerfile and build context
+ packages: write # push branch-tagged images to ghcr.io for local testing
strategy:
fail-fast: false
matrix:
@@ -42,23 +57,161 @@ jobs:
- "ha-addon"
- "docker"
# - "lint"
+ outputs:
+ tag: ${{ steps.tag.outputs.tag }}
+ push: ${{ steps.tag.outputs.push }}
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
- python-version: "3.11"
+ python-version: "3.12"
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+ uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- - name: Set TAG
+ - name: Determine tag and whether to push
+ id: tag
run: |
- echo "TAG=check" >> $GITHUB_ENV
+ # Sanitize the branch name into a valid docker tag: replace invalid
+ # characters, ensure the first character is valid (tags must start
+ # with [A-Za-z0-9_]), and cap the length at 128 characters.
+ branch="${{ github.head_ref || github.ref_name }}"
+ tag="${branch//[^a-zA-Z0-9_.-]/-}"
+ case "$tag" in
+ [a-zA-Z0-9_]*) ;;
+ *) tag="pr-${tag}" ;;
+ esac
+ tag="${tag:0:128}"
+ echo "tag=${tag}" >> "$GITHUB_OUTPUT"
+ # Only push branch images for same-repo pull requests. Push events
+ # only fire for dev/beta/release, whose images are owned by the
+ # release pipeline -- never overwrite those from here.
+ if [ "${{ github.event_name }}" = "pull_request" ] \
+ && [ "${{ github.repository }}" = "esphome/esphome" ] \
+ && [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then
+ echo "push=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "push=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Log in to the GitHub container registry
+ if: steps.tag.outputs.push == 'true'
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- name: Run build
run: |
docker/build.py \
- --tag "${TAG}" \
+ --tag "${{ steps.tag.outputs.tag }}" \
--arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \
--build-type "${{ matrix.build_type }}" \
- build
+ --registry ghcr \
+ build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} ${{ (matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker') && '--load' || '' }}
+
+ # The amd64 "docker" image is also loaded locally (above) and handed to
+ # compile-test as an artifact, so the smoke test reuses this build instead
+ # of building the image a second time. Using an artifact (rather than the
+ # pushed image) keeps it working for fork PRs, which never push to ghcr.io.
+ - name: Export image for compile-test
+ if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
+ run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz
+
+ - name: Upload compile-test image artifact
+ if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ # The tar is already gzipped, so upload it as-is. archive: false skips
+ # the redundant zip and makes the file name the artifact name (the
+ # `name` input is ignored in that mode).
+ path: compile-test-image.tar.gz
+ retention-days: 1
+ archive: false
+
+ manifest:
+ name: Push ${{ matrix.build_type }} manifest to ghcr.io
+ needs: [check-docker]
+ if: needs.check-docker.outputs.push == 'true'
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read # actions/checkout to run docker/build.py
+ packages: write # buildx imagetools writes the multi-arch tag to ghcr.io
+ strategy:
+ fail-fast: false
+ matrix:
+ build_type:
+ - "ha-addon"
+ - "docker"
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - name: Set up Python
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ with:
+ python-version: "3.12"
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
+
+ - name: Log in to the GitHub container registry
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create and push manifest
+ run: |
+ docker/build.py \
+ --tag "${{ needs.check-docker.outputs.tag }}" \
+ --build-type "${{ matrix.build_type }}" \
+ --registry ghcr \
+ manifest
+
+ # Smoke-test the built image by compiling one minimal config per target
+ # platform / toolchain. This catches missing system dependencies in the image
+ # that only surface when a given toolchain is downloaded and run. The image is
+ # the amd64 "docker" build produced by check-docker (shared as an artifact).
+ compile-test:
+ name: Compile ${{ matrix.id }}
+ needs: check-docker
+ runs-on: ubuntu-24.04
+ permissions:
+ contents: read # actions/checkout to load the test configs
+ strategy:
+ fail-fast: false
+ # Cap concurrency so this smoke test doesn't hog all the shared runners.
+ max-parallel: 2
+ matrix:
+ # One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
+ # share a toolchain bundle, so esp32 is exercised on the base variant
+ # across the full framework x toolchain cross-product (arduino/esp-idf
+ # framework, each built with the platformio and native esp-idf
+ # toolchains) so both toolchains stay covered regardless of which one is
+ # the default.
+ id:
+ - esp8266-arduino
+ - esp32-arduino-platformio
+ - esp32-arduino-esp-idf
+ - esp32-idf-platformio
+ - esp32-idf-esp-idf
+ - rp2040-arduino
+ - bk72xx-arduino
+ - rtl87xx-arduino
+ - ln882x-arduino
+ - nrf52
+ - host
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - name: Download image artifact
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: compile-test-image.tar.gz
+ - name: Load image
+ run: docker load --input compile-test-image.tar.gz
+ - name: Compile ${{ matrix.id }}
+ run: |
+ docker run --rm \
+ -v "${{ github.workspace }}/docker/test_configs:/config" \
+ "ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
+ compile "${{ matrix.id }}.yaml"
diff --git a/.github/workflows/ci-github-scripts.yml b/.github/workflows/ci-github-scripts.yml
new file mode 100644
index 0000000000..3313ced690
--- /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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - 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 fbcf5ea584..ac0322e2fa 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:
@@ -49,7 +49,7 @@ jobs:
- name: Check out code from base repository
if: steps.pr.outputs.skip != 'true'
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Always check out from the base repository (esphome/esphome), never from forks
# Use the PR's target branch to ensure we run trusted code from the main repo
@@ -60,7 +60,7 @@ jobs:
if: steps.pr.outputs.skip != 'true'
uses: ./.github/actions/restore-python
with:
- python-version: "3.11"
+ python-version: "3.12"
cache-key: ${{ hashFiles('.cache-key') }}
- name: Download memory analysis artifacts
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6aa5b2a547..17b33520a3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,22 +6,14 @@ 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"
- PYUPGRADE_TARGET: "--py311-plus"
+ DEFAULT_PYTHON: "3.12"
+ PYUPGRADE_TARGET: "--py312-plus"
concurrency:
# yamllint disable-line rule:line-length
@@ -36,30 +28,45 @@ jobs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- 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
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ with:
+ enable-cache: true
+ # Pull request saves land in per-PR scopes nothing else can
+ # reuse; dev pushes seed the shared copy instead.
+ save-cache: ${{ github.event_name != 'pull_request' }}
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: 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
@@ -70,7 +77,7 @@ jobs:
if: needs.determine-jobs.outputs.python-linters == 'true'
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -89,9 +96,11 @@ 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
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -106,7 +115,93 @@ jobs:
script/build_codeowners.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
- script/generate-rp2040-boards.py --check
+ script/generate-rp2-boards.py --check
+ script/ci_check_duplicate_test_ids.py
+
+ import-time:
+ name: Check import esphome.__main__ time
+ runs-on: ubuntu-24.04
+ needs:
+ - common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.import-time == 'true'
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+ - name: Check import time against budget and write waterfall HAR
+ run: |
+ . venv/bin/activate
+ script/check_import_time.py --check --har importtime.har
+ - name: Upload waterfall HAR
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: import-time-waterfall
+ path: importtime.har
+ if-no-files-found: ignore
+ retention-days: 14
+
+ device-builder:
+ name: Test downstream esphome/device-builder
+ runs-on: ubuntu-24.04
+ needs:
+ - common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.device-builder == 'true'
+ steps:
+ - name: Check out esphome (this PR)
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ path: esphome
+ - name: Check out esphome/device-builder
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ repository: esphome/device-builder
+ ref: main
+ path: device-builder
+ - name: Set up Python
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
+ with:
+ python-version: "3.13"
+ - name: Set up uv
+ # Mirrors the install shape device-builder's own CI uses
+ # (esphome/device-builder#192): uv replaces pip for the
+ # install step (order-of-magnitude faster on cold boots,
+ # with its own wheel cache). actions/setup-python still
+ # provides the interpreter.
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ with:
+ enable-cache: true
+ # Pull request saves land in per-PR scopes nothing else can
+ # reuse; dev pushes seed the shared copy instead.
+ save-cache: ${{ github.event_name != 'pull_request' }}
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
+ - name: Install device-builder + esphome from PR
+ # Install device-builder with its esphome + test extras
+ # first so its pinned versions of pytest/etc. land, then
+ # overlay the PR's esphome so the downstream tests run
+ # against this PR's Python code. ``--system`` installs into
+ # the runner's Python instead of a venv.
+ run: |
+ uv pip install --system -e './device-builder[esphome,test]'
+ uv pip install --system -e ./esphome
+ - name: Run device-builder pytest
+ # ``-n auto`` runs under pytest-xdist (matches device-builder's
+ # own CI). No ``--cov`` here -- this is purely a downstream
+ # smoke check against this PR's esphome code. ``tests/e2e/slow``
+ # is excluded: those are real multi-minute toolchain compiles
+ # (LibreTiny SDK clone, native ESP-IDF install) that device-builder
+ # runs in its own dedicated jobs, not this smoke check.
+ working-directory: device-builder
+ run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow
pytest:
name: Run pytest
@@ -114,7 +209,7 @@ jobs:
fail-fast: false
matrix:
python-version:
- - "3.11"
+ - "3.12"
- "3.13"
- "3.14"
os:
@@ -132,9 +227,11 @@ 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
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
@@ -147,19 +244,19 @@ 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@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev'
- uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -170,12 +267,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 }}
+ esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }}
+ esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-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,10 +287,11 @@ 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
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Fetch enough history to find the merge base
fetch-depth: 2
@@ -198,7 +301,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Restore components graph cache
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -208,17 +311,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 "esp32-platformio=$(echo "$output" | jq -r '.esp32_platformio')" >> $GITHUB_OUTPUT
+ echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_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,60 +341,73 @@ 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'
- uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .temp/components_graph.json
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
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python 3.13
id: python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.13"
- name: Restore Python virtual environment
id: cache-venv
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ with:
+ enable-cache: true
+ # Pull request saves land in per-PR scopes nothing else can
+ # reuse; dev pushes seed the shared copy instead.
+ save-cache: ${{ github.event_name != 'pull_request' }}
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: 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
@@ -292,7 +418,7 @@ jobs:
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -321,7 +447,7 @@ jobs:
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -339,9 +465,12 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
- uses: CodSpeedHQ/action@db35df748deb45fdef0960669f57d627c1956c30 # v4
+ uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
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:
@@ -353,6 +482,10 @@ jobs:
if: needs.determine-jobs.outputs.clang-tidy == 'true'
env:
GH_TOKEN: ${{ github.token }}
+ # esp32-arduino-tidy installs ESP-IDF natively; share the native IDF cache.
+ ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
+ # nrf52-tidy installs sdk-nrf natively; pin it to a cacheable path.
+ ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy:
fail-fast: false
max-parallel: 2
@@ -363,18 +496,27 @@ jobs:
options: --environment esp8266-arduino-tidy --grep USE_ESP8266
pio_cache_key: tidyesp8266
- id: clang-tidy
- name: Run script/clang-tidy for ESP32 IDF
- options: --environment esp32-idf-tidy --grep USE_ESP_IDF
- pio_cache_key: tidyesp32-idf
+ name: Run script/clang-tidy for ESP32 Arduino
+ options: --environment esp32-arduino-tidy --grep USE_ARDUINO
+ cache_idf: true
- id: clang-tidy
name: Run script/clang-tidy for ZEPHYR
options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52
- pio_cache_key: tidy-zephyr
+ cache_sdk_nrf: true
ignore_errors: false
+ - id: clang-tidy
+ name: Run script/clang-tidy for RP2
+ options: --environment rp2-tidy --grep USE_RP2
+ pio_cache_key: tidyrp2
+ - id: clang-tidy
+ name: Run script/clang-tidy for LibreTiny
+ environments: bk72xx-tidy ln882h-tidy rtl87xxb-tidy rtl87xxc-tidy
+ options: --grep USE_LIBRETINY --grep USE_BK72XX --grep USE_RTL87XX --grep USE_LN882X
+ pio_cache_key: tidylibretiny
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -386,38 +528,44 @@ jobs:
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
- if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
+ - name: Cache ESP-IDF install
+ if: matrix.cache_idf
+ uses: ./.github/actions/cache-esp-idf
+ with:
+ framework: arduino
+
+ - name: Cache nRF Connect SDK install
+ if: matrix.cache_sdk_nrf
+ uses: ./.github/actions/cache-sdk-nrf
+
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json"
echo "::add-matcher::.github/workflows/matchers/clang-tidy.json"
- - name: Run 'pio run --list-targets -e esp32-idf-tidy'
- if: matrix.name == 'Run script/clang-tidy for ESP32 IDF'
- run: |
- . venv/bin/activate
- mkdir -p .temp
- pio run --list-targets -e esp32-idf-tidy
-
- name: Check if full clang-tidy scan needed
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++ or a
+ # clang-tidy-relevant config file changed, or the ci-run-all label
+ # forced --force-all.
+ if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
echo "full_scan=true" >> $GITHUB_OUTPUT
- echo "reason=hash_changed" >> $GITHUB_OUTPUT
+ echo "reason=determine_jobs" >> $GITHUB_OUTPUT
else
echo "full_scan=false" >> $GITHUB_OUTPUT
echo "reason=normal" >> $GITHUB_OUTPUT
@@ -427,11 +575,22 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
- echo "Running FULL clang-tidy scan (hash changed)"
- script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
+ echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
+ changed=""
else
echo "Running clang-tidy on changed files only"
- script/clang-tidy --all-headers --fix --changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
+ changed="--changed"
+ fi
+ if [ -n "${{ matrix.environments }}" ]; then
+ rc=0
+ for env in ${{ matrix.environments }}; do
+ echo "::group::clang-tidy $env"
+ script/clang-tidy --all-headers --fix $changed --environment "$env" ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }} || rc=1
+ echo "::endgroup::"
+ done
+ exit $rc
+ else
+ script/clang-tidy --all-headers --fix $changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
fi
env:
# Also cache libdeps, store them in a ~/.platformio subfolder
@@ -443,7 +602,7 @@ jobs:
if: always()
clang-tidy-nosplit:
- name: Run script/clang-tidy for ESP32 Arduino
+ name: Run script/clang-tidy for ESP32 IDF
runs-on: ubuntu-24.04
needs:
- common
@@ -451,9 +610,11 @@ jobs:
if: needs.determine-jobs.outputs.clang-tidy-mode == 'nosplit'
env:
GH_TOKEN: ${{ github.token }}
+ # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache.
+ ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -464,19 +625,8 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- - name: Cache platformio
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
-
- - name: Cache platformio
- if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
+ - name: Cache ESP-IDF install
+ uses: ./.github/actions/cache-esp-idf
- name: Register problem matchers
run: |
@@ -487,9 +637,12 @@ 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++ or a
+ # clang-tidy-relevant config file changed, or the ci-run-all label
+ # forced --force-all.
+ if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
echo "full_scan=true" >> $GITHUB_OUTPUT
- echo "reason=hash_changed" >> $GITHUB_OUTPUT
+ echo "reason=determine_jobs" >> $GITHUB_OUTPUT
else
echo "full_scan=false" >> $GITHUB_OUTPUT
echo "reason=normal" >> $GITHUB_OUTPUT
@@ -499,11 +652,11 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
- echo "Running FULL clang-tidy scan (hash changed)"
- script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy
+ echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
+ script/clang-tidy --all-headers --fix --environment esp32-idf-tidy
else
echo "Running clang-tidy on changed files only"
- script/clang-tidy --all-headers --fix --changed --environment esp32-arduino-tidy
+ script/clang-tidy --all-headers --fix --changed --environment esp32-idf-tidy
fi
env:
# Also cache libdeps, store them in a ~/.platformio subfolder
@@ -522,27 +675,26 @@ jobs:
if: needs.determine-jobs.outputs.clang-tidy-mode == 'split'
env:
GH_TOKEN: ${{ github.token }}
+ # esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache.
+ ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy:
fail-fast: false
- max-parallel: 2
+ max-parallel: 3
matrix:
include:
- id: clang-tidy
- name: Run script/clang-tidy for ESP32 Arduino 1/4
- options: --environment esp32-arduino-tidy --split-num 4 --split-at 1
+ name: Run script/clang-tidy for ESP32 IDF 1/3
+ options: --environment esp32-idf-tidy --split-num 3 --split-at 1
- id: clang-tidy
- name: Run script/clang-tidy for ESP32 Arduino 2/4
- options: --environment esp32-arduino-tidy --split-num 4 --split-at 2
+ name: Run script/clang-tidy for ESP32 IDF 2/3
+ options: --environment esp32-idf-tidy --split-num 3 --split-at 2
- id: clang-tidy
- name: Run script/clang-tidy for ESP32 Arduino 3/4
- options: --environment esp32-arduino-tidy --split-num 4 --split-at 3
- - id: clang-tidy
- name: Run script/clang-tidy for ESP32 Arduino 4/4
- options: --environment esp32-arduino-tidy --split-num 4 --split-at 4
+ name: Run script/clang-tidy for ESP32 IDF 3/3
+ options: --environment esp32-idf-tidy --split-num 3 --split-at 3
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -553,19 +705,8 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- - name: Cache platformio
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
-
- - name: Cache platformio
- if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
+ - name: Cache ESP-IDF install
+ uses: ./.github/actions/cache-esp-idf
- name: Register problem matchers
run: |
@@ -576,9 +717,12 @@ 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++ or a
+ # clang-tidy-relevant config file changed, or the ci-run-all label
+ # forced --force-all.
+ if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
echo "full_scan=true" >> $GITHUB_OUTPUT
- echo "reason=hash_changed" >> $GITHUB_OUTPUT
+ echo "reason=determine_jobs" >> $GITHUB_OUTPUT
else
echo "full_scan=false" >> $GITHUB_OUTPUT
echo "reason=normal" >> $GITHUB_OUTPUT
@@ -588,7 +732,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"
@@ -602,18 +746,109 @@ jobs:
run: script/ci-suggest-changes
if: always()
+ clang-tidy-esp32-variants:
+ name: ${{ matrix.name }}
+ runs-on: ubuntu-24.04
+ needs:
+ - common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.clang-tidy == 'true'
+ env:
+ GH_TOKEN: ${{ github.token }}
+ # The variant tidy envs install ESP-IDF natively; share the native IDF cache.
+ ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
+ strategy:
+ fail-fast: false
+ max-parallel: 3
+ matrix:
+ include:
+ - id: clang-tidy
+ name: Run script/clang-tidy for ESP32 S3
+ # yamllint disable-line rule:line-length
+ options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
+ - id: clang-tidy
+ name: Run script/clang-tidy for ESP32 P4
+ # P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
+ # so their code paths differ -- lint them under the P4 build too.
+ # yamllint disable-line rule:line-length
+ options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
+ - id: clang-tidy
+ name: Run script/clang-tidy for ESP32 C6
+ # yamllint disable-line rule:line-length
+ options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
+
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ with:
+ # Need history for HEAD~1 to work for checking changed files
+ fetch-depth: 2
+
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+
+ - name: Cache ESP-IDF install
+ uses: ./.github/actions/cache-esp-idf
+
+ - name: Register problem matchers
+ run: |
+ echo "::add-matcher::.github/workflows/matchers/gcc.json"
+ echo "::add-matcher::.github/workflows/matchers/clang-tidy.json"
+
+ - name: Check if full clang-tidy scan needed
+ id: check_full_scan
+ run: |
+ . venv/bin/activate
+ # determine-jobs.clang-tidy-full-scan is true when core C++ or a
+ # clang-tidy-relevant config file changed, or the ci-run-all label
+ # forced --force-all.
+ if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
+ echo "full_scan=true" >> $GITHUB_OUTPUT
+ echo "reason=determine_jobs" >> $GITHUB_OUTPUT
+ else
+ echo "full_scan=false" >> $GITHUB_OUTPUT
+ echo "reason=normal" >> $GITHUB_OUTPUT
+ fi
+
+ - name: Run clang-tidy
+ # Limited variant scan: only the files carrying that variant's code paths
+ # (no --all-headers; the comprehensive esp32-idf pass covers the shared tree).
+ run: |
+ . venv/bin/activate
+ if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
+ echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
+ script/clang-tidy --fix ${{ matrix.options }}
+ else
+ echo "Running clang-tidy on changed files only"
+ script/clang-tidy --fix --changed ${{ matrix.options }}
+ fi
+
+ - name: Suggested changes
+ run: script/ci-suggest-changes
+ if: always()
+
test-build-components-split:
- name: Test components batch (${{ matrix.components }})
+ name: Test components batch (${{ matrix.batch.components }})
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0
+ env:
+ # esp32 component builds use the native ESP-IDF toolchain (default), so
+ # share the tidy jobs' install location -- the restore below lands here.
+ ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
+ # nrf52 component builds install sdk-nrf natively; pin it to the shared
+ # cacheable path so the restore below lands where the build looks.
+ ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf
strategy:
fail-fast: false
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }}
matrix:
- components: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
+ batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
steps:
- name: Show disk space
run: |
@@ -621,21 +856,38 @@ jobs:
df -h
- name: List components
- run: echo ${{ matrix.components }}
+ run: echo ${{ matrix.batch.components }}
- - name: Cache apt packages
- uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
- with:
- packages: libsdl2-dev
- version: 1.0
+ - name: Install apt packages
+ # Not cached: this job is pull-request-only, so a cache save could
+ # never be shared and would only consume quota.
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
+ - name: Cache ESP-IDF install (restore-only)
+ # Only batches whose test platforms include esp32 need the native
+ # ESP-IDF install; never save -- just reuse the shared install the
+ # dev tidy jobs already cached when present.
+ if: matrix.batch.needs_idf
+ uses: ./.github/actions/cache-esp-idf
+ with:
+ restore-only: true
+ - name: Cache nRF Connect SDK install (restore-only)
+ # Only batches whose test platforms include nrf52 need the native
+ # sdk-nrf install; never save -- just reuse the shared install the
+ # dev nrf52 tidy job cached when present.
+ if: matrix.batch.needs_nrf
+ uses: ./.github/actions/cache-sdk-nrf
+ with:
+ restore-only: true
- name: Validate and compile components with intelligent grouping
run: |
. venv/bin/activate
@@ -666,7 +918,7 @@ jobs:
fi
# Convert space-separated components to comma-separated for Python script
- components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',')
+ components_csv=$(echo "${{ matrix.batch.components }}" | tr ' ' ',')
# Only isolate directly changed components when targeting dev branch
# For beta/release branches, group everything for faster CI
@@ -687,7 +939,7 @@ jobs:
fi
echo ""
- # Show disk space before validation (after bind mounts setup)
+ # Show disk space before validation
echo "Disk space before config validation:"
df -h
echo ""
@@ -699,31 +951,153 @@ 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
+
+ - name: Print ccache statistics
+ # esphome stores the cache under the IDF tools path; expand the leading
+ # ~ in ESPHOME_ESP_IDF_PREFIX so ccache reads the dir the build used.
+ run: CCACHE_DIR="${ESPHOME_ESP_IDF_PREFIX/#\~/$HOME}/ccache" ccache -s
+
+ test-esp32-platformio:
+ name: Test esp32 components with PlatformIO
+ runs-on: ubuntu-24.04
+ needs:
+ - common
+ - determine-jobs
+ if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp32-platformio == 'true'
+ env:
+ # Comma-joined subset of the esp32 PlatformIO representative component list,
+ # computed by script/determine-jobs.py (esp32_platformio_components_to_test).
+ # Single source of truth -- the full list lives in
+ # script/determine-jobs.py::ESP32_PLATFORMIO_TEST_COMPONENTS.
+ TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }}
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+
+ - name: Run PlatformIO compile test
+ run: |
+ . venv/bin/activate
+
+ echo "Testing components: $TEST_COMPONENTS"
+ echo ""
+
+ # compile validates config first, so a separate config pass is
+ # redundant for this smoke test. ESP-IDF framework via PlatformIO:
+ python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio
+
+ echo ""
+ echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
+ echo ""
+
+ # Arduino framework via PlatformIO (only components with an esp32-ard test are built):
+ python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio
+
+ pre-commit-seed-cache:
+ name: Seed pre-commit cache
+ runs-on: ubuntu-latest
+ needs:
+ - common
+ # Saves a dev-scoped pre-commit cache that pull request runs can
+ # restore, since pre-commit.ci lite itself never runs on dev pushes.
+ if: github.event_name == 'push' && github.ref == 'refs/heads/dev'
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+ - name: Cache pre-commit environments
+ id: cache-pre-commit
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/pre-commit
+ # Must match the restore key in pre-commit-ci-lite
+ # yamllint disable-line rule:line-length
+ key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
+ - name: Install pre-commit hook environments
+ if: steps.cache-pre-commit.outputs.cache-hit != 'true'
+ run: |
+ python -m pip install pre-commit
+ pre-commit install-hooks
pre-commit-ci-lite:
name: pre-commit.ci lite
runs-on: ubuntu-latest
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
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache
+ # Inlined from esphome/pre-commit-action with a restore-only cache
+ # step: the pre-commit-seed-cache job owns saving this cache, so
+ # pull request runs never write per-PR copies.
+ - name: Restore pre-commit cache
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/pre-commit
+ # Must match the key pre-commit-seed-cache saves
+ # yamllint disable-line rule:line-length
+ key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
+ - name: Run pre-commit
env:
- SKIP: pylint,clang-tidy-hash,ci-custom
+ SKIP: pylint,ci-custom
+ run: |
+ python -m pip install pre-commit
+ pre-commit run --show-diff-on-failure --color=always --all-files
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always()
@@ -741,7 +1115,7 @@ jobs:
skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }}
steps:
- name: Check out target branch
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.base_ref }}
@@ -817,7 +1191,7 @@ jobs:
- name: Restore cached memory analysis
id: cache-memory-analysis
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -841,7 +1215,7 @@ jobs:
- name: Cache platformio
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -883,7 +1257,7 @@ jobs:
- name: Save memory analysis to cache
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
- uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -923,14 +1297,14 @@ jobs:
flash_usage: ${{ steps.extract.outputs.flash_usage }}
steps:
- name: Check out PR branch
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -986,13 +1360,13 @@ 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:
- name: Check out code
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -1035,8 +1409,11 @@ jobs:
- clang-tidy-single
- clang-tidy-nosplit
- clang-tidy-split
+ - clang-tidy-esp32-variants
- determine-jobs
+ - device-builder
- test-build-components-split
+ - test-esp32-platformio
- pre-commit-ci-lite
- memory-impact-target-branch
- memory-impact-pr-branch
@@ -1052,4 +1429,7 @@ jobs:
# 1. The target branch has a build issue independent of this PR
# 2. This PR fixes a build issue on the target branch
# In either case, we only care that the PR branch builds successfully.
- echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result != "failure")'
+ # Every other job must have succeeded or been skipped; a "cancelled" or
+ # "failure" result fails this check so CI is not reported green when the
+ # workflow was cancelled.
+ echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result == "success" or .result == "skipped")'
diff --git a/.github/workflows/close-pr-from-fork-default-branch.yml b/.github/workflows/close-pr-from-fork-default-branch.yml
new file mode 100644
index 0000000000..5180a07180
--- /dev/null
+++ b/.github/workflows/close-pr-from-fork-default-branch.yml
@@ -0,0 +1,72 @@
+name: Close PR From Fork Default Branch
+
+on:
+ # pull_request_target is required so we have permission to comment and close PRs from forks.
+ pull_request_target:
+ types: [opened, reopened]
+
+permissions:
+ 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:
+ name: Close PR opened from fork's default branch
+ runs-on: ubuntu-latest
+ if: >-
+ github.event.pull_request.head.repo.full_name != github.event.pull_request.base.repo.full_name
+ && github.event.pull_request.head.ref == github.event.repository.default_branch
+ steps:
+ - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const { owner, repo } = context.repo;
+ const prNumber = context.payload.pull_request.number;
+ const author = context.payload.pull_request.user.login;
+ const defaultBranch = context.payload.repository.default_branch;
+ const headRepo = context.payload.pull_request.head.repo.full_name;
+
+ const body = [
+ `Hi @${author}, thanks for opening a pull request! :tada:`,
+ ``,
+ `It looks like this PR was opened from the \`${defaultBranch}\` branch of your fork (\`${headRepo}\`), which is the same name as this repository's default branch. Working directly on \`${defaultBranch}\` in your fork causes a few problems:`,
+ ``,
+ `- Your fork's \`${defaultBranch}\` branch will permanently diverge from \`esphome/esphome:${defaultBranch}\`, making it hard to keep your fork up to date.`,
+ `- Any additional commits you push to \`${defaultBranch}\` will be added to this PR, so you can't easily work on multiple changes at once.`,
+ `- Pushing maintainer fixes to your branch is awkward, since it means committing directly to your fork's default branch.`,
+ `- It makes local collaboration painful — \`${defaultBranch}\` in a checkout becomes ambiguous between upstream and your fork, and maintainers end up with naming collisions when fetching your branch.`,
+ ``,
+ `Please re-open this as a new PR from a dedicated feature branch. The usual flow looks like:`,
+ ``,
+ `\`\`\`bash`,
+ `# Make sure your fork's ${defaultBranch} is up to date with upstream`,
+ `git remote add upstream https://github.com/${owner}/${repo}.git # if you haven't already`,
+ `git fetch upstream`,
+ `git checkout ${defaultBranch}`,
+ `git reset --hard upstream/${defaultBranch}`,
+ `git push --force-with-lease origin ${defaultBranch}`,
+ ``,
+ `# Create a new branch for your change and cherry-pick / re-apply your commits there`,
+ `git checkout -b my-feature-branch upstream/${defaultBranch}`,
+ `# ...re-apply your changes, then:`,
+ `git push origin my-feature-branch`,
+ `\`\`\``,
+ ``,
+ `Then open a new pull request from \`my-feature-branch\` into \`${owner}/${repo}:${defaultBranch}\`.`,
+ ``,
+ `Closing this PR for now — sorry for the friction, and thanks again for contributing! :heart:`,
+ ].join('\n');
+
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: prNumber,
+ body,
+ });
+
+ await github.rest.pulls.update({
+ owner,
+ repo,
+ pull_number: prNumber,
+ state: 'closed',
+ });
diff --git a/.github/workflows/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml
index 49653b6fb3..9b1333734e 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:
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml
index 76be6ecd7b..da9c5f63d6 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:
@@ -28,13 +29,24 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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 67f4690ac9..70527a0fa2 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
@@ -54,11 +52,11 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
+ uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
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@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
+ uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml
index 3165b17078..104988d7a5 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 b211c13985..bc892b64e0 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 8806a89748..ec736a2002 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@main
+ permissions:
+ issues: write # issues.lock on closed issues
+ pull-requests: write # issues.lock on closed pull requests
+ uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0
diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml
index 8700996271..2bb6505b74 100644
--- a/.github/workflows/pr-title-check.yml
+++ b/.github/workflows/pr-title-check.yml
@@ -8,15 +8,15 @@ 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:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
@@ -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 35b9e065e1..b63067ab4b 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:
@@ -20,7 +20,7 @@ jobs:
branch_build: ${{ steps.tag.outputs.branch_build }}
deploy_env: ${{ steps.tag.outputs.deploy_env }}
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Get tag
id: tag
# yamllint disable rule:line-length
@@ -57,12 +57,12 @@ 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
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.x"
- name: Build
@@ -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:
@@ -92,22 +92,22 @@ jobs:
os: "ubuntu-24.04-arm"
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
- python-version: "3.11"
+ python-version: "3.12"
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+ uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to docker hub
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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:
@@ -168,7 +168,7 @@ jobs:
- ghcr
- dockerhub
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -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@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.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 ba5c32e016..ef79b2705a 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@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml
index 709342e5ae..d27cc0cbec 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 be1457387d..2f350d09b3 100644
--- a/.github/workflows/sync-device-classes.yml
+++ b/.github/workflows/sync-device-classes.yml
@@ -6,39 +6,91 @@ 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
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Checkout Home Assistant
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: home-assistant/core
path: lib/home-assistant
- name: Setup Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.14"
+ - name: Set up uv
+ # An order of magnitude faster than pip on cold boots, with its
+ # own wheel cache. ``--system`` (below) installs into the
+ # setup-python interpreter so subsequent ``pre-commit`` /
+ # ``script/run-in-env.py`` steps find the deps without a
+ # ``uv run`` prefix.
+ uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ 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 da568d9b83..de3e4fa68e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -141,10 +141,12 @@ tests/.esphome/
sdkconfig.*
!sdkconfig.defaults
+!sdkconfig.defaults.*
.tests/
/components
/managed_components
+/dependencies.lock
api-docs/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index ac4f0049f8..da424f516f 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -6,12 +6,12 @@ ci:
autoupdate_commit_msg: 'pre-commit: autoupdate'
autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit
# Skip hooks that have issues in pre-commit CI environment
- skip: [pylint, clang-tidy-hash]
+ skip: [pylint]
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
- rev: v0.15.10
+ rev: v0.15.15
hooks:
# Run the linter.
- id: ruff
@@ -40,7 +40,7 @@ repos:
rev: v3.21.2
hooks:
- id: pyupgrade
- args: [--py311-plus]
+ args: [--py312-plus]
- repo: https://github.com/adrienverge/yamllint.git
rev: v1.37.1
hooks:
@@ -55,17 +55,11 @@ 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]
- - id: clang-tidy-hash
- name: Update clang-tidy hash
- entry: python script/clang_tidy_hash.py --update-if-changed
- language: python
- files: ^(\.clang-tidy|platformio\.ini|requirements_dev\.txt)$
- pass_filenames: false
- additional_dependencies: []
+ files: ^esphome/.+\.py$
- 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 90%
rename from .ai/instructions.md
rename to AGENTS.md
index 86f554e9ce..75a9cdb2bf 100644
--- a/.ai/instructions.md
+++ b/AGENTS.md
@@ -9,12 +9,12 @@ This document provides essential context for AI models interacting with this pro
## 2. Core Technologies & Stack
-* **Languages:** Python (>=3.11), C++ (gnu++20)
+* **Languages:** Python (>=3.12), C++ (gnu++20)
* **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF.
* **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative.
* **Configuration:** YAML.
* **Key Libraries/Dependencies:**
- * **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `tornado` (for the web server), `aioesphomeapi` (for the native API).
+ * **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `aioesphomeapi` (for the native API).
* **C++:** `ArduinoJson` (for JSON serialization/deserialization), `AsyncMqttClient-esphome` (for MQTT), `ESPAsyncWebServer` (for the web server).
* **Package Manager(s):** `pip` (for Python dependencies), `platformio` (for C++/PlatformIO dependencies).
* **Communication Protocols:** Protobuf (for native API), MQTT, HTTP.
@@ -35,7 +35,6 @@ This document provides essential context for AI models interacting with this pro
2. **Code Generation** (`esphome/codegen.py`, `esphome/cpp_generator.py`): Manages Python to C++ code generation, template processing, and build flag management.
3. **Component System** (`esphome/components/`): Contains modular hardware and software components with platform-specific implementations and dependency management.
4. **Core Framework** (`esphome/core/`): Manages the application lifecycle, hardware abstraction, and component registration.
- 5. **Dashboard** (`esphome/dashboard/`): A web-based interface for device configuration, management, and OTA updates.
* **Platform Support:**
1. **ESP32** (`components/esp32/`): Espressif ESP32 family. Supports multiple variants (Original, C2, C3, C5, C6, H2, P4, S2, S3) with ESP-IDF framework. Arduino framework supports only a subset of the variants (Original, C3, S2, S3).
@@ -59,6 +58,19 @@ This document provides essential context for AI models interacting with this pro
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations
+* **Python Idioms:**
+ * **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
+ ```python
+ # Bad - looks up CONF_BLAH twice
+ if CONF_BLAH in config:
+ cg.add(var.set_blah(config[CONF_BLAH]))
+
+ # Good - single lookup, value bound inline
+ if (blah := config.get(CONF_BLAH)) is not None:
+ cg.add(var.set_blah(blah))
+ ```
+ The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line.
+
* **C++ Field Visibility:**
* **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`.
* **Use `private` for safety-critical cases:** Use `private` visibility when direct field access could introduce bugs or violate invariants:
@@ -398,20 +410,31 @@ 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.
- * **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/`:
+ * **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/`.
+
+ All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`):
```yaml
- # test.esp32-idf.yaml — use packages for buses
+ # test.esp32-idf.yaml — everything included via named packages
packages:
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
-
- <<: !include common.yaml
+ my_component: !include common.yaml
```
```yaml
# common.yaml — component config only, NO bus definitions
@@ -433,7 +456,6 @@ This document provides essential context for AI models interacting with this pro
* **Debug Tools:**
- `esphome config .yaml` to validate configuration.
- `esphome compile .yaml` to compile without uploading.
- - Check the Dashboard for real-time logs.
- Use component-specific debug logging.
* **Common Issues:**
- **Import Errors**: Check component dependencies and `PYTHONPATH`.
@@ -452,7 +474,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.
@@ -635,7 +657,7 @@ This document provides essential context for AI models interacting with this pro
If you need a real-world example, search for components that use `@dataclass` with `CORE.data` in the codebase. Note: Some components may use `TypedDict` for dictionary-based storage; both patterns are acceptable depending on your needs.
**Why this matters:**
- - Module-level globals persist between compilation runs if the dashboard doesn't fork/exec
+ - Module-level globals persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec
- `CORE.data` automatically clears between runs
- Namespacing under `DOMAIN` prevents key collisions between components
- `@dataclass` provides type safety and cleaner attribute access
@@ -671,7 +693,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++):**
@@ -688,3 +710,9 @@ This document provides essential context for AI models interacting with this pro
_LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0")
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
```
+## 9. English Language
+
+The project uses English for non-code content. When drafting documentation, code comments, commit messages,
+PR descriptions, and similar text, avoid technical jargon. Instead, express concepts in plain English,
+using standard technical terms only when required. Ensure the text is readily comprehensible to a wide
+audience, including non-native English speakers.
diff --git a/CLAUDE.md b/CLAUDE.md
index 49e811ff05..47dc3e3d86 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 5b1ae65f1b..b752c9c5ce 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -19,7 +19,6 @@ esphome/components/ac_dimmer/* @glmnet
esphome/components/adc/* @esphome/core
esphome/components/adc128s102/* @DeerMaximum
esphome/components/addressable_light/* @justfalter
-esphome/components/ade7880/* @kpfleming
esphome/components/ade7953/* @angelnu
esphome/components/ade7953_base/* @angelnu
esphome/components/ade7953_i2c/* @angelnu
@@ -28,7 +27,7 @@ esphome/components/ads1118/* @solomondg1
esphome/components/ags10/* @mak-42
esphome/components/aic3204/* @kbx81
esphome/components/airthings_ble/* @jeromelaban
-esphome/components/airthings_wave_base/* @jeromelaban @kpfleming @ncareau
+esphome/components/airthings_wave_base/* @jeromelaban @ncareau
esphome/components/airthings_wave_mini/* @ncareau
esphome/components/airthings_wave_plus/* @jeromelaban @precurse
esphome/components/alarm_control_panel/* @grahambrown11 @hwstar
@@ -56,6 +55,7 @@ esphome/components/audio_adc/* @kbx81
esphome/components/audio_dac/* @kbx81
esphome/components/audio_file/* @kahrendt
esphome/components/audio_file/media_source/* @kahrendt
+esphome/components/audio_http/* @kahrendt
esphome/components/axs15231/* @clydebarrow
esphome/components/b_parasite/* @rbaron
esphome/components/ballu/* @bazuchan
@@ -83,6 +83,7 @@ esphome/components/bme680_bsec/* @trvrnrth
esphome/components/bme68x_bsec2/* @kbx81 @neffs
esphome/components/bme68x_bsec2_i2c/* @kbx81 @neffs
esphome/components/bmi160/* @flaviut
+esphome/components/bmi270/* @clydebarrow
esphome/components/bmp280_base/* @ademuri
esphome/components/bmp280_i2c/* @ademuri
esphome/components/bmp280_spi/* @ademuri
@@ -121,7 +122,9 @@ esphome/components/cover/* @esphome/core
esphome/components/cs5460a/* @balrog-kun
esphome/components/cse7761/* @berfenger
esphome/components/cst226/* @clydebarrow
+esphome/components/cst328/* @latonita
esphome/components/cst816/* @clydebarrow
+esphome/components/cst9220/* @clydebarrow
esphome/components/ct_clamp/* @jesserockz
esphome/components/current_based/* @djwmarcx
esphome/components/dac7678/* @NickB1
@@ -138,10 +141,11 @@ esphome/components/dfplayer/* @glmnet
esphome/components/dfrobot_sen0395/* @niklasweber
esphome/components/dht/* @OttoWinter
esphome/components/display_menu_base/* @numo68
-esphome/components/dlms_meter/* @SimonFischer04
+esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
esphome/components/dps310/* @kbx81
esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds2484/* @mrk-its
+esphome/components/ds248x/* @tomwellnitz
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duty_time/* @dudanov
esphome/components/ee895/* @Stock-M
@@ -184,6 +188,7 @@ esphome/components/ezo_pmp/* @carlos-sarmiento
esphome/components/factory_reset/* @anatoly-savchenkov
esphome/components/fastled_base/* @OttoWinter
esphome/components/feedback/* @ianchi
+esphome/components/file/* @esphome/core
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
esphome/components/font/* @clydebarrow @esphome/core
esphome/components/fs3000/* @kahrendt
@@ -205,6 +210,7 @@ esphome/components/gree/switch/* @nagyrobi
esphome/components/grove_gas_mc_v2/* @YorkshireIoT
esphome/components/grove_tb6612fng/* @max246
esphome/components/growatt_solar/* @leeuwte
+esphome/components/gsl3670/* @clydebarrow
esphome/components/gt911/* @clydebarrow @jesserockz
esphome/components/haier/* @paveldn
esphome/components/haier/binary_sensor/* @paveldn
@@ -265,6 +271,7 @@ esphome/components/integration/* @OttoWinter
esphome/components/internal_temperature/* @Mat931
esphome/components/interval/* @esphome/core
esphome/components/ir_rf_proxy/* @kbx81
+esphome/components/it8951/* @koosoli @limengdu @Passific
esphome/components/jsn_sr04t/* @Mafus1
esphome/components/json/* @esphome/core
esphome/components/kamstrup_kmp/* @cfeenstra1024
@@ -290,6 +297,7 @@ esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
esphome/components/logger/select/* @clydebarrow
esphome/components/lps22/* @nagisa
+esphome/components/lsm6ds/* @clydebarrow
esphome/components/ltr390/* @latonita @sjtrny
esphome/components/ltr501/* @latonita
esphome/components/ltr_als_ps/* @latonita
@@ -346,9 +354,11 @@ 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
+esphome/components/motion/* @esphome/core
esphome/components/mpl3115a2/* @kbickar
esphome/components/mpu6886/* @fabaff
esphome/components/ms8607/* @e28eta
@@ -377,9 +387,11 @@ esphome/components/pca6416a/* @Mat931
esphome/components/pca9554/* @bdraco @clydebarrow @hwstar
esphome/components/pcf85063/* @brogon
esphome/components/pcf8563/* @KoenBreeman
+esphome/components/pcm5122/* @remcom
esphome/components/pi4ioe5v6408/* @jesserockz
esphome/components/pid/* @OttoWinter
esphome/components/pipsolar/* @andreashergert1984
+esphome/components/pixoo/* @jesserockz
esphome/components/pm1006/* @habbie
esphome/components/pm2005/* @andrewjswan
esphome/components/pmsa003i/* @sjtrny
@@ -395,14 +407,17 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81
esphome/components/pn7160_spi/* @jesserockz @kbx81
esphome/components/power_supply/* @esphome/core
esphome/components/preferences/* @esphome/core
+esphome/components/provisioning/* @esphome/core
esphome/components/psram/* @esphome/core
esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston
esphome/components/pvvx_mithermometer/* @pasiz
esphome/components/pylontech/* @functionpointer
+esphome/components/qmi8658/* @clydebarrow
esphome/components/qmp6988/* @andrewpc
esphome/components/qr_code/* @wjtje
esphome/components/qspi_dbi/* @clydebarrow
esphome/components/qwiic_pir/* @kahrendt
+esphome/components/radio_frequency/* @kbx81
esphome/components/radon_eye_ble/* @jeffeb3
esphome/components/radon_eye_rd200/* @jeffeb3
esphome/components/rc522/* @glmnet
@@ -413,7 +428,9 @@ esphome/components/resampler/speaker/* @kahrendt
esphome/components/restart/* @esphome/core
esphome/components/rf_bridge/* @jesserockz
esphome/components/rgbct/* @jesserockz
-esphome/components/rp2040/* @jesserockz
+esphome/components/ring_buffer/* @kahrendt
+esphome/components/router/speaker/* @kahrendt
+esphome/components/rp2/* @jesserockz
esphome/components/rp2040_ble/* @bdraco
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
esphome/components/rp2040_pwm/* @jesserockz
@@ -437,7 +454,12 @@ esphome/components/select/* @esphome/core
esphome/components/sen0321/* @notjj
esphome/components/sen21231/* @shreyaskarnik
esphome/components/sen5x/* @martgras
-esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
+esphome/components/sen6x/* @martgras @mebner86 @tuct
+esphome/components/sendspin/* @kahrendt
+esphome/components/sendspin/media_player/* @kahrendt
+esphome/components/sendspin/media_source/* @kahrendt
+esphome/components/sendspin/sensor/* @kahrendt
+esphome/components/sendspin/text_sensor/* @kahrendt
esphome/components/sensirion_common/* @martgras
esphome/components/sensor/* @esphome/core
esphome/components/serial_proxy/* @kbx81
@@ -484,6 +506,7 @@ esphome/components/ssd1331_base/* @kbx81
esphome/components/ssd1331_spi/* @kbx81
esphome/components/ssd1351_base/* @kbx81
esphome/components/ssd1351_spi/* @kbx81
+esphome/components/st7123/* @miniskipper
esphome/components/st7567_base/* @latonita
esphome/components/st7567_i2c/* @latonita
esphome/components/st7567_spi/* @latonita
@@ -548,6 +571,7 @@ esphome/components/uart/packet_transport/* @clydebarrow
esphome/components/udp/* @clydebarrow
esphome/components/ufire_ec/* @pvizeli
esphome/components/ufire_ise/* @pvizeli
+esphome/components/ufm01/* @ljungqvist
esphome/components/ultrasonic/* @ssieb @swoboda1337
esphome/components/update/* @jesserockz
esphome/components/uponor_smatrix/* @kroimon
@@ -564,6 +588,7 @@ esphome/components/wake_on_lan/* @clydebarrow @willwill2will54
esphome/components/watchdog/* @oarcher
esphome/components/water_heater/* @dhoeben
esphome/components/waveshare_epaper/* @clydebarrow
+esphome/components/waveshare_io_ch32v003/* @latonita
esphome/components/web_server/ota/* @esphome/core
esphome/components/web_server_base/* @esphome/core
esphome/components/web_server_idf/* @dentra
@@ -585,6 +610,7 @@ esphome/components/wk2212_spi/* @DrCoolZic
esphome/components/wl_134/* @hobbypunk90
esphome/components/wts01/* @alepee
esphome/components/x9c/* @EtienneMD
+esphome/components/xdb401/* @RT530
esphome/components/xgzp68xx/* @gcormier
esphome/components/xiaomi_hhccjcy10/* @fariouche
esphome/components/xiaomi_lywsd02mmc/* @juanluss31
@@ -599,6 +625,6 @@ esphome/components/xxtea/* @clydebarrow
esphome/components/zephyr/* @tomaszduda23
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
esphome/components/zhlt01/* @cfeenstra1024
-esphome/components/zigbee/* @tomaszduda23
+esphome/components/zigbee/* @luar123 @tomaszduda23
esphome/components/zio_ultrasonic/* @kahrendt
esphome/components/zwave_proxy/* @kbx81
diff --git a/Doxyfile b/Doxyfile
index 7fce941c9b..3bb08e5b06 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.8.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
diff --git a/GEMINI.md b/GEMINI.md
index 49e811ff05..47dc3e3d86 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/MANIFEST.in b/MANIFEST.in
index e426627e8d..1626261fb6 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c
recursive-include esphome *.py.script
recursive-include esphome *.jinja
recursive-include esphome LICENSE.txt
+recursive-include esphome requirements.txt
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
new file mode 100644
index 0000000000..5816f38176
--- /dev/null
+++ b/THREAT_MODEL.md
@@ -0,0 +1,148 @@
+# ESPHome Threat Model
+
+This document defines the trust boundary for the **ESPHome** repository — the
+Python compiler/CLI and the device firmware it generates — so that real security
+bugs can be told apart from defense-in-depth improvements. It gives contributors,
+reviewers, and security researchers a clear answer to one question:
+**does this issue let an _unauthenticated_ attacker do something they shouldn't?**
+
+Related documents:
+
+- Deployment guidance for operators:
+ https://esphome.io/guides/security_best_practices/
+- The **Device Builder dashboard** (the web UI, its authentication, ingress,
+ Origin/Host gates, and peer-link pairing) lives in a separate repository and
+ has its own threat model. If your report concerns any of that, please read and
+ report there instead:
+ https://github.com/esphome/device-builder/blob/main/docs/THREAT_MODEL.md
+
+## The trust boundary
+
+For this repository there are two trusted inputs by design:
+
+1. **The configuration.** Anyone who can supply or edit a YAML config is trusted
+ (see below).
+2. **Authenticated peers of a running device** — clients holding the device's
+ API encryption key / password, OTA password, or web server credentials.
+
+The security boundary is therefore **unauthenticated network traffic vs. those
+trusted inputs.** A bug that lets an unauthenticated attacker cross it is a
+security bug.
+
+## Config authors are host-equivalent by design
+
+Anyone who can supply or edit a configuration is **trusted with full code
+execution on the host that runs `esphome`**, on purpose. This is what the product
+does, not a flaw. A config author can already, through fully supported features:
+
+- Run arbitrary **Python** at validation/compile time via `external_components:`
+ (and other component-import mechanisms) — ESPHome imports those packages as
+ ordinary Python.
+- Run arbitrary **shell** commands through the compile/validate/flash toolchain
+ that ESPHome invokes as subprocesses.
+- Read and write arbitrary files reachable by the process (e.g. via `!include`,
+ `packages:`, `dashboard_import:`, and generated build output).
+
+Because of this, a malicious config author is equivalent to shell access on the
+host running the build.
+
+## What is *not* a security vulnerability
+
+If exploiting an issue requires the ability to supply or edit configuration, it
+is **not** a vulnerability in ESPHome, because that ability already grants host
+code execution. This explicitly includes, among others:
+
+- Template / expression injection in substitutions or any YAML string value
+ (e.g. Jinja `${...}` evaluation reaching Python internals). This grants no
+ capability a config author lacks.
+- `!include` / `packages:` / `dashboard_import:` reading or fetching content
+ from surprising or remote locations.
+- The validator or compiler crashing or behaving unexpectedly on adversarial
+ YAML.
+- ESPHome running as root in the official container — that is the documented
+ deployment posture, reachable by the same caller through the features above.
+
+These do not warrant a CVE or coordinated disclosure. Hardening in these areas
+(for example, sandboxing template evaluation as least-surprise defense-in-depth)
+is welcome as a normal enhancement PR, framed as cleanliness rather than a
+security fix — not as a vulnerability remediation.
+
+## What we do defend
+
+These *are* security bugs in this repo, and we want to hear about them privately:
+
+- Memory-safety or protocol bugs in the generated **device firmware** that are
+ remotely triggerable over the network (native API, web server, OTA, BLE,
+ captive portal, etc.) **without** valid credentials.
+- Authentication or encryption bypass on the device — reaching API calls, OTA
+ updates, or the web server without the configured key/password.
+- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
+ below their documented guarantees.
+
+## The web server is an open HTTP API by design
+
+The `web_server` component exposes a plain HTTP interface for viewing and
+controlling entities, and, when the `web_server` OTA platform is enabled, for
+uploading firmware at `/update`. Its only access controls are the optional
+`web_server` `auth:` credentials and the network the device sits on.
+
+When `auth:` is not configured, every endpoint is reachable by any client that
+can reach the device. This is intentional; enabling `web_server` without `auth:`
+is choosing an open control surface, in the same way that running native OTA
+without a password leaves OTA open. The API is documented and is meant to be
+called by other devices, scripts, and pages.
+
+As defense-in-depth, the web server checks the `Origin` header on browser requests
+to its entity control and state endpoints: a request whose `Origin` does not match
+the address the device is served on is rejected, and the `allowed_origins` option
+widens that list. This blocks the common "confused deputy" (CSRF) case where a page
+the operator visits drives the device through their browser. It is **not** an
+authentication boundary: it only constrains browsers. Any client that omits the
+`Origin` header — `curl`, scripts, or other non-browser callers on the same
+network — reaches every endpoint exactly as before. The check also does not cover
+the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer`
+validation. The following are therefore **not** vulnerabilities in this repository:
+
+- Requests without an `Origin` header (for example `curl`) reaching the control
+ endpoints, whether or not `web_server` `auth:` is set.
+- Requests from an origin the operator added to `allowed_origins`.
+- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when
+ web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not
+ covered by the `Origin` check; this is the same exposure as running OTA without a
+ password.
+
+The supported defenses are `web_server` `auth:`, protecting OTA (a web password or
+a native OTA password), and keeping devices on a trusted, segmented network. See
+the security best practices guide linked above.
+
+What remains in scope is bypassing `web_server` `auth:` when it *is* configured,
+and any memory-safety or protocol bug in the server reachable without credentials.
+
+This section documents the current design and scope; it is not a judgment that the
+design is optimal or that it will not change.
+
+## Explicitly out of scope
+
+- Local attackers who already have shell access on the host that runs `esphome`.
+- Supply-chain attacks against ESPHome or its dependencies.
+- Operator-supplied hostile YAML (covered above — config authoring is trusted).
+- Attacks that require an already-authenticated device peer (someone who already
+ holds the API key / OTA / web credentials).
+- Access to the device web server or its web OTA endpoint by non-browser clients
+ (those that send no `Origin` header). The web server is an open HTTP API by
+ design (see above); browser cross-origin requests are blocked by default, but the
+ real controls are `web_server` `auth:` and network isolation.
+- Anything in the dashboard / device-builder — report that in its own repository
+ (linked at the top).
+- Deployments where the operator removed protections or exposed credentials. See
+ the security best practices guide:
+ https://esphome.io/guides/security_best_practices/
+
+## Reporting a vulnerability
+
+If you believe you've found an issue that crosses the unauthenticated boundary
+above, please report it privately via GitHub Security Advisories rather than a
+public issue. For issues that require config-write access, please review this
+document first — they are very likely out of scope by design. For dashboard /
+device-builder issues, report against that repository and consult its threat
+model (linked at the top).
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000000..f8afbbde04
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,18 @@
+coverage:
+ status:
+ patch:
+ default:
+ target: 100%
+ threshold: 0%
+ project:
+ default:
+ informational: true
+
+ignore:
+ - "esphome/components/**/*"
+ - "esphome/analyze_memory/**/*"
+ - "tests/integration/**/*"
+
+comment:
+ layout: "reach, diff, flags, files"
+ require_changes: true
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 540d28be7f..f804ebd148 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -1,10 +1,9 @@
ARG BUILD_VERSION=dev
-ARG BUILD_OS=alpine
-ARG BUILD_BASE_VERSION=2025.04.0
+ARG BUILD_BASE_VERSION=2026.06.1
ARG BUILD_TYPE=docker
-FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker
-FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon
+FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker
+FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon
ARG BUILD_TYPE
FROM base-source-${BUILD_TYPE} AS base
@@ -12,16 +11,6 @@ FROM base-source-${BUILD_TYPE} AS base
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)
-RUN if command -v apk > /dev/null; then \
- apk add --no-cache build-base; \
- else \
- apt-get update \
- && apt-get install -y --no-install-recommends build-essential \
- && rm -rf /var/lib/apt/lists/*; \
- fi
-
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
RUN pip install --no-cache-dir -U pip uv==0.10.1
@@ -32,6 +21,9 @@ RUN \
uv pip install --no-cache-dir \
-r /requirements.txt
+# Install the ESPHome Device Builder dashboard.
+RUN uv pip install --no-cache-dir esphome-device-builder==1.6.4
+
RUN \
platformio settings set enable_telemetry No \
&& platformio settings set check_platformio_interval 1000000 \
diff --git a/docker/build.py b/docker/build.py
index 4d093cf88d..475986e905 100755
--- a/docker/build.py
+++ b/docker/build.py
@@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon"
TYPE_LINT = "lint"
TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT]
+REGISTRY_GHCR = "ghcr"
+REGISTRY_DOCKERHUB = "dockerhub"
+REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB]
+
parser = argparse.ArgumentParser()
parser.add_argument(
@@ -34,6 +38,12 @@ parser.add_argument(
parser.add_argument(
"--build-type", choices=TYPES, required=True, help="The type of build to run"
)
+parser.add_argument(
+ "--registry",
+ choices=REGISTRIES,
+ action="append",
+ help="Restrict to specific registries (default: all). May be passed multiple times.",
+)
parser.add_argument(
"--dry-run", action="store_true", help="Don't run any commands, just print them"
)
@@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t
build_parser.add_argument(
"--load", help="Load the docker image locally", action="store_true"
)
+build_parser.add_argument(
+ "--no-cache-to",
+ help="Don't write the build cache (avoids polluting the shared cache)",
+ action="store_true",
+)
manifest_parser = subparsers.add_parser(
"manifest", help="Create a manifest from already pushed images"
)
@@ -95,11 +110,14 @@ def main():
print("Command failed")
sys.exit(1)
+ registries = args.registry or REGISTRIES
+
# detect channel from tag
match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag)
major_minor_version = None
if match is None:
- channel = CHANNEL_DEV
+ # Custom tag (e.g. a branch name) -- push only the tag itself
+ channel = None
elif match.group(2) is None:
major_minor_version = match.group(1)
channel = CHANNEL_RELEASE
@@ -128,11 +146,18 @@ def main():
CHANNEL_DEV: "cache-dev",
CHANNEL_BETA: "cache-beta",
CHANNEL_RELEASE: "cache-latest",
- }[channel]
- cache_img = f"ghcr.io/{params.build_to}:{cache_tag}"
+ }.get(channel, "cache-dev")
+ # Cache images live alongside the pushed images; prefer GHCR when it is
+ # one of the selected registries, otherwise fall back to Docker Hub so a
+ # registry-restricted build doesn't need GHCR auth.
+ cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else ""
+ cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}"
- imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push]
- imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push]
+ imgs = []
+ if REGISTRY_DOCKERHUB in registries:
+ imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push]
+ if REGISTRY_GHCR in registries:
+ imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push]
# 3. build
cmd = [
@@ -155,7 +180,9 @@ def main():
for img in imgs:
cmd += ["--tag", img]
if args.push:
- cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"]
+ cmd += ["--push"]
+ if not args.no_cache_to:
+ cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"]
if args.load:
cmd += ["--load"]
@@ -163,20 +190,22 @@ def main():
elif args.command == "manifest":
manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to
- targets = [f"{manifest}:{tag}" for tag in tags_to_push]
- targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push]
- # 1. Create manifests
+ targets = []
+ if REGISTRY_DOCKERHUB in registries:
+ targets += [f"{manifest}:{tag}" for tag in tags_to_push]
+ if REGISTRY_GHCR in registries:
+ targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push]
+ # Use buildx imagetools (not `docker manifest`) so the per-arch sources,
+ # which buildx pushes as single-platform manifest lists, are combined
+ # and pushed correctly in one step.
for target in targets:
- cmd = ["docker", "manifest", "create", target]
+ cmd = ["docker", "buildx", "imagetools", "create", "--tag", target]
for arch in ARCHS:
src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}"
if target.startswith("ghcr.io"):
src = f"ghcr.io/{src}"
cmd.append(src)
run_command(*cmd)
- # 2. Push manifests
- for target in targets:
- run_command("docker", "manifest", "push", target)
if __name__ == "__main__":
diff --git a/docker/docker_entrypoint.sh b/docker/docker_entrypoint.sh
index 1b9224244c..c88a78f97e 100755
--- a/docker/docker_entrypoint.sh
+++ b/docker/docker_entrypoint.sh
@@ -21,10 +21,23 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
+# Keep the native toolchain installs on the persistent cache root, not the
+# container's ephemeral user cache dir (re-downloaded on every restart).
+export ESPHOME_ESP_IDF_PREFIX="$(dirname "${pio_cache_base}")/idf"
+export ESPHOME_SDK_NRF_PREFIX="$(dirname "${pio_cache_base}")/sdk-nrf"
+
# If /build is mounted, use that as the build path
# otherwise use path in /config (so that builds aren't lost on container restart)
if [[ -d /build ]]; then
export ESPHOME_BUILD_PATH=/build
fi
+# The default CMD is "dashboard /config". Route the dashboard to the new
+# Device Builder, but pass every other subcommand (compile, run, config,
+# logs, ...) straight through to the esphome CLI so direct CLI use keeps working.
+if [[ "$1" == "dashboard" ]]; then
+ shift
+ exec esphome-device-builder "$@"
+fi
+
exec esphome "$@"
diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types b/docker/ha-addon-rootfs/etc/nginx/includes/mime.types
deleted file mode 100644
index 7c7cdef2d1..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/includes/mime.types
+++ /dev/null
@@ -1,96 +0,0 @@
-types {
- text/html html htm shtml;
- text/css css;
- text/xml xml;
- image/gif gif;
- image/jpeg jpeg jpg;
- application/javascript js;
- application/atom+xml atom;
- application/rss+xml rss;
-
- text/mathml mml;
- text/plain txt;
- text/vnd.sun.j2me.app-descriptor jad;
- text/vnd.wap.wml wml;
- text/x-component htc;
-
- image/png png;
- image/svg+xml svg svgz;
- image/tiff tif tiff;
- image/vnd.wap.wbmp wbmp;
- image/webp webp;
- image/x-icon ico;
- image/x-jng jng;
- image/x-ms-bmp bmp;
-
- font/woff woff;
- font/woff2 woff2;
-
- application/java-archive jar war ear;
- application/json json;
- application/mac-binhex40 hqx;
- application/msword doc;
- application/pdf pdf;
- application/postscript ps eps ai;
- application/rtf rtf;
- application/vnd.apple.mpegurl m3u8;
- application/vnd.google-earth.kml+xml kml;
- application/vnd.google-earth.kmz kmz;
- application/vnd.ms-excel xls;
- application/vnd.ms-fontobject eot;
- application/vnd.ms-powerpoint ppt;
- application/vnd.oasis.opendocument.graphics odg;
- application/vnd.oasis.opendocument.presentation odp;
- application/vnd.oasis.opendocument.spreadsheet ods;
- application/vnd.oasis.opendocument.text odt;
- application/vnd.openxmlformats-officedocument.presentationml.presentation
- pptx;
- application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
- xlsx;
- application/vnd.openxmlformats-officedocument.wordprocessingml.document
- docx;
- application/vnd.wap.wmlc wmlc;
- application/x-7z-compressed 7z;
- application/x-cocoa cco;
- application/x-java-archive-diff jardiff;
- application/x-java-jnlp-file jnlp;
- application/x-makeself run;
- application/x-perl pl pm;
- application/x-pilot prc pdb;
- application/x-rar-compressed rar;
- application/x-redhat-package-manager rpm;
- application/x-sea sea;
- application/x-shockwave-flash swf;
- application/x-stuffit sit;
- application/x-tcl tcl tk;
- application/x-x509-ca-cert der pem crt;
- application/x-xpinstall xpi;
- application/xhtml+xml xhtml;
- application/xspf+xml xspf;
- application/zip zip;
-
- application/octet-stream bin exe dll;
- application/octet-stream deb;
- application/octet-stream dmg;
- application/octet-stream iso img;
- application/octet-stream msi msp msm;
-
- audio/midi mid midi kar;
- audio/mpeg mp3;
- audio/ogg ogg;
- audio/x-m4a m4a;
- audio/x-realaudio ra;
-
- video/3gpp 3gpp 3gp;
- video/mp2t ts;
- video/mp4 mp4;
- video/mpeg mpeg mpg;
- video/quicktime mov;
- video/webm webm;
- video/x-flv flv;
- video/x-m4v m4v;
- video/x-mng mng;
- video/x-ms-asf asx asf;
- video/x-ms-wmv wmv;
- video/x-msvideo avi;
-}
diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf
deleted file mode 100644
index a1ebb5079a..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/includes/proxy_params.conf
+++ /dev/null
@@ -1,16 +0,0 @@
-proxy_http_version 1.1;
-proxy_ignore_client_abort off;
-proxy_read_timeout 86400s;
-proxy_redirect off;
-proxy_send_timeout 86400s;
-proxy_max_temp_file_size 0;
-
-proxy_set_header Accept-Encoding "";
-proxy_set_header Connection $connection_upgrade;
-proxy_set_header Host $http_host;
-proxy_set_header Upgrade $http_upgrade;
-proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
-proxy_set_header X-Forwarded-Proto $scheme;
-proxy_set_header X-NginX-Proxy true;
-proxy_set_header X-Real-IP $remote_addr;
-proxy_set_header Authorization "";
diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf
deleted file mode 100644
index debdf83a8c..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/includes/server_params.conf
+++ /dev/null
@@ -1,8 +0,0 @@
-root /dev/null;
-server_name $hostname;
-
-client_max_body_size 512m;
-
-add_header X-Content-Type-Options nosniff;
-add_header X-XSS-Protection "1; mode=block";
-add_header X-Robots-Tag none;
diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf b/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf
deleted file mode 100644
index e6789cbb9b..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/includes/ssl_params.conf
+++ /dev/null
@@ -1,8 +0,0 @@
-ssl_protocols TLSv1.2 TLSv1.3;
-ssl_prefer_server_ciphers off;
-ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
-ssl_session_timeout 10m;
-ssl_session_cache shared:SSL:10m;
-ssl_session_tickets off;
-ssl_stapling on;
-ssl_stapling_verify on;
diff --git a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf b/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf
deleted file mode 100644
index 8e782bdc88..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/includes/upstream.conf
+++ /dev/null
@@ -1,3 +0,0 @@
-upstream esphome {
- server unix:/var/run/esphome.sock;
-}
diff --git a/docker/ha-addon-rootfs/etc/nginx/nginx.conf b/docker/ha-addon-rootfs/etc/nginx/nginx.conf
deleted file mode 100644
index 497427596d..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/nginx.conf
+++ /dev/null
@@ -1,30 +0,0 @@
-daemon off;
-user root;
-pid /var/run/nginx.pid;
-worker_processes 1;
-error_log /proc/1/fd/1 error;
-events {
- worker_connections 1024;
-}
-
-http {
- include /etc/nginx/includes/mime.types;
-
- access_log off;
- default_type application/octet-stream;
- gzip on;
- keepalive_timeout 65;
- sendfile on;
- server_tokens off;
-
- tcp_nodelay on;
- tcp_nopush on;
-
- map $http_upgrade $connection_upgrade {
- default upgrade;
- '' close;
- }
-
- include /etc/nginx/includes/upstream.conf;
- include /etc/nginx/servers/*.conf;
-}
diff --git a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep b/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep
deleted file mode 100644
index 85ad51be5f..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/servers/.gitkeep
+++ /dev/null
@@ -1 +0,0 @@
-Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley)
diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl
deleted file mode 100644
index 4fb0ca3f90..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/templates/direct.gtpl
+++ /dev/null
@@ -1,28 +0,0 @@
-server {
- {{ if not .ssl }}
- listen 6052 default_server;
- {{ else }}
- listen 6052 default_server ssl http2;
- {{ end }}
-
- include /etc/nginx/includes/server_params.conf;
- include /etc/nginx/includes/proxy_params.conf;
-
- {{ if .ssl }}
- include /etc/nginx/includes/ssl_params.conf;
-
- ssl_certificate /ssl/{{ .certfile }};
- ssl_certificate_key /ssl/{{ .keyfile }};
-
- # Redirect http requests to https on the same port.
- # https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/
- error_page 497 https://$http_host$request_uri;
- {{ end }}
-
- # Clear Home Assistant Ingress header
- proxy_set_header X-HA-Ingress "";
-
- location / {
- proxy_pass http://esphome;
- }
-}
diff --git a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl b/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl
deleted file mode 100644
index 105ddde710..0000000000
--- a/docker/ha-addon-rootfs/etc/nginx/templates/ingress.gtpl
+++ /dev/null
@@ -1,18 +0,0 @@
-server {
- listen 127.0.0.1:{{ .port }} default_server;
- listen {{ .interface }}:{{ .port }} default_server;
-
- include /etc/nginx/includes/server_params.conf;
- include /etc/nginx/includes/proxy_params.conf;
-
- # Set Home Assistant Ingress header
- proxy_set_header X-HA-Ingress "YES";
-
- location / {
- allow 172.30.32.2;
- allow 127.0.0.1;
- deny all;
-
- proxy_pass http://esphome;
- }
-}
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run
index 111157d301..bb36cfcdb4 100755
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run
+++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/discovery/run
@@ -16,7 +16,7 @@ fi
port=$(bashio::addon.ingress_port)
-# Wait for NGINX to become available
+# Wait for the ESPHome Device Builder to become available
bashio::net.wait_for "${port}" "127.0.0.1" 300
config=$(\
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish
index 6e0f8fe23a..da450c25f9 100755
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish
+++ b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/esphome/finish
@@ -2,7 +2,7 @@
# shellcheck shell=bash
# ==============================================================================
# Home Assistant Community Add-on: ESPHome
-# Take down the S6 supervision tree when ESPHome dashboard fails
+# Take down the S6 supervision tree when ESPHome Device Builder fails
# ==============================================================================
declare exit_code
readonly exit_code_container=$( /run/s6-linux-init-container-results/exitcode
- fi
- [[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt
-elif [[ "${exit_code_service}" -ne 0 ]]; then
- if [[ "${exit_code_container}" -eq 0 ]]; then
- echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode
- fi
- exec /run/s6/basedir/bin/halt
-fi
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
deleted file mode 100755
index e96991cdd1..0000000000
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run
+++ /dev/null
@@ -1,15 +0,0 @@
-#!/command/with-contenv bashio
-# shellcheck shell=bash
-# ==============================================================================
-# Community Hass.io Add-ons: ESPHome
-# Runs the NGINX proxy
-# ==============================================================================
-
-bashio::log.info "Waiting for ESPHome dashboard to come up..."
-
-while [[ ! -S /var/run/esphome.sock ]]; do
- sleep 0.5
-done
-
-bashio::log.info "Starting NGINX..."
-exec nginx
diff --git a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type b/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type
deleted file mode 100644
index 5883cff0cd..0000000000
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/type
+++ /dev/null
@@ -1 +0,0 @@
-longrun
diff --git a/docker/test_configs/bk72xx-arduino.yaml b/docker/test_configs/bk72xx-arduino.yaml
new file mode 100644
index 0000000000..138aa9e282
--- /dev/null
+++ b/docker/test_configs/bk72xx-arduino.yaml
@@ -0,0 +1,7 @@
+esphome:
+ name: docker-test-bk72xx-arduino
+
+bk72xx:
+ board: generic-bk7231n-qfn32-tuya
+
+logger:
diff --git a/docker/test_configs/esp32-arduino-esp-idf.yaml b/docker/test_configs/esp32-arduino-esp-idf.yaml
new file mode 100644
index 0000000000..fbc68aff0c
--- /dev/null
+++ b/docker/test_configs/esp32-arduino-esp-idf.yaml
@@ -0,0 +1,10 @@
+esphome:
+ name: docker-test-esp32-ard-idf
+
+esp32:
+ variant: esp32
+ framework:
+ type: arduino
+ toolchain: esp-idf
+
+logger:
diff --git a/docker/test_configs/esp32-arduino-platformio.yaml b/docker/test_configs/esp32-arduino-platformio.yaml
new file mode 100644
index 0000000000..e216c02059
--- /dev/null
+++ b/docker/test_configs/esp32-arduino-platformio.yaml
@@ -0,0 +1,10 @@
+esphome:
+ name: docker-test-esp32-ard-pio
+
+esp32:
+ variant: esp32
+ framework:
+ type: arduino
+ toolchain: platformio
+
+logger:
diff --git a/docker/test_configs/esp32-idf-esp-idf.yaml b/docker/test_configs/esp32-idf-esp-idf.yaml
new file mode 100644
index 0000000000..b180aa9c0a
--- /dev/null
+++ b/docker/test_configs/esp32-idf-esp-idf.yaml
@@ -0,0 +1,10 @@
+esphome:
+ name: docker-test-esp32-idf-idf
+
+esp32:
+ variant: esp32
+ framework:
+ type: esp-idf
+ toolchain: esp-idf
+
+logger:
diff --git a/docker/test_configs/esp32-idf-platformio.yaml b/docker/test_configs/esp32-idf-platformio.yaml
new file mode 100644
index 0000000000..5aec23e40d
--- /dev/null
+++ b/docker/test_configs/esp32-idf-platformio.yaml
@@ -0,0 +1,10 @@
+esphome:
+ name: docker-test-esp32-idf-pio
+
+esp32:
+ variant: esp32
+ framework:
+ type: esp-idf
+ toolchain: platformio
+
+logger:
diff --git a/docker/test_configs/esp8266-arduino.yaml b/docker/test_configs/esp8266-arduino.yaml
new file mode 100644
index 0000000000..80b52260e4
--- /dev/null
+++ b/docker/test_configs/esp8266-arduino.yaml
@@ -0,0 +1,7 @@
+esphome:
+ name: docker-test-esp8266-arduino
+
+esp8266:
+ board: d1_mini
+
+logger:
diff --git a/docker/test_configs/host.yaml b/docker/test_configs/host.yaml
new file mode 100644
index 0000000000..9f99069304
--- /dev/null
+++ b/docker/test_configs/host.yaml
@@ -0,0 +1,6 @@
+esphome:
+ name: docker-test-host
+
+host:
+
+logger:
diff --git a/docker/test_configs/ln882x-arduino.yaml b/docker/test_configs/ln882x-arduino.yaml
new file mode 100644
index 0000000000..38e96630ba
--- /dev/null
+++ b/docker/test_configs/ln882x-arduino.yaml
@@ -0,0 +1,7 @@
+esphome:
+ name: docker-test-ln882x-arduino
+
+ln882x:
+ board: generic-ln882h
+
+logger:
diff --git a/docker/test_configs/nrf52.yaml b/docker/test_configs/nrf52.yaml
new file mode 100644
index 0000000000..d6337149cc
--- /dev/null
+++ b/docker/test_configs/nrf52.yaml
@@ -0,0 +1,8 @@
+esphome:
+ name: docker-test-nrf52
+
+nrf52:
+ board: adafruit_itsybitsy_nrf52840
+ bootloader: adafruit_nrf52_sd140_v6
+
+logger:
diff --git a/docker/test_configs/rp2040-arduino.yaml b/docker/test_configs/rp2040-arduino.yaml
new file mode 100644
index 0000000000..4b5df11d87
--- /dev/null
+++ b/docker/test_configs/rp2040-arduino.yaml
@@ -0,0 +1,7 @@
+esphome:
+ name: docker-test-rp2040-arduino
+
+rp2040:
+ variant: rp2040
+
+logger:
diff --git a/docker/test_configs/rtl87xx-arduino.yaml b/docker/test_configs/rtl87xx-arduino.yaml
new file mode 100644
index 0000000000..e8d9cf7503
--- /dev/null
+++ b/docker/test_configs/rtl87xx-arduino.yaml
@@ -0,0 +1,7 @@
+esphome:
+ name: docker-test-rtl87xx-arduino
+
+rtl87xx:
+ board: generic-rtl8710bn-2mb-788k
+
+logger:
diff --git a/esphome/__main__.py b/esphome/__main__.py
index 7879cdad0c..b82e60259f 100644
--- a/esphome/__main__.py
+++ b/esphome/__main__.py
@@ -21,7 +21,7 @@ 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 (
@@ -34,11 +34,13 @@ from esphome.const import (
CONF_DISABLED,
CONF_ESPHOME,
CONF_LEVEL,
+ CONF_LOG,
CONF_LOG_TOPIC,
CONF_LOGGER,
CONF_MDNS,
CONF_MQTT,
CONF_NAME,
+ CONF_NAME_ADD_MAC_SUFFIX,
CONF_OTA,
CONF_PASSWORD,
CONF_PLATFORM,
@@ -46,22 +48,22 @@ from esphome.const import (
CONF_PORT,
CONF_SUBSTITUTIONS,
CONF_TOPIC,
+ CONF_VERSION,
+ 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,6 +74,12 @@ from esphome.util import (
safe_print,
)
+# 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__)
ESPHOME_COMMAND = [sys.executable, "-m", "esphome"]
@@ -144,7 +152,6 @@ class ArgsProtocol(Protocol):
configuration: str
name: str
upload_speed: str | None
- native_idf: bool
def choose_prompt(options, purpose: str = None):
@@ -184,14 +191,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})
@@ -204,6 +203,97 @@ def _resolve_with_cache(address: str, purpose: Purpose) -> list[str]:
return [address]
+def _populate_mdns_cache(hosts_to_addresses: dict[str, list[str]]) -> None:
+ """Store discovered ``host -> [ips]`` entries in ``CORE.address_cache``.
+
+ Ensures ``CORE.address_cache`` exists, then records each mDNS hostname so
+ the downstream resolution path (``resolve_ip_address``) can skip opening a
+ second Zeroconf client.
+ """
+ from esphome.address_cache import AddressCache
+
+ if CORE.address_cache is None:
+ CORE.address_cache = AddressCache()
+ for host, addresses in hosts_to_addresses.items():
+ if addresses:
+ _LOGGER.debug("Caching mDNS result %s -> %s", host, addresses)
+ CORE.address_cache.add_mdns_addresses(host, addresses)
+
+
+def _discover_mac_suffix_devices() -> list[str] | None:
+ """Discover ``-.local`` devices and cache their IPs.
+
+ Returns:
+ - ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
+ mDNS disabled, or ``CORE.address`` isn't a ``.local`` mDNS address).
+ Callers should then fall back to whatever default OTA address they
+ normally use.
+ - ``[]`` when discovery ran but found nothing. Callers should NOT fall
+ back to the base name: with ``name_add_mac_suffix`` enabled, the base
+ name by definition doesn't exist on the network.
+ - A non-empty sorted list of ``.local`` hostnames on success.
+
+ Populates ``CORE.address_cache`` so downstream resolution (``espota2`` or
+ ``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we
+ already have without opening a second Zeroconf client.
+ """
+ if not (has_name_add_mac_suffix() and has_mdns() and has_mdns_address()):
+ return None
+ from esphome.zeroconf import discover_mdns_devices
+
+ _LOGGER.info("Discovering devices...")
+ if not (discovered := discover_mdns_devices(CORE.name)):
+ _LOGGER.warning(
+ "No devices matching '%s-.local' were discovered.", CORE.name
+ )
+ return []
+ _populate_mdns_cache(discovered)
+ return list(discovered)
+
+
+def _ota_hostnames_for_default(purpose: Purpose) -> list[str]:
+ """Return OTA hostname(s) for the ``--device OTA`` / default-resolve path.
+
+ When ``name_add_mac_suffix`` is enabled, returns discovered
+ ``-.local`` hostnames (possibly empty — in which case the
+ caller should not fall back to the base name). Otherwise falls back to
+ the cache-resolved ``CORE.address``.
+ """
+ if (discovered := _discover_mac_suffix_devices()) is not None:
+ return discovered
+ return _resolve_with_cache(CORE.address, purpose)
+
+
+def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str:
+ """Build the error when a default device target produced no usable host.
+
+ When the OTA default was requested and the address resolves but the config
+ lacks the transport the purpose needs (``api:`` for logs, an ``ota:``
+ platform for uploads), name that gap instead of the misleading
+ "could not be resolved" / set-use_address hint.
+ """
+ if "OTA" in defaults and has_resolvable_address():
+ if purpose == Purpose.LOGGING and not has_api():
+ return (
+ "Cannot view logs over the network: no 'api:' component is "
+ "configured. Network log streaming requires the native API; add "
+ "an 'api:' component, enable MQTT logging, or view logs over USB."
+ )
+ if purpose == Purpose.UPLOADING and not has_ota():
+ return (
+ "Cannot upload over the network: no 'ota:' platform is "
+ "configured. Add an 'ota:' platform, or upload over USB."
+ )
+ if CORE.dashboard:
+ hint = "If you know the IP, set 'use_address' in your network config."
+ else:
+ hint = "If you know the IP, try --device "
+ return (
+ f"All specified devices {defaults} could not be resolved. "
+ f"Is the device connected to the network? {hint}"
+ )
+
+
def choose_upload_log_host(
default: list[str] | str | None,
check_default: str | None,
@@ -227,9 +317,12 @@ def choose_upload_log_host(
]
resolved.append(choose_prompt(options, purpose=purpose))
elif device == "OTA":
+ # Logs can stream over a network transport via the native API
+ # or the web_server HTTP SSE feed.
+ network_logging = has_api() or has_web_server_logging()
# ensure IP adresses are used first
if is_ip_address(CORE.address) and (
- (purpose == Purpose.LOGGING and has_api())
+ (purpose == Purpose.LOGGING and network_logging)
or (purpose == Purpose.UPLOADING and has_ota())
):
resolved.extend(_resolve_with_cache(CORE.address, purpose))
@@ -241,26 +334,23 @@ def choose_upload_log_host(
if has_mqtt_logging():
resolved.append("MQTT")
- if has_api() and has_non_ip_address() and has_resolvable_address():
- resolved.extend(_resolve_with_cache(CORE.address, purpose))
+ if (
+ network_logging
+ and has_non_ip_address()
+ and has_resolvable_address()
+ ):
+ resolved.extend(_ota_hostnames_for_default(purpose))
elif purpose == Purpose.UPLOADING:
if has_ota() and has_mqtt_ip_lookup():
resolved.append("MQTTIP")
if has_ota() and has_non_ip_address() and has_resolvable_address():
- resolved.extend(_resolve_with_cache(CORE.address, purpose))
+ resolved.extend(_ota_hostnames_for_default(purpose))
else:
resolved.append(device)
if not resolved:
- if CORE.dashboard:
- hint = "If you know the IP, set 'use_address' in your network config."
- else:
- hint = "If you know the IP, try --device "
- raise EsphomeError(
- f"All specified devices {defaults} could not be resolved. "
- f"Is the device connected to the network? {hint}"
- )
+ raise EsphomeError(_unresolved_default_error(purpose, defaults))
return resolved
# No devices specified, show interactive chooser
@@ -272,7 +362,7 @@ def choose_upload_log_host(
bootsel_permission_error = False
if (
purpose == Purpose.UPLOADING
- and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
+ and CORE.is_rp2
and (picotool := _find_picotool()) is not None
):
bootsel = detect_rp2040_bootsel(picotool)
@@ -281,27 +371,45 @@ 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:
+ # Discovery was applicable. Use whatever we found — on empty,
+ # 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{ota_suffix} ({host})", host))
+ elif has_resolvable_address():
+ options.append((f"Over The Air{ota_suffix} ({CORE.address})", CORE.address))
+ if has_mqtt_ip_lookup():
+ options.append((f"Over The Air{ota_suffix} (MQTT IP lookup)", "MQTTIP"))
+
if purpose == Purpose.LOGGING:
if has_mqtt_logging():
mqtt_config = CORE.config[CONF_MQTT]
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
- if has_api():
- if has_resolvable_address():
- options.append((f"Over The Air ({CORE.address})", CORE.address))
- if has_mqtt_ip_lookup():
- options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
+ if has_api() or has_web_server_logging():
+ add_ota_options()
elif purpose == Purpose.UPLOADING and has_ota():
- if has_resolvable_address():
- options.append((f"Over The Air ({CORE.address})", CORE.address))
- if has_mqtt_ip_lookup():
- options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
+ add_ota_options()
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
if (
purpose == Purpose.UPLOADING
- and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
+ and CORE.is_rp2
and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options)
):
if bootsel_permission_error:
@@ -354,7 +462,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(
@@ -363,6 +483,31 @@ 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_web_server_logging() -> bool:
+ """Check if logs can be streamed over the web_server HTTP SSE endpoint.
+
+ The ``web_server`` component exposes a ``/events`` Server-Sent Events
+ stream that carries ``event: log`` frames. This requires version 2+ (the
+ v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default).
+ """
+ web_conf = CORE.config.get(CONF_WEB_SERVER)
+ if web_conf is None:
+ return False
+ if web_conf.get(CONF_VERSION, 2) == 1:
+ return False
+ return web_conf.get(CONF_LOG, True)
+
+
def has_mqtt_ip_lookup() -> bool:
"""Check if MQTT is available and IP lookup is supported."""
from esphome.components.mqtt import CONF_DISCOVER_IP
@@ -381,17 +526,22 @@ def has_mdns() -> bool:
def has_non_ip_address() -> bool:
- """Check if CORE.address is set and is not an IP address."""
+ """Check if ``CORE.address`` is set and is not an IP address."""
return CORE.address is not None and not is_ip_address(CORE.address)
+def has_mdns_address() -> bool:
+ """Check if ``CORE.address`` is a ``.local`` mDNS hostname."""
+ return CORE.address is not None and CORE.address.endswith(".local")
+
+
def has_ip_address() -> bool:
- """Check if CORE.address is a valid IP address."""
+ """Check if ``CORE.address`` is a valid IP address."""
return CORE.address is not None and is_ip_address(CORE.address)
def has_resolvable_address() -> bool:
- """Check if CORE.address is resolvable (via mDNS, DNS, or is an IP address)."""
+ """Check if ``CORE.address`` is resolvable (via mDNS, DNS, or is an IP address)."""
# Any address (IP, mDNS hostname, or regular DNS hostname) is resolvable
# The resolve_ip_address() function in helpers.py handles all types via AsyncResolver
if CORE.address is None:
@@ -400,14 +550,30 @@ def has_resolvable_address() -> bool:
if has_ip_address():
return True
+ # device-builder pre-resolves the device and passes the IPs via
+ # --mdns-address-cache/--dns-address-cache; honor a cached address even when the
+ # device has mDNS disabled (e.g. a .local host found via ping).
+ if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address):
+ return True
+
if has_mdns():
return True
# .local mDNS hostnames are only resolvable if mDNS is enabled
- return not CORE.address.endswith(".local")
+ return not has_mdns_address()
-def mqtt_get_ip(config: ConfigType, username: str, password: str, client_id: str):
+def has_name_add_mac_suffix() -> bool:
+ """Check if name_add_mac_suffix is enabled in the config."""
+ if CORE.config is None:
+ return False
+ esphome_config = CORE.config.get(CONF_ESPHOME, {})
+ return esphome_config.get(CONF_NAME_ADD_MAC_SUFFIX, False)
+
+
+def mqtt_get_ip(
+ config: ConfigType, username: str, password: str, client_id: str
+) -> list[str]:
from esphome import mqtt
return mqtt.get_esphome_device_ip(config, username, password, client_id)
@@ -420,6 +586,9 @@ def _resolve_network_devices(
This function filters the devices list to:
- Replace MQTT/MQTTIP magic strings with actual IP addresses via MQTT lookup
+ - Expand hostnames that are already in ``CORE.address_cache`` to their
+ cached IPs so downstream code (e.g. aioesphomeapi) doesn't open a second
+ Zeroconf client to resolve them
- Deduplicate addresses while preserving order
- Only resolve MQTT once even if multiple MQTT strings are present
- If MQTT resolution fails, log a warning and continue with other devices
@@ -444,13 +613,29 @@ def _resolve_network_devices(
mqtt_ips = mqtt_get_ip(
config, args.username, args.password, args.client_id
)
- network_devices.extend(mqtt_ips)
+ # pylint can't infer mqtt_get_ip's return through its
+ # lazy ``from esphome import mqtt`` import, so it flags
+ # the genexpr below.
+ network_devices.extend(
+ addr
+ for addr in mqtt_ips # pylint: disable=not-an-iterable
+ if addr not in network_devices
+ )
except EsphomeError as err:
_LOGGER.warning(
"MQTT IP discovery failed (%s), will try other devices if available",
err,
)
mqtt_resolved = True
+ continue
+
+ # If the hostname is already in the address cache (e.g. populated by
+ # mDNS discovery), substitute the cached IPs so aioesphomeapi doesn't
+ # open its own Zeroconf to re-resolve it.
+ if CORE.address_cache and (cached := CORE.address_cache.get_addresses(device)):
+ network_devices.extend(
+ addr for addr in cached if addr not in network_devices
+ )
elif device not in network_devices:
# Regular network address or IP - add if not already present
network_devices.append(device)
@@ -458,33 +643,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
@@ -498,9 +660,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()
@@ -526,7 +691,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}]"
@@ -543,14 +708,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
@@ -564,11 +725,12 @@ 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)
async def wrapped(conf):
+ cg.add(cg.ComponentMarker(name))
cg.add(cg.LineComment(f"{name}:"))
if comp.config_schema is not None:
conf_str = yaml_util.dump(conf)
@@ -583,33 +745,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()
@@ -622,30 +792,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
@@ -672,7 +853,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)
@@ -734,22 +915,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():
@@ -818,13 +1003,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)
+ if CORE.is_rp2:
+ 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"
@@ -834,15 +1019,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)
@@ -855,9 +1040,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():
@@ -930,7 +1115,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:
@@ -975,13 +1160,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,
@@ -992,45 +1193,237 @@ def upload_program(
check_permissions(host)
exit_code = 1
- if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266):
+ if CORE.is_esp32 or CORE.is_esp8266:
file = getattr(args, "file", None)
exit_code = upload_using_esptool(config, host, file, args.upload_speed)
- elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny:
+ elif CORE.is_rp2 or CORE.is_libretiny:
exit_code = upload_using_platformio(config, host)
# else: Unknown target platform, exit_code remains 1
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]:
+ from esphome import web_server_ota
+ from esphome.web_server_helpers import get_web_server_connection
+
+ remote_port, username, password = get_web_server_connection(config)
+ return web_server_ota.run_ota(
+ network_devices, remote_port, username, password, binary
+ )
+
+
+def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int:
+ from esphome import web_server_logs
+ from esphome.web_server_helpers import get_web_server_connection
+
+ port, username, password = get_web_server_connection(config)
+ return web_server_logs.run_logs(network_devices, port, username, password)
+
+
+# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
+# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
+# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
+# 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
@@ -1056,7 +1449,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():
@@ -1066,6 +1459,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
config, args.topic, args.username, args.password, args.client_id
)
+ # Fall back to the web_server HTTP SSE log stream for devices that have
+ # web_server: but no api: (the logging counterpart to web_server OTA).
+ if has_web_server_logging() and (
+ network_devices := _resolve_network_devices(devices, config, args)
+ ):
+ return _show_logs_via_web_server(config, network_devices)
+
raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
@@ -1084,20 +1484,87 @@ def command_wizard(args: ArgsProtocol) -> int | None:
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
- if not CORE.verbose:
+ from esphome import yaml_util
+
+ if getattr(args, "no_defaults", False):
+ user_config = getattr(config, "user_config", None)
+ if user_config is None:
+ _LOGGER.warning(
+ "--no-defaults requested but the user-only config snapshot is "
+ "unavailable; falling back to the validated configuration."
+ )
+ else:
+ config = user_config
+ elif 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()
+ # Track the top-level ``substitutions:`` block. Its keys are arbitrary
+ # user-chosen names with no schema validator, so the ``cv.sensitive(...)``
+ # migration named in the warning can't be applied to them. Their values are
+ # still redacted, but emitting the (unactionable) deprecation warning would
+ # only confuse users.
+ in_substitutions = False
+
+ lines = output.split("\n")
+ for i, line in enumerate(lines):
+ # A non-indented, non-blank line is a top-level key that opens or
+ # closes the substitutions block.
+ if line and not line[0].isspace():
+ in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:")
+ m = _LEGACY_REDACTION_RE.search(line)
+ if m is None:
+ continue
+ if not in_substitutions:
+ unmarked.add(m.group("key"))
+ lines[i] = (
+ f"{line[: m.start()]}{m.group('key')}: "
+ f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
+ )
+ output = "\n".join(lines)
+ 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
@@ -1107,8 +1574,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:
@@ -1118,9 +1584,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.")
@@ -1163,8 +1634,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)
@@ -1172,9 +1642,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)
@@ -1200,10 +1675,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
# After BOOTSEL upload, wait for a new serial port to appear
# so it shows up in the log chooser
- if (
- successful_device is None
- and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
- ):
+ if successful_device is None and CORE.is_rp2:
_wait_for_serial_port(known_ports=pre_upload_ports)
# If exactly one new serial port appeared, use it directly
serial_ports = get_serial_ports()
@@ -1225,6 +1697,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:
@@ -1240,8 +1714,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
@@ -1282,9 +1758,13 @@ def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_dashboard(args: ArgsProtocol) -> int | None:
- from esphome.dashboard import dashboard
-
- return dashboard.start_dashboard(args)
+ raise EsphomeError(
+ "The built-in dashboard has been removed from ESPHome. "
+ "Install and run ESPHome Device Builder instead:\n"
+ " pip install esphome-device-builder\n"
+ " esphome-device-builder\n"
+ "See https://github.com/esphome/device-builder for more information."
+ )
def run_multiple_configs(
@@ -1361,12 +1841,34 @@ def command_update_all(args: ArgsProtocol) -> int | None:
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json
- from esphome import platformio_api
+ if CORE.using_toolchain_esp_idf:
+ # Native ESP-IDF derives idedata from the build's compile_commands.json,
+ # so the configuration must already be compiled.
+ from esphome.espidf import toolchain as espidf_toolchain
+
+ idedata = espidf_toolchain.get_idedata()
+ if idedata is None:
+ _LOGGER.error(
+ "No idedata available; compile the configuration first",
+ )
+ return 1
+
+ print(json.dumps(idedata, indent=2) + "\n")
+ return 0
+
+ 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
@@ -1380,7 +1882,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
@@ -1394,12 +1895,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)
@@ -1409,8 +1923,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,
)
@@ -1426,7 +1940,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()
@@ -1435,13 +1949,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:
@@ -1467,11 +1983,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 (
@@ -1494,7 +2031,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))}"
)
@@ -1546,6 +2116,7 @@ PRE_CONFIG_ACTIONS = {
POST_CONFIG_ACTIONS = {
"config": command_config,
+ "config-hash": command_config_hash,
"compile": command_compile,
"upload": command_upload,
"logs": command_logs,
@@ -1566,6 +2137,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(
@@ -1615,6 +2209,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]
@@ -1647,6 +2252,19 @@ def parse_args(argv):
parser_config.add_argument(
"--show-secrets", help="Show secrets in output.", action="store_true"
)
+ parser_config.add_argument(
+ "--no-defaults",
+ help="Only output the user-supplied configuration without "
+ "schema defaults applied.",
+ 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."
@@ -1659,11 +2277,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",
@@ -1686,6 +2299,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",
@@ -1708,11 +2342,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",
@@ -1743,6 +2373,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",
@@ -1751,9 +2384,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(
@@ -1781,50 +2420,31 @@ def parse_args(argv):
)
parser_clean_all = subparsers.add_parser(
- "clean-all", help="Clean all build and platform files."
+ "clean-all",
+ help="Clean all build and platform files, including machine-global "
+ "toolchain caches shared by all configurations, so other projects will "
+ "re-download them on next build.",
)
parser_clean_all.add_argument(
"configuration", help="Your YAML file or configuration directory.", nargs="*"
)
- parser_dashboard = subparsers.add_parser(
- "dashboard", help="Create a simple web server for a dashboard."
+ # The dashboard moved to ESPHome Device Builder; the command is kept only to
+ # print a redirect (see command_dashboard). Accept and ignore the old flags
+ # so legacy invocations reach that message instead of failing on argparse
+ # "unrecognized arguments".
+ parser_dashboard = subparsers.add_parser("dashboard")
+ parser_dashboard.add_argument("configuration", nargs="?", help=argparse.SUPPRESS)
+ parser_dashboard.add_argument("--port", help=argparse.SUPPRESS)
+ parser_dashboard.add_argument("--address", help=argparse.SUPPRESS)
+ parser_dashboard.add_argument("--username", help=argparse.SUPPRESS)
+ parser_dashboard.add_argument("--password", help=argparse.SUPPRESS)
+ parser_dashboard.add_argument("--socket", help=argparse.SUPPRESS)
+ parser_dashboard.add_argument(
+ "--open-ui", action="store_true", help=argparse.SUPPRESS
)
parser_dashboard.add_argument(
- "configuration", help="Your YAML configuration file directory."
- )
- parser_dashboard.add_argument(
- "--port",
- help="The HTTP port to open connections on. Defaults to 6052.",
- type=int,
- default=6052,
- )
- parser_dashboard.add_argument(
- "--address",
- help="The address to bind to.",
- type=str,
- default="0.0.0.0",
- )
- parser_dashboard.add_argument(
- "--username",
- help="The optional username to require for authentication.",
- type=str,
- default="",
- )
- parser_dashboard.add_argument(
- "--password",
- help="The optional password to require for authentication.",
- type=str,
- default="",
- )
- parser_dashboard.add_argument(
- "--open-ui", help="Open the dashboard UI in a browser.", action="store_true"
- )
- parser_dashboard.add_argument(
- "--ha-addon", help=argparse.SUPPRESS, action="store_true"
- )
- parser_dashboard.add_argument(
- "--socket", help="Make the dashboard serve under a unix socket", type=str
+ "--ha-addon", action="store_true", help=argparse.SUPPRESS
)
parser_vscode = subparsers.add_parser("vscode")
@@ -1919,11 +2539,7 @@ def run_esphome(argv):
elif args.quiet:
args.log_level = "CRITICAL"
- setup_log(
- log_level=args.log_level,
- # Show timestamp for dashboard access logs
- include_timestamp=args.command == "dashboard",
- )
+ setup_log(log_level=args.log_level)
if args.command in PRE_CONFIG_ACTIONS:
try:
@@ -1966,17 +2582,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/address_cache.py b/esphome/address_cache.py
index 7c20be90f0..4fb3689818 100644
--- a/esphome/address_cache.py
+++ b/esphome/address_cache.py
@@ -101,6 +101,17 @@ class AddressCache:
"""Check if any cache entries exist."""
return bool(self.mdns_cache or self.dns_cache)
+ def add_mdns_addresses(self, hostname: str, addresses: list[str]) -> None:
+ """Store resolved mDNS addresses for ``hostname`` in the cache.
+
+ Callers that discover ``.local`` hosts (e.g. via mDNS browse) can use
+ this to avoid a second resolution round-trip during the upload path.
+ No-op when ``addresses`` is empty.
+ """
+ if not addresses:
+ return
+ self.mdns_cache[normalize_hostname(hostname)] = addresses
+
@classmethod
def from_cli_args(
cls, mdns_args: Iterable[str], dns_args: Iterable[str]
diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py
index f56d720ec2..1198562218 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 8438457db6..cd59e7ae29 100644
--- a/esphome/analyze_memory/cli.py
+++ b/esphome/analyze_memory/cli.py
@@ -7,6 +7,7 @@ from collections.abc import Callable
import heapq
import json
from operator import itemgetter
+from pathlib import Path
import sys
from typing import TYPE_CHECKING
@@ -20,6 +21,7 @@ from . import (
RAM_SECTIONS,
MemoryAnalyzer,
)
+from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
if TYPE_CHECKING:
from . import ComponentMemory
@@ -510,7 +512,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(
@@ -602,7 +604,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)}"
)
@@ -641,7 +643,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)
@@ -700,7 +702,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 +739,9 @@ def main():
build_dir = sys.argv[1]
# Load build directory
- from pathlib import Path
+ import json
- from esphome.platformio_api import IDEData
+ from esphome.platformio.toolchain import IDEData
build_path = Path(build_dir)
@@ -759,45 +761,25 @@ def main():
print(f"Error: {build_path} is not a directory", file=sys.stderr)
sys.exit(1)
- # Find firmware.elf
- elf_file = None
- for elf_candidate in [
- build_path / "firmware.elf",
- build_path / ".pioenvs" / build_path.name / "firmware.elf",
- ]:
- if elf_candidate.exists():
- elf_file = str(elf_candidate)
- break
-
- if not elf_file:
- print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
+ elf_path = find_elf_path(build_path)
+ if not elf_path:
+ print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr)
sys.exit(1)
-
- # Find idedata.json - check current directory first, then home
- device_name = build_path.name
- idedata_candidates = [
- Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
- Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
- ]
+ elf_file = str(elf_path)
idedata = None
- for idedata_path in idedata_candidates:
- if not idedata_path.exists():
- continue
+ if idedata_path := find_idedata_path(build_path):
try:
- with 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)
- break
except (json.JSONDecodeError, OSError) as e:
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
if not idedata:
- print(
- f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
- file=sys.stderr,
- )
+ searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
+ print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
analyzer.analyze()
diff --git a/esphome/analyze_memory/demangle.py b/esphome/analyze_memory/demangle.py
index 8999108b51..7dbd6d4f63 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/ram_strings.py b/esphome/analyze_memory/ram_strings.py
index fbcbeeca61..03da86de94 100644
--- a/esphome/analyze_memory/ram_strings.py
+++ b/esphome/analyze_memory/ram_strings.py
@@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266.
from __future__ import annotations
from collections import defaultdict
-from dataclasses import dataclass
+from dataclasses import dataclass, field
import logging
from pathlib import Path
import re
@@ -65,6 +65,7 @@ class RamSymbol:
size: int
section: str
demangled: str = "" # Demangled name, set after batch demangling
+ aliases: list[str] = field(default_factory=list) # Other names at same address
class RamStringsAnalyzer:
@@ -235,6 +236,11 @@ class RamStringsAnalyzer:
except (subprocess.CalledProcessError, FileNotFoundError):
return
+ # Track symbols by address so aliases (multiple names for the same
+ # object, e.g. the newlib __lock___* mutexes that all alias one
+ # StaticSemaphore_t) are reported once instead of once per name.
+ symbols_by_addr: dict[int, RamSymbol] = {}
+
for line in output.split("\n"):
parts = line.split()
if len(parts) < 4:
@@ -253,6 +259,18 @@ class RamStringsAnalyzer:
if sym_type not in DATA_SYMBOL_TYPES:
continue
+ if (existing := symbols_by_addr.get(addr)) is not None:
+ # Prefer a global (uppercase type) name as the primary so
+ # nm output order can't hide it behind a local alias.
+ if sym_type.isupper() and existing.sym_type.islower():
+ existing.aliases.append(existing.name)
+ existing.name = name
+ existing.sym_type = sym_type
+ else:
+ existing.aliases.append(name)
+ existing.size = max(existing.size, size)
+ continue
+
# Check if symbol is in a RAM section
for section_name in self.ram_sections:
if section_name not in self.sections:
@@ -260,15 +278,15 @@ class RamStringsAnalyzer:
section = self.sections[section_name]
if section.address <= addr < section.address + section.size:
- self.ram_symbols.append(
- RamSymbol(
- name=name,
- sym_type=sym_type,
- address=addr,
- size=size,
- section=section_name,
- )
+ symbol = RamSymbol(
+ name=name,
+ sym_type=sym_type,
+ address=addr,
+ size=size,
+ section=section_name,
)
+ symbols_by_addr[addr] = symbol
+ self.ram_symbols.append(symbol)
break
def _demangle_symbols(self) -> None:
@@ -436,7 +454,13 @@ class RamStringsAnalyzer:
for symbol in largest_symbols:
# Use demangled name if available, otherwise raw name
display_name = symbol.demangled or symbol.name
- name_display = display_name[:49] if len(display_name) > 49 else display_name
+ # Truncate the name, not the alias note, so merged aliases stay
+ # visible even for long demangled C++ names.
+ alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else ""
+ max_name_len = 49 - len(alias_note)
+ if len(display_name) > max_name_len:
+ display_name = display_name[:max_name_len]
+ name_display = display_name + alias_note
lines.append(
f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}"
)
diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py
index 3a8a5f7be4..19041ac807 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
@@ -24,6 +23,78 @@ TOOLCHAIN_PREFIXES = [
]
+def find_elf_path(build_path: Path) -> Path | None:
+ """Locate the firmware ELF inside an ESPHome build directory.
+
+ The layout depends on the toolchain that produced the build, so try each
+ known one in turn.
+
+ Args:
+ build_path: Path to an ESPHome build directory
+
+ Returns:
+ Path to the ELF file, or None if no known layout matches
+ """
+ name = build_path.name
+ for candidate in (
+ # Native ESP-IDF: idf.py writes build/.elf, which ESPHome copies
+ # to build/firmware.elf (see espidf.toolchain.create_elf_copy)
+ build_path / "build" / "firmware.elf",
+ # PlatformIO
+ build_path / "firmware.elf",
+ build_path / ".pioenvs" / name / "firmware.elf",
+ # LibreTiny uses raw_firmware.elf
+ build_path / "raw_firmware.elf",
+ build_path / ".pioenvs" / name / "raw_firmware.elf",
+ # Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2
+ build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf",
+ build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf",
+ ):
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+def idedata_candidates(build_path: Path) -> list[Path]:
+ """Return the idedata locations searched for a build directory, in order.
+
+ Exposed so a caller reporting "not found" can name the paths it tried
+ without keeping its own copy of the list.
+
+ Args:
+ build_path: Path to an ESPHome build directory
+
+ Returns:
+ The candidate idedata JSON paths, most specific first
+ """
+ name = build_path.name
+ return [
+ # In .pioenvs for test builds
+ build_path / ".pioenvs" / name / "idedata.json",
+ # Both toolchains cache it in the data dir, which holds this build dir:
+ # /idedata/.json next to /build/
+ build_path.parent.parent / "idedata" / f"{name}.json",
+ # Regular builds, invoked from the config dir or from anywhere
+ Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
+ Path.home() / ".esphome" / "idedata" / f"{name}.json",
+ ]
+
+
+def find_idedata_path(build_path: Path) -> Path | None:
+ """Locate the idedata JSON belonging to an ESPHome build directory.
+
+ Args:
+ build_path: Path to an ESPHome build directory
+
+ Returns:
+ Path to the idedata JSON, or None if it was not found
+ """
+ for candidate in idedata_candidates(build_path):
+ if candidate.is_file():
+ return candidate
+ return None
+
+
def _find_in_platformio_packages(tool_name: str) -> str | None:
"""Search for a tool in PlatformIO package directories.
@@ -37,7 +108,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
new file mode 100644
index 0000000000..3972d735f5
--- /dev/null
+++ b/esphome/async_thread.py
@@ -0,0 +1,53 @@
+"""Helpers for running an async coroutine from sync code via a daemon thread.
+
+``asyncio.run(coro())`` in the main thread blocks until the loop's cleanup
+cycle finishes, which can add hundreds of milliseconds before the caller
+receives the result. Running the loop in a daemon thread lets the caller
+observe the result as soon as the coroutine completes while cleanup finishes
+in the background.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Awaitable, Callable
+import threading
+
+
+class AsyncThreadRunner[T](threading.Thread):
+ """Run an async coroutine in a daemon thread and expose its result.
+
+ The runner catches all exceptions from the coroutine and stores them in
+ ``exception`` so ``event`` is always set — this prevents callers waiting
+ on ``event`` from hanging forever when the coroutine crashes.
+
+ Typical usage::
+
+ runner = AsyncThreadRunner(lambda: my_coro(arg))
+ runner.start()
+ if not runner.event.wait(timeout=5.0):
+ ... # timed out
+ if runner.exception is not None:
+ raise runner.exception
+ result = runner.result
+ """
+
+ def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None:
+ super().__init__(daemon=True)
+ self._coro_factory = coro_factory
+ self.result: T | None = None
+ self.exception: BaseException | None = None
+ self.event = threading.Event()
+
+ async def _runner(self) -> None:
+ try:
+ self.result = await self._coro_factory()
+ except Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
+ # Capture all exceptions so ``event`` is always set — otherwise a
+ # crash would hang the waiter forever.
+ self.exception = exc
+ finally:
+ self.event.set()
+
+ def run(self) -> None:
+ asyncio.run(self._runner())
diff --git a/esphome/automation.py b/esphome/automation.py
index b4dcc41995..1689d29c42 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)
@@ -199,11 +199,10 @@ def validate_automation(extra_schema=None, extra_validators=None, single=False):
return cv.Schema([schema])(value)
except cv.Invalid as err2:
if "extra keys not allowed" in str(err2) and len(err2.path) == 2:
- # pylint: disable=raise-missing-from
- raise err
+ raise err from None
if "Unable to find action" in str(err):
- raise err2
- raise cv.MultipleInvalid([err, err2])
+ raise err2 from None
+ raise cv.MultipleInvalid([err, err2]) from None
elif isinstance(value, dict):
if CONF_THEN in value:
return [schema(value)]
@@ -397,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
@@ -598,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 01923baaac..cc2fc5c4cd 100644
--- a/esphome/build_gen/espidf.py
+++ b/esphome/build_gen/espidf.py
@@ -3,23 +3,43 @@
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.framework_helpers import (
+ get_project_compile_flags,
+ get_project_cxx_compile_flags,
+ get_project_link_flags,
+)
from esphome.helpers import mkdir_p, write_file_if_changed
+# Replaces the IDF default C++ standard (-std=gnu++2b appended to
+# CXX_COMPILE_OPTIONS by project.cmake's __build_init) with the one set via
+# cg.set_cpp_standard(). Emitted between include(project.cmake) and project(),
+# i.e. after IDF appends its default and before the options are consumed, and
+# applies project-wide like PlatformIO build_unflags.
+CPP_STANDARD_TEMPLATE = """\
+idf_build_get_property(esphome_cxx_compile_options CXX_COMPILE_OPTIONS)
+list(FILTER esphome_cxx_compile_options EXCLUDE REGEX "^-std=")
+list(APPEND esphome_cxx_compile_options "-std={standard}")
+idf_build_set_property(CXX_COMPILE_OPTIONS "${{esphome_cxx_compile_options}}")"""
+
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 +50,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,72 +67,166 @@ 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 = get_project_compile_flags()
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
+ )
+
+ # Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
+ # (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
+ # -Wno-volatile is passed on a C compile.
+ cxx_compile_options = "\n".join(
+ f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
+ for flag in get_project_cxx_compile_flags()
+ )
+
+ cpp_standard_options = (
+ CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard)
+ if CORE.cpp_standard
+ else ""
+ )
+
+ # 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)
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
+{cpp_standard_options}
+
+{cxx_compile_options}
+
{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)
- 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 ""
+ 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 = get_project_link_flags()
+ link_opts_str = "\n ".join(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}
-)
-
-# Apply C++ standard
-target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
-
-# ESPHome compile options
-target_compile_options(${{COMPONENT_LIB}} PUBLIC
- {compile_opts_str}
+ REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
)
# ESPHome linker options
@@ -130,11 +244,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 30dbb69d86..b63c4b733d 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 ============"
@@ -33,12 +33,27 @@ def format_ini(data: dict[str, str | list[str]]) -> str:
return content
+# All -std= variants a platform/framework may set by default, in both the GNU
+# and strict dialects; unflagged so the cg.set_cpp_standard() value is the
+# only standard left in the build.
+CPP_STD_VARIANTS = [
+ f"{prefix}{year}"
+ for year in ("11", "14", "17", "20", "23", "26", "2a", "2b", "2c")
+ for prefix in ("gnu++", "c++")
+]
+
+
def get_ini_content():
CORE.add_platformio_option(
"lib_deps",
[x.as_lib_dep for x in CORE.platformio_libraries.values()]
+ ["${common.lib_deps}"],
)
+ if CORE.cpp_standard:
+ for variant in CPP_STD_VARIANTS:
+ if variant != CORE.cpp_standard:
+ CORE.add_build_unflag(f"-std={variant}")
+ CORE.add_build_flag(f"-std={CORE.cpp_standard}")
# Sort to avoid changing build flags order
CORE.add_platformio_option("build_flags", sorted(CORE.build_flags))
@@ -58,7 +73,6 @@ def get_ini_content():
def write_ini(content):
- update_storage_json()
path = CORE.relative_build_path("platformio.ini")
if path.is_file():
@@ -94,7 +108,6 @@ Import("env")
def write_cxx_flags_script() -> None:
path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME)
contents = CXX_FLAGS_FILE_CONTENTS
- if not CORE.is_host:
- contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])'
- contents += "\n"
+ for flag in sorted(CORE.cxx_build_flags):
+ contents += f'env.Append(CXXFLAGS=["{flag}"])\n'
write_file_if_changed(path, contents)
diff --git a/esphome/bundle.py b/esphome/bundle.py
index b6816c7c95..88df87c3ba 100644
--- a/esphome/bundle.py
+++ b/esphome/bundle.py
@@ -7,7 +7,7 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from enum import StrEnum
import io
import json
@@ -32,6 +32,8 @@ from esphome.core import CORE, EsphomeError
_LOGGER = logging.getLogger(__name__)
+DOMAIN = "bundle"
+
BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
MANIFEST_FILENAME = "manifest.json"
CURRENT_MANIFEST_VERSION = 1
@@ -98,11 +100,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]:
@@ -118,6 +122,32 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
return keys
+@dataclass
+class BundleData:
+ """Files components asked to include, keyed under DOMAIN in CORE.data."""
+
+ extra_files: list[Path] = field(default_factory=list)
+
+
+def _get_data() -> BundleData:
+ if DOMAIN not in CORE.data:
+ CORE.data[DOMAIN] = BundleData()
+ return CORE.data[DOMAIN]
+
+
+def add_bundle_file(path: Path) -> None:
+ """Register a file that a bundle must include.
+
+ Bundle discovery walks the validated config, so it only finds files the config
+ names. Components call this during validation for files it cannot see, such as a
+ file that is referenced from inside another file.
+
+ A relative path is taken as relative to the config directory. Files outside the
+ config directory are skipped when the bundle is built.
+ """
+ _get_data().extra_files.append(CORE.relative_config_path(path))
+
+
@dataclass
class BundleFile:
"""A file to include in the bundle."""
@@ -151,8 +181,8 @@ class ConfigBundleCreator:
def __init__(self, config: dict[str, Any]) -> None:
self._config = config
- self._config_dir = CORE.config_dir
- self._config_path = CORE.config_path
+ self._config_dir = Path(CORE.config_dir).resolve()
+ self._config_path = Path(CORE.config_path).resolve()
self._files: list[BundleFile] = []
self._seen_paths: set[Path] = set()
self._secrets_paths: set[Path] = set()
@@ -258,27 +288,20 @@ class ConfigBundleCreator:
def _discover_yaml_includes(self) -> None:
"""Discover YAML files loaded during config parsing.
- We track files by wrapping _load_yaml_internal. The config has already
- been loaded at this point (bundle is a POST_CONFIG_ACTION), so we
- re-load just to discover the file list.
-
- 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.
"""
- with yaml_util.track_yaml_loads() as loaded_files:
- try:
- yaml_util.load_yaml(self._config_path)
- except EsphomeError:
- _LOGGER.debug(
- "Bundle: re-loading YAML for include discovery failed, "
- "proceeding with partial file list"
- )
-
- 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:
@@ -291,13 +314,18 @@ class ConfigBundleCreator:
with known file extensions are also resolved and checked.
Core ESPHome concepts that use relative paths or directories
- are handled explicitly.
+ are handled explicitly. Files the config does not name at all are
+ registered by their component with add_bundle_file().
"""
config = self._config
# Generic walk: find all file paths in the validated config
self._walk_config_for_files(config)
+ # Files registered by components during validation
+ for extra_file in _get_data().extra_files:
+ self._add_file(extra_file)
+
# --- Core ESPHome concepts needing explicit handling ---
# esphome.includes / includes_c - can be relative paths and directories
@@ -417,7 +445,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())
diff --git a/esphome/codegen.py b/esphome/codegen.py
index a5b5abe447..1cddfa5382 100644
--- a/esphome/codegen.py
+++ b/esphome/codegen.py
@@ -10,6 +10,7 @@
# pylint: disable=unused-import
from esphome.cpp_generator import ( # noqa: F401
ArrayInitializer,
+ ComponentMarker,
Expression,
FlashStringLiteral,
LineComment,
@@ -25,6 +26,7 @@ from esphome.cpp_generator import ( # noqa: F401
add,
add_build_flag,
add_build_unflag,
+ add_cxx_build_flag,
add_define,
add_global,
add_library,
diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py
new file mode 100644
index 0000000000..f4fd205285
--- /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/__init__.py b/esphome/components/__init__.py
index e69de29bb2..3d7a546253 100644
--- a/esphome/components/__init__.py
+++ b/esphome/components/__init__.py
@@ -0,0 +1,6 @@
+# Importing `esphome.loader` here installs the component-alias
+# ``sys.meta_path`` finder before any submodule lookup runs. Without this,
+# `from esphome.components import ` from a fresh interpreter
+# can race the finder install and raise ImportError, since the legacy
+# alias dir no longer exists on disk.
+from esphome import loader as _loader # noqa: F401
diff --git a/esphome/components/a01nyub/a01nyub.cpp b/esphome/components/a01nyub/a01nyub.cpp
index 210c3557b3..6111af2b7e 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";
@@ -26,7 +25,7 @@ void A01nyubComponent::check_buffer_() {
if (this->buffer_[3] == checksum) {
float distance = (this->buffer_[1] << 8) + this->buffer_[2];
if (distance > 280) {
- float meters = distance / 1000.0;
+ float meters = distance / 1000.0f;
ESP_LOGV(TAG, "Distance from sensor: %f mm, %f m", distance, meters);
this->publish_state(meters);
} else {
@@ -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 6b22e9bcad..69636eb8e4 100644
--- a/esphome/components/a01nyub/a01nyub.h
+++ b/esphome/components/a01nyub/a01nyub.h
@@ -6,10 +6,9 @@
#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 {
+class A01nyubComponent final : public sensor::Sensor, public Component, public uart::UARTDevice {
public:
// Nothing really 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 a2aad0cef1..2832334ef1 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 6ff370fdc3..2e71651301 100644
--- a/esphome/components/a02yyuw/a02yyuw.h
+++ b/esphome/components/a02yyuw/a02yyuw.h
@@ -6,10 +6,9 @@
#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 {
+class A02yyuwComponent final : public sensor::Sensor, public Component, public uart::UARTDevice {
public:
// Nothing really 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 b9efb4ea44..d8fc6752f3 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 0fe7891110..f50b5926c1 100644
--- a/esphome/components/a4988/a4988.h
+++ b/esphome/components/a4988/a4988.h
@@ -4,10 +4,9 @@
#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 {
+class A4988 final : public stepper::Stepper, public Component {
public:
void set_step_pin(GPIOPin *step_pin) { step_pin_ = step_pin; }
void set_dir_pin(GPIOPin *dir_pin) { dir_pin_ = dir_pin; }
@@ -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/absolute_humidity/absolute_humidity.h b/esphome/components/absolute_humidity/absolute_humidity.h
index be28d3dc50..9989bb17fc 100644
--- a/esphome/components/absolute_humidity/absolute_humidity.h
+++ b/esphome/components/absolute_humidity/absolute_humidity.h
@@ -13,7 +13,7 @@ enum SaturationVaporPressureEquation {
};
/// This class implements calculation of absolute humidity from temperature and relative humidity.
-class AbsoluteHumidityComponent : public sensor::Sensor, public Component {
+class AbsoluteHumidityComponent final : public sensor::Sensor, public Component {
public:
void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; }
void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; }
diff --git a/esphome/components/ac_dimmer/ac_dimmer.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp
index f731a8c753..477962a040 100644
--- a/esphome/components/ac_dimmer/ac_dimmer.cpp
+++ b/esphome/components/ac_dimmer/ac_dimmer.cpp
@@ -190,7 +190,7 @@ void AcDimmer::setup() {
this->zero_cross_pin_->setup();
this->store_.zero_cross_pin = this->zero_cross_pin_->to_isr();
this->zero_cross_pin_->attach_interrupt(&AcDimmerDataStore::s_gpio_intr, &this->store_,
- gpio::INTERRUPT_FALLING_EDGE);
+ this->zero_cross_interrupt_type_);
}
#ifdef USE_ESP8266
@@ -216,7 +216,7 @@ void AcDimmer::setup() {
}
void AcDimmer::write_state(float state) {
- state = std::acos(1 - (2 * state)) / std::numbers::pi; // RMS power compensation
+ state = std::acos(1 - (2 * state)) / std::numbers::pi_v; // RMS power compensation
auto new_value = static_cast(roundf(state * 65535));
if (new_value != 0 && this->store_.value == 0)
this->store_.init_cycle = this->init_with_half_cycle_;
@@ -226,19 +226,25 @@ void AcDimmer::write_state(float state) {
void AcDimmer::dump_config() {
ESP_LOGCONFIG(TAG,
"AcDimmer:\n"
- " Min Power: %.1f%%\n"
- " Init with half cycle: %s",
+ " Min Power: %.1f%%\n"
+ " Init with half cycle: %s",
this->store_.min_power / 10.0f, YESNO(this->init_with_half_cycle_));
LOG_PIN(" Output Pin: ", this->gate_pin_);
LOG_PIN(" Zero-Cross Pin: ", this->zero_cross_pin_);
- if (method_ == DIM_METHOD_LEADING_PULSE) {
- ESP_LOGCONFIG(TAG, " Method: leading pulse");
- } else if (method_ == DIM_METHOD_LEADING) {
- ESP_LOGCONFIG(TAG, " Method: leading");
+ if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_RISING_EDGE) {
+ ESP_LOGCONFIG(TAG, " Interrupt Type: rising");
+ } else if (this->zero_cross_interrupt_type_ == gpio::INTERRUPT_FALLING_EDGE) {
+ ESP_LOGCONFIG(TAG, " Interrupt Type: falling");
} else {
- ESP_LOGCONFIG(TAG, " Method: trailing");
+ ESP_LOGCONFIG(TAG, " Interrupt Type: any");
+ }
+ if (method_ == DIM_METHOD_LEADING_PULSE) {
+ ESP_LOGCONFIG(TAG, " Method: leading pulse");
+ } else if (method_ == DIM_METHOD_LEADING) {
+ ESP_LOGCONFIG(TAG, " Method: leading");
+ } else {
+ ESP_LOGCONFIG(TAG, " Method: trailing");
}
-
LOG_FLOAT_OUTPUT(this);
ESP_LOGV(TAG, " Estimated Frequency: %.3fHz", 1e6f / this->store_.cycle_time_us / 2);
}
diff --git a/esphome/components/ac_dimmer/ac_dimmer.h b/esphome/components/ac_dimmer/ac_dimmer.h
index ca2a19210a..783a9d7e24 100644
--- a/esphome/components/ac_dimmer/ac_dimmer.h
+++ b/esphome/components/ac_dimmer/ac_dimmer.h
@@ -41,13 +41,14 @@ struct AcDimmerDataStore {
#endif
};
-class AcDimmer : public output::FloatOutput, public Component {
+class AcDimmer final : public output::FloatOutput, public Component {
public:
void setup() override;
void dump_config() override;
void set_gate_pin(InternalGPIOPin *gate_pin) { gate_pin_ = gate_pin; }
void set_zero_cross_pin(InternalGPIOPin *zero_cross_pin) { zero_cross_pin_ = zero_cross_pin; }
+ void set_zero_cross_interrupt_type(gpio::InterruptType type) { zero_cross_interrupt_type_ = type; }
void set_init_with_half_cycle(bool init_with_half_cycle) { init_with_half_cycle_ = init_with_half_cycle; }
void set_method(DimMethod method) { method_ = method; }
@@ -56,6 +57,7 @@ class AcDimmer : public output::FloatOutput, public Component {
InternalGPIOPin *gate_pin_;
InternalGPIOPin *zero_cross_pin_;
+ gpio::InterruptType zero_cross_interrupt_type_;
AcDimmerDataStore store_;
bool init_with_half_cycle_;
DimMethod method_;
diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py
index efc24b65e7..1f35095e0e 100644
--- a/esphome/components/ac_dimmer/output.py
+++ b/esphome/components/ac_dimmer/output.py
@@ -7,6 +7,8 @@ from esphome.core import CORE
CODEOWNERS = ["@glmnet"]
+gpio_ns = cg.esphome_ns.namespace("gpio")
+
ac_dimmer_ns = cg.esphome_ns.namespace("ac_dimmer")
AcDimmer = ac_dimmer_ns.class_("AcDimmer", output.FloatOutput, cg.Component)
@@ -17,15 +19,26 @@ DIM_METHODS = {
"TRAILING": DimMethod.DIM_METHOD_TRAILING,
}
+ZC_INTERRUPT_TYPES = {
+ "RISING": gpio_ns.INTERRUPT_RISING_EDGE,
+ "FALLING": gpio_ns.INTERRUPT_FALLING_EDGE,
+ "ANY": gpio_ns.INTERRUPT_ANY_EDGE,
+}
+
CONF_GATE_PIN = "gate_pin"
CONF_ZERO_CROSS_PIN = "zero_cross_pin"
CONF_INIT_WITH_HALF_CYCLE = "init_with_half_cycle"
+CONF_ZERO_CROSS_INTERRUPT_TYPE = "zero_cross_interrupt_type"
+
CONFIG_SCHEMA = cv.All(
output.FLOAT_OUTPUT_SCHEMA.extend(
{
cv.Required(CONF_ID): cv.declare_id(AcDimmer),
cv.Required(CONF_GATE_PIN): pins.internal_gpio_output_pin_schema,
cv.Required(CONF_ZERO_CROSS_PIN): pins.internal_gpio_input_pin_schema,
+ cv.Optional(CONF_ZERO_CROSS_INTERRUPT_TYPE, default="FALLING"): cv.enum(
+ ZC_INTERRUPT_TYPES, upper=True, space="_"
+ ),
cv.Optional(CONF_INIT_WITH_HALF_CYCLE, default=True): cv.boolean,
cv.Optional(CONF_METHOD, default="leading pulse"): cv.enum(
DIM_METHODS, upper=True, space="_"
@@ -54,5 +67,6 @@ async def to_code(config):
cg.add(var.set_gate_pin(pin))
pin = await cg.gpio_pin_expression(config[CONF_ZERO_CROSS_PIN])
cg.add(var.set_zero_cross_pin(pin))
+ cg.add(var.set_zero_cross_interrupt_type(config[CONF_ZERO_CROSS_INTERRUPT_TYPE]))
cg.add(var.set_init_with_half_cycle(config[CONF_INIT_WITH_HALF_CYCLE]))
cg.add(var.set_method(config[CONF_METHOD]))
diff --git a/esphome/components/adalight/adalight_light_effect.cpp b/esphome/components/adalight/adalight_light_effect.cpp
index 4cf639a01f..bf6849acaf 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 bb7319c99c..c30e846778 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/__init__.py b/esphome/components/adc/__init__.py
index 96c8334a6d..555d511f6e 100644
--- a/esphome/components/adc/__init__.py
+++ b/esphome/components/adc/__init__.py
@@ -227,12 +227,12 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = {
def validate_adc_pin(value):
if str(value).upper() == "VCC":
- if CORE.is_rp2040:
+ if CORE.is_rp2:
return pins.internal_gpio_input_pin_schema(29)
return cv.only_on([PLATFORM_ESP8266])("VCC")
if str(value).upper() == "TEMPERATURE":
- return cv.only_on_rp2040("TEMPERATURE")
+ return cv.only_on_rp2("TEMPERATURE")
if CORE.is_esp32:
conf = pins.internal_gpio_input_pin_schema(value)
@@ -261,11 +261,11 @@ def validate_adc_pin(value):
raise cv.Invalid("ESP8266: Only pin A0 (GPIO17) supports ADC")
return conf
- if CORE.is_rp2040:
+ if CORE.is_rp2:
conf = pins.internal_gpio_input_pin_schema(value)
number = conf[CONF_NUMBER]
if number not in (26, 27, 28, 29):
- raise cv.Invalid("RP2040: Only pins 26, 27, 28 and 29 support ADC")
+ raise cv.Invalid("RP2: Only pins 26, 27, 28 and 29 support ADC")
return conf
if CORE.is_libretiny:
diff --git a/esphome/components/adc/adc_sensor.h b/esphome/components/adc/adc_sensor.h
index cf48ccd9c3..7131898747 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
@@ -55,7 +54,7 @@ template class Aggregator {
SamplingMode mode_{SamplingMode::AVG};
};
-class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler {
+class ADCSensor final : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler {
public:
/// Update the sensor's state by reading the current ADC value.
/// This method is called periodically based on the update interval.
@@ -124,9 +123,9 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage
void set_autorange(bool autorange) { this->autorange_ = autorange; }
#endif // USE_ESP32
-#ifdef USE_RP2040
+#ifdef USE_RP2
void set_is_temperature() { this->is_temperature_ = true; }
-#endif // USE_RP2040
+#endif // USE_RP2
protected:
uint8_t sample_count_{1};
@@ -153,14 +152,13 @@ class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage
static adc_oneshot_unit_handle_t shared_adc_handles[2];
#endif // USE_ESP32
-#ifdef USE_RP2040
+#ifdef USE_RP2
bool is_temperature_{false};
-#endif // USE_RP2040
+#endif // USE_RP2
#ifdef USE_ZEPHYR
const struct adc_dt_spec *channel_ = nullptr;
#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 c779fd5893..16c86aee18 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 fc707013a8..a761b37749 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 be14b252d4..e4f2f82f08 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 0b1393c2e7..d9b9f50be1 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_rp2.cpp
similarity index 78%
rename from esphome/components/adc/adc_sensor_rp2040.cpp
rename to esphome/components/adc/adc_sensor_rp2.cpp
index a79707e234..6cb9ef113f 100644
--- a/esphome/components/adc/adc_sensor_rp2040.cpp
+++ b/esphome/components/adc/adc_sensor_rp2.cpp
@@ -1,4 +1,4 @@
-#ifdef USE_RP2040
+#ifdef USE_RP2
#include "adc_sensor.h"
#include "esphome/core/log.h"
@@ -15,10 +15,9 @@
#define PICO_VSYS_PIN 29 // NOLINT(cppcoreguidelines-macro-usage)
#endif
-namespace esphome {
-namespace adc {
+namespace esphome::adc {
-static const char *const TAG = "adc.rp2040";
+static const char *const TAG = "adc.rp2";
void ADCSensor::setup() {
static bool initialized = false;
@@ -67,15 +66,18 @@ float ADCSensor::sample() {
}
uint8_t pin = this->pin_->get_pin();
-#ifdef CYW43_USES_VSYS_PIN
+#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
if (pin == PICO_VSYS_PIN) {
// Measuring VSYS on Raspberry Pico W needs to be wrapped with
// `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in
// https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and
- // VSYS ADC both share GPIO29
+ // VSYS ADC both share GPIO29.
+ // The USE_WIFI guard is required because CYW43_USES_VSYS_PIN can be defined
+ // transitively (e.g. via lwip_wrap.h) even on non-WiFi boards where the CYW43
+ // driver is never initialized; calling cyw43_thread_enter() there hard-faults.
cyw43_thread_enter();
}
-#endif // CYW43_USES_VSYS_PIN
+#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
adc_gpio_init(pin);
adc_select_input(pin - 26);
@@ -85,11 +87,11 @@ float ADCSensor::sample() {
aggr.add_sample(raw);
}
-#ifdef CYW43_USES_VSYS_PIN
+#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
if (pin == PICO_VSYS_PIN) {
cyw43_thread_exit();
}
-#endif // CYW43_USES_VSYS_PIN
+#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
if (this->output_raw_) {
return aggr.aggregate();
@@ -98,7 +100,6 @@ float ADCSensor::sample() {
return aggr.aggregate() * 3.3f / 4096.0f * coeff;
}
-} // namespace adc
-} // namespace esphome
+} // namespace esphome::adc
-#endif // USE_RP2040
+#endif // USE_RP2
diff --git a/esphome/components/adc/adc_sensor_zephyr.cpp b/esphome/components/adc/adc_sensor_zephyr.cpp
index 2fb9d4b0e5..c3632b00e2 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/adc/sensor.py b/esphome/components/adc/sensor.py
index 09e09f0dc1..86e2b771ab 100644
--- a/esphome/components/adc/sensor.py
+++ b/esphome/components/adc/sensor.py
@@ -201,7 +201,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
PlatformFramework.ESP32_IDF,
},
"adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO},
- "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO},
+ "adc_sensor_rp2.cpp": {PlatformFramework.RP2_ARDUINO},
"adc_sensor_libretiny.cpp": {
PlatformFramework.BK72XX_ARDUINO,
PlatformFramework.RTL87XX_ARDUINO,
diff --git a/esphome/components/adc128s102/adc128s102.cpp b/esphome/components/adc128s102/adc128s102.cpp
index 935dbde8ea..ef0db4730a 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 bd6b7f7af1..7d6355815e 100644
--- a/esphome/components/adc128s102/adc128s102.h
+++ b/esphome/components/adc128s102/adc128s102.h
@@ -4,12 +4,11 @@
#include "esphome/core/hal.h"
#include "esphome/components/spi/spi.h"
-namespace esphome {
-namespace adc128s102 {
+namespace esphome::adc128s102 {
-class ADC128S102 : public Component,
- public spi::SPIDevice {
+class ADC128S102 final : public Component,
+ public spi::SPIDevice {
public:
ADC128S102() = default;
@@ -19,5 +18,4 @@ class ADC128S102 : public Component,
uint16_t read_data(uint8_t channel);
};
-} // namespace adc128s102
-} // namespace esphome
+} // namespace esphome::adc128s102
diff --git a/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp b/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp
index 800b2d5261..01b6a3f2b8 100644
--- a/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp
+++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/log.h"
-namespace esphome {
-namespace adc128s102 {
+namespace esphome::adc128s102 {
static const char *const TAG = "adc128s102.sensor";
@@ -18,5 +17,4 @@ void ADC128S102Sensor::dump_config() {
float ADC128S102Sensor::sample() { return this->parent_->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 5e6fc74e9c..3c42e709f2 100644
--- a/esphome/components/adc128s102/sensor/adc128s102_sensor.h
+++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.h
@@ -7,13 +7,12 @@
#include "../adc128s102.h"
-namespace esphome {
-namespace adc128s102 {
+namespace esphome::adc128s102 {
-class ADC128S102Sensor : public PollingComponent,
- public Parented,
- public sensor::Sensor,
- public voltage_sampler::VoltageSampler {
+class ADC128S102Sensor final : public PollingComponent,
+ public Parented,
+ public sensor::Sensor,
+ public voltage_sampler::VoltageSampler {
public:
ADC128S102Sensor(uint8_t channel);
@@ -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 329620bcf0..4cbcb3324b 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 d9b8680547..39d62b8733 100644
--- a/esphome/components/addressable_light/addressable_light_display.h
+++ b/esphome/components/addressable_light/addressable_light_display.h
@@ -7,10 +7,9 @@
#include
-namespace esphome {
-namespace addressable_light {
+namespace esphome::addressable_light {
-class AddressableLightDisplay : public display::DisplayBuffer {
+class AddressableLightDisplay final : public display::DisplayBuffer {
public:
light::AddressableLight *get_light() const { return this->light_; }
@@ -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/__init__.py b/esphome/components/ade7880/__init__.py
index aed63c7dfa..e69de29bb2 100644
--- a/esphome/components/ade7880/__init__.py
+++ b/esphome/components/ade7880/__init__.py
@@ -1 +0,0 @@
-CODEOWNERS = ["@kpfleming"]
diff --git a/esphome/components/ade7880/ade7880.cpp b/esphome/components/ade7880/ade7880.cpp
index 8fb3e55b91..0f4189ad90 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";
@@ -88,14 +87,24 @@ void ADE7880::update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_
sensor->publish_state(f(val));
}
-template
-void ADE7880::update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f) {
- if (sensor == nullptr) {
+void ADE7880::update_active_energy_(PowerChannel *channel, uint16_t a_register) {
+ if (channel->forward_active_energy == nullptr && channel->reverse_active_energy == nullptr) {
return;
}
- float val = this->read_s32_register16_(a_register);
- sensor->publish_state(f(val));
+ // The ADE7880 has no separate forward/reverse active energy accumulators. The xWATTHR registers
+ // accumulate signed energy since the last read (positive = imported/forward, negative = exported/
+ // reverse), so split the value by sign into the forward and reverse running totals.
+ float val = this->read_s32_register16_(a_register) / 14400.0f;
+ if (val >= 0.0f) {
+ if (channel->forward_active_energy != nullptr) {
+ channel->forward_active_energy->publish_state(channel->forward_active_energy_total += val);
+ }
+ } else {
+ if (channel->reverse_active_energy != nullptr) {
+ channel->reverse_active_energy->publish_state(channel->reverse_active_energy_total -= val);
+ }
+ }
}
void ADE7880::update() {
@@ -118,12 +127,7 @@ void ADE7880::update() {
this->update_sensor_from_s24zp_register16_(chan->apparent_power, AVA, [](float val) { return val / 100.0f; });
this->update_sensor_from_s16_register16_(chan->power_factor, APF,
[](float val) { return std::abs(val / -327.68f); });
- this->update_sensor_from_s32_register16_(chan->forward_active_energy, AFWATTHR, [&chan](float val) {
- return chan->forward_active_energy_total += val / 14400.0f;
- });
- this->update_sensor_from_s32_register16_(chan->reverse_active_energy, ARWATTHR, [&chan](float val) {
- return chan->reverse_active_energy_total += val / 14400.0f;
- });
+ this->update_active_energy_(chan, AWATTHR);
}
if (this->channel_b_ != nullptr) {
@@ -134,12 +138,7 @@ void ADE7880::update() {
this->update_sensor_from_s24zp_register16_(chan->apparent_power, BVA, [](float val) { return val / 100.0f; });
this->update_sensor_from_s16_register16_(chan->power_factor, BPF,
[](float val) { return std::abs(val / -327.68f); });
- this->update_sensor_from_s32_register16_(chan->forward_active_energy, BFWATTHR, [&chan](float val) {
- return chan->forward_active_energy_total += val / 14400.0f;
- });
- this->update_sensor_from_s32_register16_(chan->reverse_active_energy, BRWATTHR, [&chan](float val) {
- return chan->reverse_active_energy_total += val / 14400.0f;
- });
+ this->update_active_energy_(chan, BWATTHR);
}
if (this->channel_c_ != nullptr) {
@@ -150,12 +149,7 @@ void ADE7880::update() {
this->update_sensor_from_s24zp_register16_(chan->apparent_power, CVA, [](float val) { return val / 100.0f; });
this->update_sensor_from_s16_register16_(chan->power_factor, CPF,
[](float val) { return std::abs(val / -327.68f); });
- this->update_sensor_from_s32_register16_(chan->forward_active_energy, CFWATTHR, [&chan](float val) {
- return chan->forward_active_energy_total += val / 14400.0f;
- });
- this->update_sensor_from_s32_register16_(chan->reverse_active_energy, CRWATTHR, [&chan](float val) {
- return chan->reverse_active_energy_total += val / 14400.0f;
- });
+ this->update_active_energy_(chan, CWATTHR);
}
ESP_LOGD(TAG, "update took %" PRIu32 " ms", millis() - start);
@@ -313,5 +307,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 40bc22e54a..12be0849ff 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; }
@@ -66,7 +65,7 @@ struct ADE7880Store {
static void gpio_intr(ADE7880Store *arg);
};
-class ADE7880 : public i2c::I2CDevice, public PollingComponent {
+class ADE7880 final : public i2c::I2CDevice, public PollingComponent {
public:
void set_irq0_pin(InternalGPIOPin *pin) { this->irq0_pin_ = pin; }
void set_irq1_pin(InternalGPIOPin *pin) { this->irq1_pin_ = pin; }
@@ -106,7 +105,8 @@ class ADE7880 : public i2c::I2CDevice, public PollingComponent {
// the callable will be passed a 'float' value and is expected to return a 'float'
template void update_sensor_from_s24zp_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f);
template void update_sensor_from_s16_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f);
- template void update_sensor_from_s32_register16_(sensor::Sensor *sensor, uint16_t a_register, F &&f);
+
+ void update_active_energy_(PowerChannel *channel, uint16_t a_register);
void reset_device_();
@@ -125,5 +125,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 fae20f175d..294fd430d3 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 9fd8ca3bf5..8b0b86fe7a 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;
@@ -85,9 +84,7 @@ constexpr uint16_t CWATTHR = 0xE402;
constexpr uint16_t AFWATTHR = 0xE403;
constexpr uint16_t BFWATTHR = 0xE404;
constexpr uint16_t CFWATTHR = 0xE405;
-constexpr uint16_t ARWATTHR = 0xE406;
-constexpr uint16_t BRWATTHR = 0xE407;
-constexpr uint16_t CRWATTHR = 0xE408;
+// 0xE406-0xE408 are reserved on the ADE7880 (it does not implement total reactive energy accumulation)
constexpr uint16_t AFVARHR = 0xE409;
constexpr uint16_t BFVARHR = 0xE40A;
constexpr uint16_t CFVARHR = 0xE40B;
@@ -242,5 +239,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 2dfab8ff85..1adf44f8f7 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 b58f95b230..a1dfea23b0 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 59c2254d44..252e55ee5c 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 65dc30dddb..0b368a73ee 100644
--- a/esphome/components/ade7953_i2c/ade7953_i2c.h
+++ b/esphome/components/ade7953_i2c/ade7953_i2c.h
@@ -8,10 +8,9 @@
#include
-namespace esphome {
-namespace ade7953_i2c {
+namespace esphome::ade7953_i2c {
-class AdE7953I2c : public ade7953_base::ADE7953, public i2c::I2CDevice {
+class AdE7953I2c final : public ade7953_base::ADE7953, public i2c::I2CDevice {
public:
void dump_config() override;
@@ -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 a69b7d19fb..c2d85231d6 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 27f6025d98..657397db4e 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,
@@ -44,7 +43,7 @@ enum ADS1115Samplerate {
ADS1115_860SPS = 0b111
};
-class ADS1115Component : public Component, public i2c::I2CDevice {
+class ADS1115Component final : public Component, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
@@ -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 fac6b60d0a..8086d97231 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 5ca25c13ad..ecc8fb7af8 100644
--- a/esphome/components/ads1115/sensor/ads1115_sensor.h
+++ b/esphome/components/ads1115/sensor/ads1115_sensor.h
@@ -8,14 +8,13 @@
#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,
- public PollingComponent,
- public voltage_sampler::VoltageSampler,
- public Parented {
+class ADS1115Sensor final : public sensor::Sensor,
+ public PollingComponent,
+ public voltage_sampler::VoltageSampler,
+ public Parented {
public:
void update() override;
void set_multiplexer(ADS1115Multiplexer multiplexer) { this->multiplexer_ = multiplexer; }
@@ -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 f7db9f93dd..0a07193bfe 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 e96baab386..275933c70d 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,
@@ -27,9 +26,9 @@ enum ADS1118Gain {
ADS1118_GAIN_0P256 = 0b101,
};
-class ADS1118 : public Component,
- public spi::SPIDevice {
+class ADS1118 final : public Component,
+ public spi::SPIDevice {
public:
ADS1118() = default;
void setup() override;
@@ -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 7193c3c880..383a3d25fc 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 d2d7a03f59..8987dba073 100644
--- a/esphome/components/ads1118/sensor/ads1118_sensor.h
+++ b/esphome/components/ads1118/sensor/ads1118_sensor.h
@@ -8,13 +8,12 @@
#include "../ads1118.h"
-namespace esphome {
-namespace ads1118 {
+namespace esphome::ads1118 {
-class ADS1118Sensor : public PollingComponent,
- public sensor::Sensor,
- public voltage_sampler::VoltageSampler,
- public Parented {
+class ADS1118Sensor final : public PollingComponent,
+ public sensor::Sensor,
+ public voltage_sampler::VoltageSampler,
+ public Parented {
public:
void update() override;
@@ -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 fa7170114c..230548ae94 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 9e034b20cb..8ebc8da544 100644
--- a/esphome/components/ags10/ags10.h
+++ b/esphome/components/ags10/ags10.h
@@ -5,10 +5,9 @@
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
-namespace esphome {
-namespace ags10 {
+namespace esphome::ags10 {
-class AGS10Component : public PollingComponent, public i2c::I2CDevice {
+class AGS10Component final : public PollingComponent, public i2c::I2CDevice {
public:
/**
* Sets TVOC sensor.
@@ -101,7 +100,7 @@ class AGS10Component : public PollingComponent, public i2c::I2CDevice {
template optional> read_and_check_(uint8_t a_register);
};
-template class AGS10NewI2cAddressAction : public Action, public Parented {
+template class AGS10NewI2cAddressAction final : public Action, public Parented {
public:
TEMPLATABLE_VALUE(uint8_t, new_address)
@@ -117,7 +116,7 @@ enum AGS10SetZeroPointActionMode {
CUSTOM_VALUE,
};
-template class AGS10SetZeroPointAction : public Action, public Parented {
+template class AGS10SetZeroPointAction final : public Action, public Parented {
public:
TEMPLATABLE_VALUE(uint16_t, value)
TEMPLATABLE_VALUE(AGS10SetZeroPointActionMode, mode)
@@ -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 1b1f8335cc..cc90abfc3a 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 ce9cd963ad..e99ba6fb98 100644
--- a/esphome/components/aht10/aht10.h
+++ b/esphome/components/aht10/aht10.h
@@ -6,12 +6,11 @@
#include "esphome/components/sensor/sensor.h"
#include "esphome/components/i2c/i2c.h"
-namespace esphome {
-namespace aht10 {
+namespace esphome::aht10 {
enum AHT10Variant { AHT10, AHT20 };
-class AHT10Component : public PollingComponent, public i2c::I2CDevice {
+class AHT10Component final : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void update() override;
@@ -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 e1acf32f83..0ba960fd70 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 28006e33fc..ae99a8f4d6 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
@@ -62,7 +61,7 @@ static const uint8_t AIC3204_ADC_PTM = 0x3D; // Register 61 - ADC Power Tu
static const uint8_t AIC3204_AN_IN_CHRG = 0x47; // Register 71 - Analog Input Quick Charging Config
static const uint8_t AIC3204_REF_STARTUP = 0x7B; // Register 123 - Reference Power Up Config
-class AIC3204 : public audio_dac::AudioDac, public Component, public i2c::I2CDevice {
+class AIC3204 final : public audio_dac::AudioDac, public Component, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
@@ -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 851ff930f8..f0f8856614 100644
--- a/esphome/components/aic3204/automation.h
+++ b/esphome/components/aic3204/automation.h
@@ -4,10 +4,9 @@
#include "esphome/core/component.h"
#include "aic3204.h"
-namespace esphome {
-namespace aic3204 {
+namespace esphome::aic3204 {
-template class SetAutoMuteAction : public Action {
+template class SetAutoMuteAction final : public Action {
public:
explicit SetAutoMuteAction(AIC3204 *aic3204) : aic3204_(aic3204) {}
@@ -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 58faf923f5..881b3e297b 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 52f69ea970..8105ac32eb 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 {
+class AirthingsListener final : 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/__init__.py b/esphome/components/airthings_wave_base/__init__.py
index c3f3b8f199..dee26b524a 100644
--- a/esphome/components/airthings_wave_base/__init__.py
+++ b/esphome/components/airthings_wave_base/__init__.py
@@ -21,7 +21,7 @@ from esphome.const import (
UNIT_VOLT,
)
-CODEOWNERS = ["@ncareau", "@jeromelaban", "@kpfleming"]
+CODEOWNERS = ["@ncareau", "@jeromelaban"]
DEPENDENCIES = ["ble_client"]
diff --git a/esphome/components/airthings_wave_base/airthings_wave_base.cpp b/esphome/components/airthings_wave_base/airthings_wave_base.cpp
index e4c7d2a81d..5fa59f22fd 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 1dc2e1f71f..2f1e15491f 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 873826d06c..f487e9dbc0 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 825ddbdc69..c41dde15c9 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;
@@ -13,7 +12,7 @@ static const char *const SERVICE_UUID = "b42e3882-ade7-11e4-89d3-123b93f75cba";
static const char *const CHARACTERISTIC_UUID = "b42e3b98-ade7-11e4-89d3-123b93f75cba";
static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID = "b42e3ef4-ade7-11e4-89d3-123b93f75cba";
-class AirthingsWaveMini : public airthings_wave_base::AirthingsWaveBase {
+class AirthingsWaveMini final : public airthings_wave_base::AirthingsWaveBase {
public:
AirthingsWaveMini();
@@ -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 5ed62fff62..80fe081b57 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 c978a9af92..af355e45d6 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;
@@ -20,7 +19,7 @@ static const char *const CHARACTERISTIC_UUID_WAVE_RADON_GEN2 = "b42e4dcc-ade7-11
static const char *const ACCESS_CONTROL_POINT_CHARACTERISTIC_UUID_WAVE_RADON_GEN2 =
"b42e50d8-ade7-11e4-89d3-123b93f75cba";
-class AirthingsWavePlus : public airthings_wave_base::AirthingsWaveBase {
+class AirthingsWavePlus final : public airthings_wave_base::AirthingsWaveBase {
public:
void setup() override;
@@ -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 9fcdf42ecb..2f5d4c7c2b 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/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h
index 022d2650d2..dcb5121c60 100644
--- a/esphome/components/alarm_control_panel/automation.h
+++ b/esphome/components/alarm_control_panel/automation.h
@@ -27,7 +27,7 @@ static_assert(std::is_trivially_copyable_v);
static_assert(sizeof(StateEnterForwarder) <= sizeof(void *));
static_assert(std::is_trivially_copyable_v>);
-template class ArmAwayAction : public Action {
+template class ArmAwayAction final : public Action {
public:
explicit ArmAwayAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -39,7 +39,7 @@ template class ArmAwayAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class ArmHomeAction : public Action {
+template class ArmHomeAction final : public Action {
public:
explicit ArmHomeAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -51,7 +51,7 @@ template class ArmHomeAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class ArmNightAction : public Action {
+template class ArmNightAction final : public Action {
public:
explicit ArmNightAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -63,7 +63,7 @@ template class ArmNightAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class DisarmAction : public Action {
+template class DisarmAction final : public Action {
public:
explicit DisarmAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -75,7 +75,7 @@ template class DisarmAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class PendingAction : public Action {
+template class PendingAction final : public Action {
public:
explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -85,7 +85,7 @@ template class PendingAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class TriggeredAction : public Action {
+template class TriggeredAction final : public Action {
public:
explicit TriggeredAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -95,7 +95,7 @@ template class TriggeredAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class AlarmControlPanelCondition : public Condition {
+template class AlarmControlPanelCondition final : public Condition {
public:
AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {}
bool check(const Ts &...x) override {
diff --git a/esphome/components/alpha3/alpha3.cpp b/esphome/components/alpha3/alpha3.cpp
index 6e82ec047d..048c365616 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 19d8e99331..5a5b01ac0b 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;
@@ -32,7 +31,7 @@ static const int16_t GENI_RESPONSE_POWER_OFFSET = 12;
static const int16_t GENI_RESPONSE_MOTOR_POWER_OFFSET = 16; // not sure
static const int16_t GENI_RESPONSE_MOTOR_SPEED_OFFSET = 20;
-class Alpha3 : public esphome::ble_client::BLEClientNode, public PollingComponent {
+class Alpha3 final : public esphome::ble_client::BLEClientNode, public PollingComponent {
public:
void setup() override;
void update() override;
@@ -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 1390b74975..8980a8dfc3 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 d7baf01cae..73dc0d8758 100644
--- a/esphome/components/am2315c/am2315c.h
+++ b/esphome/components/am2315c/am2315c.h
@@ -25,10 +25,9 @@
#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 {
+class AM2315C final : public PollingComponent, public i2c::I2CDevice {
public:
void dump_config() override;
void update() override;
@@ -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 7fef3bb3a6..5445ab3898 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 708dbb632e..f92156b154 100644
--- a/esphome/components/am2320/am2320.h
+++ b/esphome/components/am2320/am2320.h
@@ -4,10 +4,9 @@
#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 {
+class AM2320Component final : public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
void dump_config() override;
@@ -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 d70e638382..977185e5e3 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 35354af9ed..5df83747c0 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 2fa26d266a..4b096983a4 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";
@@ -115,13 +114,13 @@ void Am43Component::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->decoder_->decode(param->notify.value, param->notify.value_len);
if (this->decoder_->has_position()) {
- this->position = ((float) this->decoder_->position_ / 100.0);
+ this->position = ((float) this->decoder_->position_ / 100.0f);
if (!this->invert_position_)
this->position = 1 - this->position;
- if (this->position > 0.97)
- this->position = 1.0;
- if (this->position < 0.02)
- this->position = 0.0;
+ if (this->position > 0.97f)
+ this->position = 1.0f;
+ if (this->position < 0.02f)
+ this->position = 0.0f;
this->publish_state();
}
@@ -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 d6d020e98c..be7af59ade 100644
--- a/esphome/components/am43/cover/am43_cover.h
+++ b/esphome/components/am43/cover/am43_cover.h
@@ -10,12 +10,11 @@
#include
-namespace esphome {
-namespace am43 {
+namespace esphome::am43 {
namespace espbt = esphome::esp32_ble_tracker;
-class Am43Component : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component {
+class Am43Component final : public cover::Cover, public esphome::ble_client::BLEClientNode, public Component {
public:
void setup() override;
void loop() override;
@@ -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 b2bc3254e2..ddc3eadae9 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 195b96a19e..944681bb60 100644
--- a/esphome/components/am43/sensor/am43_sensor.h
+++ b/esphome/components/am43/sensor/am43_sensor.h
@@ -10,12 +10,11 @@
#include
-namespace esphome {
-namespace am43 {
+namespace esphome::am43 {
namespace espbt = esphome::esp32_ble_tracker;
-class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent {
+class Am43 final : public esphome::ble_client::BLEClientNode, public PollingComponent {
public:
void setup() override;
void update() override;
@@ -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 0b3bd0e472..d25c10021c 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 55a822b9b0..a4df00ff05 100644
--- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h
+++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h
@@ -5,10 +5,9 @@
#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 {
+class AnalogThresholdBinarySensor final : public Component, public binary_sensor::BinarySensor {
public:
void dump_config() override;
void setup() override;
@@ -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/__init__.py b/esphome/components/animation/__init__.py
index e9630f5266..0df7c56313 100644
--- a/esphome/components/animation/__init__.py
+++ b/esphome/components/animation/__init__.py
@@ -1,114 +1,36 @@
-import logging
+# ---------------------------------------------------------------------------
+# Legacy top-level `animation:` deprecation shim -- REMOVE this whole file after
+# 2027.1.0.
+#
+# Animations are now a platform of the `image:` component (`platform:
+# animation`); the real schema, actions and codegen live in `image.py`. This
+# module only keeps the deprecated top-level `animation:` key working during the
+# deprecation window: it reuses that schema/codegen and adds a one-shot
+# deprecation warning (with a pasteable migrated `image:` block) at validation
+# time. Deleting this file drops the top-level form entirely.
+# ---------------------------------------------------------------------------
-from esphome import automation
-import esphome.codegen as cg
import esphome.components.image as espImage
import esphome.config_validation as cv
-from esphome.const import CONF_ID, CONF_REPEAT
-_LOGGER = logging.getLogger(__name__)
+from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
-AUTO_LOAD = ["image"]
+AUTO_LOAD = ["image", "file"]
CODEOWNERS = ["@syndlex"]
DEPENDENCIES = ["display"]
MULTI_CONF = True
MULTI_CONF_NO_DEFAULT = True
-CONF_LOOP = "loop"
-CONF_START_FRAME = "start_frame"
-CONF_END_FRAME = "end_frame"
-CONF_FRAME = "frame"
+DOMAIN = "animation"
-animation_ns = cg.esphome_ns.namespace("animation")
+LEGACY_REMOVAL_VERSION = "2027.1.0"
-Animation_ = animation_ns.class_("Animation", espImage.Image_)
-
-# Actions
-NextFrameAction = animation_ns.class_(
- "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
-)
-PrevFrameAction = animation_ns.class_(
- "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
-)
-SetFrameAction = animation_ns.class_(
- "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
+_capture_legacy_entry, _warn_legacy_animation = (
+ espImage.legacy_platform_migration_warning(DOMAIN, DOMAIN, LEGACY_REMOVAL_VERSION)
)
-CONFIG_SCHEMA = cv.All(
- espImage.IMAGE_SCHEMA.extend(
- {
- cv.Required(CONF_ID): cv.declare_id(Animation_),
- cv.Optional(CONF_LOOP): cv.All(
- {
- cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
- cv.Optional(CONF_END_FRAME): cv.positive_int,
- cv.Optional(CONF_REPEAT): cv.positive_int,
- }
- ),
- },
- ),
- espImage.validate_settings,
-)
+CONFIG_SCHEMA = cv.All(_capture_legacy_entry, ANIMATION_CONFIG_SCHEMA)
+FINAL_VALIDATE_SCHEMA = _warn_legacy_animation
-NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
- {
- cv.GenerateID(): cv.use_id(Animation_),
- }
-)
-PREV_FRAME_SCHEMA = automation.maybe_simple_id(
- {
- cv.GenerateID(): cv.use_id(Animation_),
- }
-)
-SET_FRAME_SCHEMA = cv.Schema(
- {
- cv.GenerateID(): cv.use_id(Animation_),
- cv.Required(CONF_FRAME): cv.uint16_t,
- }
-)
-
-
-@automation.register_action(
- "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
-)
-@automation.register_action(
- "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
-)
-@automation.register_action(
- "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
-)
-async def animation_action_to_code(config, action_id, template_arg, args):
- paren = await cg.get_variable(config[CONF_ID])
- var = cg.new_Pvariable(action_id, template_arg, paren)
-
- if (frame := config.get(CONF_FRAME)) is not None:
- template_ = await cg.templatable(frame, args, cg.uint16)
- cg.add(var.set_frame(template_))
- return var
-
-
-async def to_code(config):
- (
- prog_arr,
- width,
- height,
- image_type,
- trans_value,
- frame_count,
- ) = await espImage.write_image(config, all_frames=True)
-
- var = cg.new_Pvariable(
- config[CONF_ID],
- prog_arr,
- width,
- height,
- frame_count,
- image_type,
- trans_value,
- )
- if loop_config := config.get(CONF_LOOP):
- start = loop_config[CONF_START_FRAME]
- end = loop_config.get(CONF_END_FRAME, frame_count)
- count = loop_config.get(CONF_REPEAT, -1)
- cg.add(var.set_loop(start, end, count))
+to_code = setup_animation
diff --git a/esphome/components/animation/animation.cpp b/esphome/components/animation/animation.cpp
index c2ae3b2f76..2a293adf1d 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 b33254df30..64cddbf09c 100644
--- a/esphome/components/animation/animation.h
+++ b/esphome/components/animation/animation.h
@@ -3,10 +3,9 @@
#include "esphome/core/automation.h"
-namespace esphome {
-namespace animation {
+namespace esphome::animation {
-class Animation : public image::Image {
+class Animation final : public image::Image {
public:
Animation(const uint8_t *data_start, int width, int height, uint32_t animation_frame_count, image::ImageType type,
image::Transparency transparent);
@@ -36,7 +35,7 @@ class Animation : public image::Image {
int loop_current_iteration_;
};
-template class AnimationNextFrameAction : public Action {
+template class AnimationNextFrameAction final : public Action {
public:
AnimationNextFrameAction(Animation *parent) : parent_(parent) {}
void play(const Ts &...x) override { this->parent_->next_frame(); }
@@ -45,7 +44,7 @@ template class AnimationNextFrameAction : public Action {
Animation *parent_;
};
-template class AnimationPrevFrameAction : public Action {
+template class AnimationPrevFrameAction final : public Action {
public:
AnimationPrevFrameAction(Animation *parent) : parent_(parent) {}
void play(const Ts &...x) override { this->parent_->prev_frame(); }
@@ -54,7 +53,7 @@ template class AnimationPrevFrameAction : public Action {
Animation *parent_;
};
-template class AnimationSetFrameAction : public Action {
+template class AnimationSetFrameAction final : public Action {
public:
AnimationSetFrameAction(Animation *parent) : parent_(parent) {}
TEMPLATABLE_VALUE(uint16_t, frame)
@@ -64,5 +63,4 @@ template class AnimationSetFrameAction : public Action {
Animation *parent_;
};
-} // namespace animation
-} // namespace esphome
+} // namespace esphome::animation
diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py
new file mode 100644
index 0000000000..95875fe2b0
--- /dev/null
+++ b/esphome/components/animation/image.py
@@ -0,0 +1,115 @@
+from esphome import automation
+import esphome.codegen as cg
+from esphome.components.const import CONF_LOOP
+from esphome.components.file.image import image_schema, write_image
+from esphome.components.image import Image_, validate_settings
+import esphome.config_validation as cv
+from esphome.const import CONF_ID, CONF_REPEAT
+from esphome.types import ConfigType
+
+CODEOWNERS = ["@syndlex"]
+AUTO_LOAD = ["file"]
+DEPENDENCIES = ["display"]
+
+CONF_START_FRAME = "start_frame"
+CONF_END_FRAME = "end_frame"
+CONF_FRAME = "frame"
+
+animation_ns = cg.esphome_ns.namespace("animation")
+
+Animation_ = animation_ns.class_("Animation", Image_)
+
+# Actions
+NextFrameAction = animation_ns.class_(
+ "AnimationNextFrameAction", automation.Action, cg.Parented.template(Animation_)
+)
+PrevFrameAction = animation_ns.class_(
+ "AnimationPrevFrameAction", automation.Action, cg.Parented.template(Animation_)
+)
+SetFrameAction = animation_ns.class_(
+ "AnimationSetFrameAction", automation.Action, cg.Parented.template(Animation_)
+)
+
+ANIMATION_SCHEMA = image_schema(Animation_).extend(
+ {
+ cv.Optional(CONF_LOOP): cv.All(
+ {
+ cv.Optional(CONF_START_FRAME, default=0): cv.positive_int,
+ cv.Optional(CONF_END_FRAME): cv.positive_int,
+ cv.Optional(CONF_REPEAT): cv.positive_int,
+ }
+ ),
+ },
+)
+
+# Shared schema used by both the (deprecated) top-level `animation:` key and the
+# `image:` `platform: animation` entry.
+ANIMATION_CONFIG_SCHEMA = cv.All(ANIMATION_SCHEMA, validate_settings)
+
+
+NEXT_FRAME_SCHEMA = automation.maybe_simple_id(
+ {
+ cv.GenerateID(): cv.use_id(Animation_),
+ }
+)
+PREV_FRAME_SCHEMA = automation.maybe_simple_id(
+ {
+ cv.GenerateID(): cv.use_id(Animation_),
+ }
+)
+SET_FRAME_SCHEMA = cv.Schema(
+ {
+ cv.GenerateID(): cv.use_id(Animation_),
+ cv.Required(CONF_FRAME): cv.uint16_t,
+ }
+)
+
+
+@automation.register_action(
+ "animation.next_frame", NextFrameAction, NEXT_FRAME_SCHEMA, synchronous=True
+)
+@automation.register_action(
+ "animation.prev_frame", PrevFrameAction, PREV_FRAME_SCHEMA, synchronous=True
+)
+@automation.register_action(
+ "animation.set_frame", SetFrameAction, SET_FRAME_SCHEMA, synchronous=True
+)
+async def animation_action_to_code(config, action_id, template_arg, args):
+ paren = await cg.get_variable(config[CONF_ID])
+ var = cg.new_Pvariable(action_id, template_arg, paren)
+
+ if (frame := config.get(CONF_FRAME)) is not None:
+ template_ = await cg.templatable(frame, args, cg.uint16)
+ cg.add(var.set_frame(template_))
+ return var
+
+
+async def setup_animation(config: ConfigType) -> None:
+ (
+ prog_arr,
+ width,
+ height,
+ image_type,
+ trans_value,
+ frame_count,
+ ) = await write_image(config, all_frames=True)
+
+ var = cg.new_Pvariable(
+ config[CONF_ID],
+ prog_arr,
+ width,
+ height,
+ frame_count,
+ image_type,
+ trans_value,
+ )
+ if loop_config := config.get(CONF_LOOP):
+ start = loop_config[CONF_START_FRAME]
+ end = loop_config.get(CONF_END_FRAME, frame_count)
+ count = loop_config.get(CONF_REPEAT, -1)
+ cg.add(var.set_loop(start, end, count))
+
+
+CONFIG_SCHEMA = ANIMATION_CONFIG_SCHEMA
+
+to_code = setup_animation
diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp
index f21230b075..6e382872e2 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 2e43ebfb98..49b1100c37 100644
--- a/esphome/components/anova/anova.h
+++ b/esphome/components/anova/anova.h
@@ -10,15 +10,14 @@
#include
-namespace esphome {
-namespace anova {
+namespace esphome::anova {
namespace espbt = esphome::esp32_ble_tracker;
static const uint16_t ANOVA_SERVICE_UUID = 0xFFE0;
static const uint16_t ANOVA_CHARACTERISTIC_UUID = 0xFFE1;
-class Anova : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent {
+class Anova final : public climate::Climate, public esphome::ble_client::BLEClientNode, public PollingComponent {
public:
void setup() override;
void loop() override;
@@ -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 fef4f1d852..806a441dcd 100644
--- a/esphome/components/anova/anova_base.cpp
+++ b/esphome/components/anova/anova_base.cpp
@@ -2,12 +2,13 @@
#include
#include
-namespace esphome {
-namespace anova {
+#include "esphome/core/alloc_helpers.h"
-float ftoc(float f) { return (f - 32.0) * (5.0f / 9.0f); }
+namespace esphome::anova {
-float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0; }
+float ftoc(float f) { return (f - 32.0f) * (5.0f / 9.0f); }
+
+float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0f; }
AnovaPacket *AnovaCodec::clean_packet_() {
this->packet_.length = strlen((char *) this->packet_.data);
@@ -105,14 +106,14 @@ void AnovaCodec::decode(const uint8_t *data, uint16_t length) {
}
case READ_TARGET_TEMPERATURE:
case SET_TARGET_TEMPERATURE: {
- this->target_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f);
+ this->target_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); // NOLINT
if (this->fahrenheit_)
this->target_temp_ = ftoc(this->target_temp_);
this->has_target_temp_ = true;
break;
}
case READ_CURRENT_TEMPERATURE: {
- this->current_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f);
+ this->current_temp_ = parse_number(str_until(buf, '\r')).value_or(0.0f); // NOLINT
if (this->fahrenheit_)
this->current_temp_ = ftoc(this->current_temp_);
this->has_current_temp_ = true;
@@ -130,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 b831157849..b3ed0f01a0 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 fb3adde868..57a502ca42 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 44362908c8..f971290cdd 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,
@@ -40,7 +39,7 @@ enum AmbientLightGain : uint8_t {
};
static const uint8_t AMBIENT_LIGHT_GAIN_VALUES[] = {1, 3, 6, 9, 18};
-class APDS9306 : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice {
+class APDS9306 final : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice {
public:
void setup() override;
float get_setup_priority() const override { return setup_priority::BUS; }
@@ -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 a07175f2c9..da8029b4ee 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 4574b70a42..bfa64bcc74 100644
--- a/esphome/components/apds9960/apds9960.h
+++ b/esphome/components/apds9960/apds9960.h
@@ -10,10 +10,9 @@
#include "esphome/components/binary_sensor/binary_sensor.h"
#endif
-namespace esphome {
-namespace apds9960 {
+namespace esphome::apds9960 {
-class APDS9960 : public PollingComponent, public i2c::I2CDevice {
+class APDS9960 final : public PollingComponent, public i2c::I2CDevice {
#ifdef USE_SENSOR
SUB_SENSOR(red)
SUB_SENSOR(green)
@@ -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 84589d540d..0719cee352 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"
@@ -94,6 +112,23 @@ CONF_MAX_SEND_QUEUE = "max_send_queue"
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
+def _register_provisioning_source(config: ConfigType) -> ConfigType:
+ """Register the API as a provisioning source when encryption is enabled.
+
+ With no ``key`` the device boots unprovisioned and is set up on first
+ connection; a YAML ``key`` means it is born provisioned. Either way the API
+ drives the provisioning manager, so it counts as a source for `provisioning:`.
+ A hardcoded ``key`` is reported so `provisioning:` can warn about it.
+ """
+ if (encryption := config.get(CONF_ENCRYPTION)) is not None:
+ from esphome.components import provisioning
+
+ provisioning.register_source("api")
+ if CONF_KEY in encryption:
+ provisioning.report_hardcoded_credentials("api")
+ return config
+
+
def validate_encryption_key(value):
value = cv.string_strict(value)
try:
@@ -216,7 +251,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),
}
)
@@ -282,21 +317,23 @@ CONFIG_SCHEMA = cv.All(
CONF_LISTEN_BACKLOG,
esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets
esp32=4, # More RAM (520KB), BSD sockets
- rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266
+ rp2=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266
bk72xx=4, # Moderate RAM, BSD-style sockets
rtl87xx=4, # Moderate RAM, BSD-style sockets
host=4, # Abundant resources
ln882x=4, # Moderate RAM
+ nrf52=4, # ~256KB RAM, BSD sockets
): cv.int_range(min=1, max=10),
cv.SplitDefault(
CONF_MAX_CONNECTIONS,
esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes
- esp32=8, # 520KB RAM available
- rp2040=4, # 264KB RAM but LWIP constraints
- bk72xx=8, # Moderate RAM
- rtl87xx=8, # Moderate RAM
+ esp32=5, # 520KB RAM available
+ rp2=4, # 264KB RAM but LWIP constraints
+ bk72xx=5, # Moderate RAM
+ rtl87xx=5, # Moderate RAM
host=8, # Abundant resources
- ln882x=8, # Moderate RAM
+ ln882x=5, # Moderate RAM
+ nrf52=4, # ~256KB RAM, BSD sockets, Thread (single HA controller)
): cv.int_range(min=1, max=20),
# Maximum queued send buffers per connection before dropping connection
# Each buffer uses ~8-12 bytes overhead plus actual message size
@@ -306,7 +343,7 @@ CONFIG_SCHEMA = cv.All(
CONF_MAX_SEND_QUEUE,
esp8266=4, # Limited RAM, need to fail fast
esp32=8, # More RAM, can buffer more
- rp2040=8, # Moderate RAM
+ rp2=8, # Moderate RAM
bk72xx=8, # Moderate RAM
nrf52=8, # Moderate RAM
rtl87xx=8, # Moderate RAM
@@ -317,6 +354,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_consume_api_sockets,
+ _register_provisioning_source,
)
@@ -336,8 +374,7 @@ async def to_code(config: ConfigType) -> None:
cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY]))
if CONF_LISTEN_BACKLOG in config:
cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG]))
- if CONF_MAX_CONNECTIONS in config:
- cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS]))
+ cg.add_define("MAX_API_CONNECTIONS", config[CONF_MAX_CONNECTIONS])
cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE])
# Set USE_API_USER_DEFINED_ACTIONS if any services are enabled
@@ -382,17 +419,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)
@@ -448,8 +488,11 @@ async def to_code(config: ConfigType) -> None:
cg.add_define("USE_API_NOISE_PSK_FROM_YAML")
else:
# No key provided, but encryption desired
- # This will allow a plaintext client to provide a noise key,
- # send it to the device, and then switch to noise.
+ # Until a key is set, the device accepts both Noise connections
+ # using the well-known all-zeros PSK (preferred: the key travels
+ # encrypted, protecting against passive sniffing) and plaintext
+ # connections (deprecated, remove after 2027.2.0) so a client can
+ # provide a noise key and the device then switches to noise only.
# The key will be saved in flash and used for future connections
# and plaintext disabled. Only a factory reset can remove it.
cg.add_define("USE_API_PLAINTEXT")
@@ -518,17 +561,20 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
)
+# synchronous=False: when on_success/on_error is configured, play() stores the
+# trigger args until the HomeassistantActionResponse arrives, so non-owning args
+# (StringRef into the API receive buffer) must not be used.
@automation.register_action(
"homeassistant.action",
HomeAssistantServiceCallAction,
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
- synchronous=True,
+ synchronous=False,
)
@automation.register_action(
"homeassistant.service",
HomeAssistantServiceCallAction,
HOMEASSISTANT_ACTION_ACTION_SCHEMA,
- synchronous=True,
+ synchronous=False,
)
async def homeassistant_service_to_code(
config: ConfigType,
@@ -622,6 +668,8 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
)
+# synchronous=True is safe here: the event schema has no on_success/on_error,
+# so play() never stores the trigger args.
@automation.register_action(
"homeassistant.event",
HomeAssistantServiceCallAction,
diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto
index 4cd61f0d5e..4b3df62ec4 100644
--- a/esphome/components/api/api.proto
+++ b/esphome/components/api/api.proto
@@ -158,6 +158,16 @@ message AuthenticationResponse {
bool invalid_password = 1;
}
+// Reason a party is requesting the connection be closed.
+enum DisconnectReason {
+ // No specific reason / not provided (default for older peers).
+ DISCONNECT_REASON_UNSPECIFIED = 0;
+ // The device's provisioning window has expired. The device must be reset
+ // (power-cycled) to reopen the provisioning window before it will accept a
+ // connection again.
+ DISCONNECT_REASON_PROVISIONING_CLOSED = 1;
+}
+
// Request to close the connection.
// Can be sent by both the client and server
message DisconnectRequest {
@@ -166,6 +176,10 @@ message DisconnectRequest {
option (no_delay) = true;
// Do not close the connection before the acknowledgement arrives
+
+ // Optional reason the connection is being closed. Older peers that do not
+ // send this field will report DISCONNECT_REASON_UNSPECIFIED (0).
+ DisconnectReason reason = 1;
}
message DisconnectResponse {
@@ -296,6 +310,11 @@ message DeviceInfoResponse {
// Serial proxy instance metadata
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
+
+ // Device is unprovisioned and accepts Noise handshakes with the well-known
+ // all-zeros PSK, so the api encryption key can be provisioned without being
+ // sent in plaintext (protects against passive sniffing, not active MITM)
+ bool api_encryption_provisionable = 26 [(field_ifdef) = "USE_API_NOISE"];
}
message ListEntitiesRequest {
@@ -911,7 +930,7 @@ message GetTimeResponse {
option (no_delay) = true;
fixed32 epoch_seconds = 1;
- string timezone = 2 [deprecated = true]; // Use parsed_timezone instead. Remove before 2026.9.0.
+ string timezone = 2;
ParsedTimezone parsed_timezone = 3;
}
@@ -1025,6 +1044,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 +1136,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 +1230,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 +1438,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 +1658,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 +2045,7 @@ message VoiceAssistantAudio {
bytes data = 1 [(pointer_to_buffer) = true];
bool end = 2;
+ bytes data2 = 3 [(pointer_to_buffer) = true];
}
enum VoiceAssistantTimerEvent {
@@ -2500,6 +2531,7 @@ message ZWaveProxyFrame {
option (source) = SOURCE_BOTH;
option (ifdef) = "USE_ZWAVE_PROXY";
option (no_delay) = true;
+ option (speed_optimized) = true;
bytes data = 1;
}
@@ -2544,27 +2576,51 @@ message ListEntitiesInfraredResponse {
message InfraredRFTransmitRawTimingsRequest {
option (id) = 136;
option (source) = SOURCE_CLIENT;
- option (ifdef) = "USE_IR_RF";
+ option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
- fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
- uint32 carrier_frequency = 3; // Carrier frequency in Hz
- uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.)
+ fixed32 key = 2 [(force) = true]; // Key identifying the transmitter instance
+ uint32 carrier_frequency = 3; // Carrier frequency in Hz
+ uint32 repeat_count = 4; // Number of times to transmit (1 = once, 2 = twice, etc.)
repeated sint32 timings = 5 [packed = true, (packed_buffer) = true]; // Raw timings in microseconds (zigzag-encoded): positive = mark (LED/TX on), negative = space (LED/TX off)
+ uint32 modulation = 6; // RadioFrequencyModulation enum value (0 = OOK; ignored for IR entities)
}
// Event message for received infrared/RF data
message InfraredRFReceiveEvent {
option (id) = 137;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_IR_RF";
+ 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
+ fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance
repeated sint32 timings = 3 [packed = true, (container_pointer_no_template) = "std::vector"]; // Raw timings in microseconds (zigzag-encoded): alternating mark/space periods
}
+// ==================== RADIO FREQUENCY ====================
+
+// Lists available radio frequency entity instances
+message ListEntitiesRadioFrequencyResponse {
+ option (id) = 148;
+ option (base_class) = "InfoResponseProtoMessage";
+ option (source) = SOURCE_SERVER;
+ option (ifdef) = "USE_RADIO_FREQUENCY";
+
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
+ string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
+ bool disabled_by_default = 5;
+ EntityCategory entity_category = 6;
+ uint32 device_id = 7 [(field_ifdef) = "USE_DEVICES"];
+ uint32 capabilities = 8; // Bitmask of RadioFrequencyCapabilityFlags: bit 0 = transmitter, bit 1 = receiver
+ uint32 frequency_min = 9; // Minimum tunable frequency in Hz; if min == max (non-zero): fixed frequency; 0 = unspecified
+ uint32 frequency_max = 10; // Maximum tunable frequency in Hz; 0 = unspecified
+ uint32 supported_modulations = 11; // Bitmask of supported RadioFrequencyModulation values (bit N = modulation N supported)
+}
+
// ==================== SERIAL PROXY ====================
enum SerialProxyParity {
@@ -2593,6 +2649,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 702d01727d..1f7f59128a 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
@@ -24,6 +25,9 @@
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
#include "esphome/core/version.h"
+#ifdef USE_PROVISIONING
+#include "esphome/components/provisioning/provisioning.h"
+#endif
#ifdef USE_DEEP_SLEEP
#include "esphome/components/deep_sleep/deep_sleep_component.h"
@@ -49,6 +53,9 @@
#ifdef USE_INFRARED
#include "esphome/components/infrared/infrared.h"
#endif
+#ifdef USE_RADIO_FREQUENCY
+#include "esphome/components/radio_frequency/radio_frequency.h"
+#endif
namespace esphome::api {
@@ -100,6 +107,12 @@ static const int CAMERA_STOP_STREAM = 5000;
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id); \
if ((entity_var) == nullptr) \
return;
+
+// Helper macro for multi-entity dispatch: looks up an entity by key and device_id without early return or make_call().
+// Use when multiple entity types must be checked in sequence (at most one will match).
+#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \
+ entity_type *entity_var = App.get_##getter_name##_by_key(msg.key, msg.device_id)
+
#else // No device support, use simpler macros
// Helper macro for entity command handlers - gets entity by key, returns if not found, and creates call
// object
@@ -115,6 +128,12 @@ static const int CAMERA_STOP_STREAM = 5000;
entity_type *entity_var = App.get_##getter_name##_by_key(msg.key); \
if ((entity_var) == nullptr) \
return;
+
+// Helper macro for multi-entity dispatch: looks up an entity by key without early return or make_call().
+// Use when multiple entity types must be checked in sequence (at most one will match).
+#define ENTITY_COMMAND_LOOKUP(entity_type, entity_var, getter_name) \
+ entity_type *entity_var = App.get_##getter_name##_by_key(msg.key)
+
#endif // USE_DEVICES
APIConnection::APIConnection(std::unique_ptr sock, APIServer *parent) : parent_(parent) {
@@ -179,6 +198,29 @@ APIConnection::~APIConnection() {
#endif
}
+#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
+void APIConnection::upgrade_helper_to_noise_() {
+ // The client opened with a Noise hello while this device has no encryption
+ // key set. Replace the plaintext helper with a Noise helper so the key can
+ // be provisioned over an encrypted channel: the noise context PSK is all
+ // zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519
+ // exchange, so a passive listener cannot read the session. A publicly known
+ // PSK authenticates nobody; this protects against sniffing only.
+ auto *plaintext = static_cast(this->helper_.get());
+ uint8_t header[3];
+ uint8_t header_len = plaintext->get_consumed_header(header);
+ auto *noise = new APINoiseFrameHelper(plaintext->release_socket_for_switch(), this->parent_->get_noise_ctx());
+ // Carry over the peername-based client name (Hello has not arrived yet)
+ const char *name = plaintext->get_client_name();
+ noise->set_client_name(name, strlen(name));
+ this->helper_.reset(noise); // destroys the plaintext helper
+ APIError err = noise->init_from_handoff(header, header_len);
+ if (err != APIError::OK) {
+ this->fatal_error_with_log_(LOG_STR("Noise handoff failed"), err);
+ }
+}
+#endif // USE_API_NOISE && USE_API_PLAINTEXT
+
void APIConnection::destroy_active_iterator_() {
switch (this->active_iterator_) {
case ActiveIterator::LIST_ENTITIES:
@@ -237,6 +279,15 @@ void APIConnection::loop() {
// No more data available
break;
} else if (err != APIError::OK) {
+#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
+ // Checked inside the error branch to keep the hot err == OK path
+ // free of it; this can only fire on the first bytes of a plaintext
+ // helper on an unprovisioned device
+ if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) {
+ this->upgrade_helper_to_noise_();
+ return;
+ }
+#endif
this->fatal_error_with_log_(LOG_STR("Reading failed"), err);
return;
} else {
@@ -359,7 +410,7 @@ void APIConnection::finalize_iterator_sync_() {
void APIConnection::process_iterator_batch_(ComponentIterator &iterator) {
size_t initial_size = this->deferred_batch_.size();
- size_t max_batch = this->get_max_batch_size_();
+ size_t max_batch = MAX_INITIAL_PER_BATCH;
while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
iterator.advance();
}
@@ -402,16 +453,6 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
// Set common fields that are shared by all entity types
msg.key = entity->get_object_id_hash();
- // API 1.14+ clients compute object_id client-side from the entity name
- // For older clients, we must send object_id for backward compatibility
- // See: https://github.com/esphome/backlog/issues/76
- // TODO: Remove this backward compat code before 2026.7.0 - all clients should support API 1.14 by then
- // Buffer must remain in scope until encode_to_buffer is called
- char object_id_buf[OBJECT_ID_MAX_LEN];
- if (!conn->client_supports_api_version(1, 14)) {
- msg.object_id = entity->get_object_id_to(object_id_buf);
- }
-
if (entity->has_own_name()) {
msg.name = entity->get_name();
}
@@ -1153,13 +1194,11 @@ 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
- // Only apply if the sender provided pre-parsed timezone data.
- // Old clients (before 2026.3.0) only send the timezone string without the parsed struct,
- // so all parsed_timezone fields default to zero — skip to keep the codegen-configured timezone.
- // For actual UTC (all zeros), this also skips, which is harmless since UTC is the default.
- // Eventually the timezone string will be removed and only the struct will be sent.
- {
+#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.
+ // For UTC (all zeros), string parsing produces the same result, so the fallback is equivalent.
const auto &pt = value.parsed_timezone;
if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) {
time::ParsedTimezone tz{};
@@ -1178,6 +1217,8 @@ void APIConnection::on_get_time_response(const GetTimeResponse &value) {
tz.dst_end.week = static_cast(pt.dst_end.week);
tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week);
time::set_global_tz(tz);
+ } else {
+ homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size());
}
}
#endif
@@ -1290,6 +1331,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);
}
@@ -1339,7 +1383,7 @@ void APIConnection::on_voice_assistant_set_configuration(const VoiceAssistantSet
#ifdef USE_ZWAVE_PROXY
void APIConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) {
- zwave_proxy::global_zwave_proxy->send_frame(msg.data, msg.data_len);
+ zwave_proxy::global_zwave_proxy->send_frame(this, msg.data, msg.data_len);
}
void APIConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) {
@@ -1471,19 +1515,36 @@ uint16_t APIConnection::try_send_event_info(EntityBase *entity, APIConnection *c
}
#endif
-#ifdef USE_IR_RF
+#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) {
- // TODO: When RF is implemented, add a field to the message to distinguish IR vs RF
- // and dispatch to the appropriate entity type based on that field.
+ // Dispatch by key: infrared entities are checked first, then radio frequency entities.
+ // The key is unique across all entity instances on a device, so at most one lookup will succeed.
#ifdef USE_INFRARED
- ENTITY_COMMAND_MAKE_CALL(infrared::Infrared, infrared, infrared)
- call.set_carrier_frequency(msg.carrier_frequency);
- call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
- call.set_repeat_count(msg.repeat_count);
- call.perform();
+ ENTITY_COMMAND_LOOKUP(infrared::Infrared, infrared, infrared);
+ if (infrared != nullptr) {
+ auto call = infrared->make_call();
+ call.set_carrier_frequency(msg.carrier_frequency);
+ call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
+ call.set_repeat_count(msg.repeat_count);
+ call.perform();
+ return;
+ }
+#endif
+#ifdef USE_RADIO_FREQUENCY
+ ENTITY_COMMAND_LOOKUP(radio_frequency::RadioFrequency, radio_frequency, radio_frequency);
+ if (radio_frequency != nullptr) {
+ auto call = radio_frequency->make_call();
+ call.set_frequency(msg.carrier_frequency);
+ call.set_modulation(static_cast(msg.modulation));
+ call.set_repeat_count(msg.repeat_count);
+ call.set_raw_timings_packed(msg.timings_data_, msg.timings_length_, msg.timings_count_);
+ call.perform();
+ }
#endif
}
+#endif
+#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); }
#endif
@@ -1580,6 +1641,19 @@ uint16_t APIConnection::try_send_infrared_info(EntityBase *entity, APIConnection
}
#endif
+#ifdef USE_RADIO_FREQUENCY
+uint16_t APIConnection::try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn,
+ uint32_t remaining_size) {
+ auto *rf = static_cast(entity);
+ ListEntitiesRadioFrequencyResponse msg;
+ msg.capabilities = rf->get_capability_flags();
+ msg.frequency_min = rf->get_traits().get_frequency_min_hz();
+ msg.frequency_max = rf->get_traits().get_frequency_max_hz();
+ msg.supported_modulations = rf->get_traits().get_supported_modulations();
+ return fill_and_encode_entity_info(rf, msg, conn, remaining_size);
+}
+#endif
+
#ifdef USE_UPDATE
bool APIConnection::send_update_state(update::UpdateEntity *update) {
return this->send_message_smart_(update, UpdateStateResponse::MESSAGE_TYPE, UpdateStateResponse::ESTIMATED_SIZE);
@@ -1672,12 +1746,6 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(),
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
- // TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal)
- if (!this->client_supports_api_version(1, 14)) {
- ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(),
- this->client_api_version_major_, this->client_api_version_minor_);
- }
-
HelloResponse resp;
resp.api_version_major = 1;
resp.api_version_minor = 14;
@@ -1685,6 +1753,19 @@ bool APIConnection::send_hello_response_(const HelloRequest &msg) {
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
+#ifdef USE_PROVISIONING
+ if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
+ // The provisioning window has closed without the device being provisioned.
+ // Acknowledge the hello so the client can read the server name, then request
+ // disconnect with the reason. Authentication is intentionally not completed.
+ this->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Provisioning closed; rejecting connection"));
+ this->send_message(resp);
+ DisconnectRequest req;
+ req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
+ return this->send_message(req);
+ }
+#endif
+
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
this->complete_authentication_();
@@ -1720,7 +1801,7 @@ bool APIConnection::send_device_info_response_() {
// Manufacturer string - define once, handle ESP8266 PROGMEM separately
#if defined(USE_ESP8266) || defined(USE_ESP32)
#define ESPHOME_MANUFACTURER "Espressif"
-#elif defined(USE_RP2040)
+#elif defined(USE_RP2)
#define ESPHOME_MANUFACTURER "Raspberry Pi"
#elif defined(USE_BK72XX)
#define ESPHOME_MANUFACTURER "Beken"
@@ -1805,6 +1886,12 @@ bool APIConnection::send_device_info_response_() {
#endif
#ifdef USE_API_NOISE
resp.api_encryption_supported = true;
+#ifndef USE_API_NOISE_PSK_FROM_YAML
+ // No key from YAML: while no key is set, the key can be provisioned over a
+ // zero-PSK Noise connection. Gated on the YAML define (not the plaintext
+ // one) so this advertisement survives the plaintext removal in 2027.2.0.
+ resp.api_encryption_provisionable = !this->parent_->get_noise_ctx().has_psk();
+#endif
#endif
#ifdef USE_DEVICES
size_t device_index = 0;
@@ -1835,7 +1922,8 @@ void APIConnection::on_hello_request(const HelloRequest &msg) {
this->on_fatal_error();
}
}
-void APIConnection::on_disconnect_request() {
+void APIConnection::on_disconnect_request(const DisconnectRequest & /*msg*/) {
+ // The reason is informational when a client disconnects us; we always ack and close.
if (!this->send_disconnect_response_()) {
this->on_fatal_error();
}
@@ -1963,6 +2051,15 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
NoiseEncryptionSetKeyResponse resp;
resp.success = false;
+#ifdef USE_PROVISIONING
+ // Refuse to set a key once the provisioning window has closed (defense in depth;
+ // such connections are already rejected at hello).
+ if (provisioning::global_provisioning_manager != nullptr && provisioning::global_provisioning_manager->closed()) {
+ ESP_LOGW(TAG, "Provisioning closed; rejecting key set");
+ return this->send_message(resp);
+ }
+#endif
+
psk_t psk{};
if (msg.key_len == 0) {
if (this->parent_->clear_noise_psk(true)) {
@@ -1972,10 +2069,21 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
}
} else if (base64_decode(msg.key, msg.key_len, psk.data(), psk.size()) != psk.size()) {
ESP_LOGW(TAG, "Invalid encryption key length");
+ } else if (APINoiseContext::is_all_zeros(psk)) {
+ // Accepting the reserved provisioning PSK would report success without
+ // enabling encryption (or silently clear an existing key)
+ ESP_LOGW(TAG, "Rejecting all-zero encryption key");
} else if (!this->parent_->save_noise_psk(psk, true)) {
ESP_LOGW(TAG, "Failed to save encryption key");
} else {
resp.success = true;
+#ifdef USE_API_PLAINTEXT
+ if (this->helper_->frame_footer_size() == 0) {
+ // Plaintext transport has no frame footer; Noise always has the MAC footer.
+ // Remove after 2027.2.0 together with plaintext support on keyless devices.
+ ESP_LOGW(TAG, "Key received over plaintext; deprecated, will be removed in 2027.2.0");
+ }
+#endif
}
return this->send_message(resp);
@@ -2341,6 +2449,9 @@ uint16_t APIConnection::dispatch_message_(const DeferredBatch::BatchItem &item,
#ifdef USE_INFRARED
CASE_INFO_ONLY(infrared, ListEntitiesInfraredResponse)
#endif
+#ifdef USE_RADIO_FREQUENCY
+ CASE_INFO_ONLY(radio_frequency, ListEntitiesRadioFrequencyResponse)
+#endif
#ifdef USE_EVENT
CASE_INFO_ONLY(event, ListEntitiesEventResponse)
#endif
diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h
index 7d08797090..7df7ea1429 100644
--- a/esphome/components/api/api_connection.h
+++ b/esphome/components/api/api_connection.h
@@ -11,14 +11,15 @@
#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
#include "esphome/components/esp32/crash_handler.h"
#endif
-#ifdef USE_RP2040_CRASH_HANDLER
-#include "esphome/components/rp2040/crash_handler.h"
+#ifdef USE_RP2_CRASH_HANDLER
+#include "esphome/components/rp2/crash_handler.h"
#endif
#ifdef USE_ESP8266_CRASH_HANDLER
#include "esphome/components/esp8266/crash_handler.h"
@@ -36,13 +37,13 @@ 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
-// API 1.14+ clients compute object_id client-side, so messages are smaller and we can fit more per batch
-// TODO: Remove MAX_INITIAL_PER_BATCH_LEGACY before 2026.7.0 - all clients should support API 1.14 by then
-static constexpr size_t MAX_INITIAL_PER_BATCH_LEGACY = 24; // For clients < API 1.14 (includes object_id)
-static constexpr size_t MAX_INITIAL_PER_BATCH = 34; // For clients >= API 1.14 (no object_id)
+static constexpr size_t MAX_INITIAL_PER_BATCH = 34;
// Verify MAX_MESSAGES_PER_BATCH (defined in api_frame_helper.h) can hold the initial batch
static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_PER_BATCH,
"MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_PER_BATCH");
@@ -165,10 +166,14 @@ class APIConnection final : public APIServerConnectionBase {
#endif
bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len);
#ifdef USE_API_HOMEASSISTANT_SERVICES
- void send_homeassistant_action(const HomeassistantActionRequest &call) {
+ // Returns whether this client has subscribed to Home Assistant actions; the message
+ // is only handed to the send path when subscribed. A true return does not guarantee
+ // delivery - it lets the caller warn when no connected client has the subscription.
+ bool send_homeassistant_action(const HomeassistantActionRequest &call) {
if (!this->flags_.service_call_subscription)
- return;
+ return false;
this->send_message(call);
+ return true;
}
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
@@ -223,7 +228,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_water_heater_command_request(const WaterHeaterCommandRequest &msg);
#endif
-#ifdef USE_IR_RF
+#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg);
void send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg);
#endif
@@ -258,7 +263,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_get_time_response(const GetTimeResponse &value);
#endif
void on_hello_request(const HelloRequest &msg);
- void on_disconnect_request();
+ void on_disconnect_request(const DisconnectRequest &msg);
void on_ping_request();
void on_device_info_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
@@ -278,8 +283,8 @@ class APIConnection final : public APIServerConnectionBase {
esp32::crash_handler_log();
esp32::crash_handler_clear();
#endif
-#ifdef USE_RP2040_CRASH_HANDLER
- rp2040::crash_handler_log();
+#ifdef USE_RP2_CRASH_HANDLER
+ rp2::crash_handler_log();
#endif
#ifdef USE_ESP8266_CRASH_HANDLER
esp8266::crash_handler_log();
@@ -411,44 +416,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.
@@ -511,13 +482,6 @@ class APIConnection final : public APIServerConnectionBase {
inline bool check_voice_assistant_api_connection_() const;
#endif
- // Get the max batch size based on client API version
- // API 1.14+ clients don't receive object_id, so messages are smaller and more fit per batch
- // TODO: Remove this method before 2026.7.0 and use MAX_INITIAL_PER_BATCH directly
- size_t get_max_batch_size_() const {
- return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY;
- }
-
// Send keepalive ping or disconnect unresponsive client.
// Cold path — extracted from loop() to reduce instruction cache pressure.
void __attribute__((noinline)) check_keepalive_(uint32_t now);
@@ -612,6 +576,9 @@ class APIConnection final : public APIServerConnectionBase {
#ifdef USE_INFRARED
static uint16_t try_send_infrared_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
#endif
+#ifdef USE_RADIO_FREQUENCY
+ static uint16_t try_send_radio_frequency_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size);
+#endif
#ifdef USE_EVENT
static uint16_t try_send_event_response(event::Event *event, StringRef event_type, APIConnection *conn,
uint32_t remaining_size);
@@ -663,6 +630,11 @@ class APIConnection final : public APIServerConnectionBase {
void destroy_active_iterator_();
void begin_iterator_(ActiveIterator type);
void finalize_iterator_sync_();
+#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
+ // Swap the plaintext helper for a Noise helper after the client opened
+ // with a Noise hello on an unprovisioned device (zero-PSK provisioning).
+ void upgrade_helper_to_noise_();
+#endif
#ifdef USE_CAMERA
std::unique_ptr image_reader_;
#endif
@@ -789,7 +761,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 0000000000..1dd8a162e4
--- /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_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp
index 90353b6402..7425304766 100644
--- a/esphome/components/api/api_frame_helper.cpp
+++ b/esphome/components/api/api_frame_helper.cpp
@@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) {
return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE");
}
#endif
+ // PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before
+ // any logging can happen, so it intentionally has no entry here.
return LOG_STR("UNKNOWN");
}
diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h
index f98eca8076..9cae6ba92e 100644
--- a/esphome/components/api/api_frame_helper.h
+++ b/esphome/components/api/api_frame_helper.h
@@ -88,6 +88,11 @@ enum class APIError : uint16_t {
HANDSHAKESTATE_SPLIT_FAILED = 1020,
BAD_HANDSHAKE_ERROR_BYTE = 1021,
#endif
+#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
+ // Not an error: an unprovisioned device received a Noise client hello on a
+ // plaintext connection; the caller must hand the socket off to a Noise helper.
+ PROTOCOL_SWITCH_TO_NOISE = 1023,
+#endif
};
const LogString *api_error_to_logstr(APIError err);
@@ -200,6 +205,12 @@ class APIFrameHelper {
// or track that they stopped early and retry without this check.
// See Socket::ready() for details.
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
+#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
+ // Move the socket out of this helper so a replacement helper can take it
+ // over (plaintext to Noise handoff on unprovisioned devices). The drained
+ // helper must be destroyed right after.
+ std::unique_ptr release_socket_for_switch() { return std::move(this->socket_); }
+#endif
// Release excess memory from internal buffers after initial sync
void release_buffers() {
// rx_buf_: Safe to clear only if no partial read in progress.
diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp
index 6dba64a7f8..225bac51a6 100644
--- a/esphome/components/api/api_frame_helper_noise.cpp
+++ b/esphome/components/api/api_frame_helper_noise.cpp
@@ -109,6 +109,40 @@ APIError APINoiseFrameHelper::init() {
state_ = State::CLIENT_HELLO;
return APIError::OK;
}
+#ifdef USE_API_PLAINTEXT
+APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
+ APIError err = this->init();
+ if (err != APIError::OK) {
+ return err;
+ }
+ // Seed the header bytes the plaintext helper consumed before detecting the
+ // Noise indicator; try_read_frame_ resumes from rx_header_buf_len_.
+ std::memcpy(this->rx_header_buf_, header, header_len);
+ this->rx_header_buf_len_ = header_len;
+ // Pump the handshake without gating on socket_->ready(): on LWIP the
+ // plaintext helper's partial read can drain rcvevent while the rest of the
+ // client hello sits in the lastdata cache, so ready() may report false even
+ // though data is available.
+ return this->pump_handshake_();
+}
+#endif // USE_API_PLAINTEXT
+
+/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal
+/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK
+/// and resume on the next loop().
+APIError APINoiseFrameHelper::pump_handshake_() {
+ while (this->state_ != State::DATA) {
+ APIError err = this->state_action_();
+ if (err == APIError::WOULD_BLOCK) {
+ break;
+ }
+ if (err != APIError::OK) {
+ return err;
+ }
+ }
+ return APIError::OK;
+}
+
// Helper for handling handshake frame errors
APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) {
if (aerr == APIError::BAD_INDICATOR) {
@@ -131,16 +165,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func
/// Run through handshake messages (if in that phase)
APIError APINoiseFrameHelper::loop() {
- // Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once
- // the rx buffer is consumed. Re-checking each iteration would block handshake writes
- // that must follow reads, deadlocking the handshake. state_action() will return
- // WOULD_BLOCK when no more data is available to read.
- bool socket_ready = this->socket_->ready();
- while (state_ != State::DATA && socket_ready) {
- APIError err = state_action_();
- if (err == APIError::WOULD_BLOCK) {
- break;
- }
+ // Check ready() once, not per state transition. On ESP8266 LWIP raw TCP,
+ // ready() returns false once the rx buffer is consumed. Re-checking each
+ // iteration would block handshake writes that must follow reads,
+ // deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when
+ // no more data is available to read.
+ if (state_ != State::DATA && this->socket_->ready()) {
+ APIError err = this->pump_handshake_();
if (err != APIError::OK) {
return err;
}
diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h
index 0676eab78d..b0ba9fd01c 100644
--- a/esphome/components/api/api_frame_helper_noise.h
+++ b/esphome/components/api/api_frame_helper_noise.h
@@ -22,12 +22,20 @@ class APINoiseFrameHelper final : public APIFrameHelper {
}
~APINoiseFrameHelper() override;
APIError init() override;
+#ifdef USE_API_PLAINTEXT
+ // Take over a connection whose first bytes were consumed by a plaintext
+ // helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE).
+ // Seeds the already-read header bytes and pumps the handshake state machine
+ // until it would block.
+ APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
+#endif
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override;
protected:
+ APIError pump_handshake_();
APIError state_action_();
APIError state_action_client_hello_();
APIError state_action_server_hello_();
diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp
index fa611a6e33..9359f568fb 100644
--- a/esphome/components/api/api_frame_helper_plaintext.cpp
+++ b/esphome/components/api/api_frame_helper_plaintext.cpp
@@ -89,6 +89,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
// If this was the first read, validate the indicator byte
if (rx_header_buf_pos_ == 0 && received > 0) {
if (rx_header_buf_[0] != 0x00) {
+#ifdef USE_API_NOISE
+ // Dual build (encryption supported but no key set): a 0x01 first byte
+ // is a Noise client hello. Hand the connection off to a Noise helper
+ // running the all-zeros provisioning PSK so the encryption key can be
+ // set without crossing the wire in plaintext. Preserve the bytes we
+ // already consumed; they are the start of the Noise 3-byte header.
+ if (rx_header_buf_[0] == 0x01) {
+ rx_header_buf_pos_ = static_cast(received);
+ return APIError::PROTOCOL_SWITCH_TO_NOISE;
+ }
+#endif
state_ = State::FAILED;
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
return APIError::BAD_INDICATOR;
diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h
index 8314754715..ea3f6d7280 100644
--- a/esphome/components/api/api_frame_helper_plaintext.h
+++ b/esphome/components/api/api_frame_helper_plaintext.h
@@ -23,6 +23,15 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
APIError read_packet(ReadPacketBuffer *buffer) override;
APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override;
+#ifdef USE_API_NOISE
+ // After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the
+ // header bytes already consumed from the socket (at most 3, the size of the
+ // Noise fixed header) so the replacement Noise helper can be seeded with them.
+ uint8_t get_consumed_header(uint8_t out[3]) const {
+ memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_);
+ return this->rx_header_buf_pos_;
+ }
+#endif
protected:
APIError try_read_frame_();
diff --git a/esphome/components/api/api_noise_context.h b/esphome/components/api/api_noise_context.h
index b5f7016689..44484ffa2c 100644
--- a/esphome/components/api/api_noise_context.h
+++ b/esphome/components/api/api_noise_context.h
@@ -10,13 +10,20 @@ using psk_t = std::array;
class APINoiseContext {
public:
+ // The all-zeros PSK is reserved: it marks the device as unprovisioned and
+ // doubles as the well-known provisioning PSK that unprovisioned devices
+ // accept for Noise handshakes (passive-sniffing protection only, no
+ // authentication). It is never a valid real key.
+ static bool is_all_zeros(const psk_t &psk) {
+ uint8_t acc = 0;
+ for (uint8_t b : psk) {
+ acc |= b;
+ }
+ return acc == 0;
+ }
void set_psk(psk_t psk) {
this->psk_ = psk;
- bool has_psk = false;
- for (auto i : psk) {
- has_psk |= i;
- }
- this->has_psk_ = has_psk;
+ this->has_psk_ = !is_all_zeros(psk);
}
const psk_t &get_psk() const { return this->psk_; }
bool has_psk() const { return this->has_psk_; }
diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto
index d5d0b37e8d..ac9c4e59cc 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 103edb38a6..190bd32425 100644
--- a/esphome/components/api/api_pb2.cpp
+++ b/esphome/components/api/api_pb2.cpp
@@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const {
size += 2 + this->name.size();
return size;
}
+bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
+ switch (field_id) {
+ case 1:
+ this->reason = static_cast(value);
+ break;
+ default:
+ return false;
+ }
+ return true;
+}
+uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+ uint8_t *__restrict__ pos = buffer.get_pos();
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason));
+ return pos;
+}
+uint32_t DisconnectRequest::calculate_size() const {
+ uint32_t size = 0;
+ size += this->reason ? 2 : 0;
+ return size;
+}
#ifdef USE_AREAS
uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
@@ -150,6 +170,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
for (const auto &it : this->serial_proxies) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it);
}
+#endif
+#ifdef USE_API_NOISE
+ ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
#endif
return pos;
}
@@ -212,6 +235,9 @@ uint32_t DeviceInfoResponse::calculate_size() const {
for (const auto &it : this->serial_proxies) {
size += ProtoSize::calc_message_force(2, it.calculate_size());
}
+#endif
+#ifdef USE_API_NOISE
+ size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
#endif
return size;
}
@@ -1147,6 +1173,10 @@ bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value
}
bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
switch (field_id) {
+ case 2: {
+ this->timezone = StringRef(reinterpret_cast(value.data()), value.size());
+ break;
+ }
case 3:
value.decode_to_message(this->parsed_timezone);
break;
@@ -1435,6 +1465,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 {
@@ -1484,6 +1515,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 {
@@ -1641,6 +1673,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 {
@@ -1663,6 +1696,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 {
@@ -2344,7 +2378,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) {
@@ -2365,7 +2399,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;
@@ -2885,6 +2919,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;
}
@@ -2894,12 +2933,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) {
@@ -3776,12 +3817,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;
@@ -3857,7 +3902,7 @@ uint32_t ListEntitiesInfraredResponse::calculate_size() const {
return size;
}
#endif
-#ifdef USE_IR_RF
+#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
#ifdef USE_DEVICES
@@ -3871,6 +3916,9 @@ bool InfraredRFTransmitRawTimingsRequest::decode_varint(uint32_t field_id, proto
case 4:
this->repeat_count = value;
break;
+ case 6:
+ this->modulation = value;
+ break;
default:
return false;
}
@@ -3899,7 +3947,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);
@@ -3910,7 +3960,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);
@@ -3924,6 +3976,46 @@ uint32_t InfraredRFReceiveEvent::calculate_size() const {
return size;
}
#endif
+#ifdef USE_RADIO_FREQUENCY
+uint8_t *ListEntitiesRadioFrequencyResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+ uint8_t *__restrict__ pos = buffer.get_pos();
+ ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 10, this->object_id);
+ ProtoEncode::write_tag_and_fixed32(pos PROTO_ENCODE_DEBUG_ARG, 21, this->key);
+ ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 26, this->name);
+#ifdef USE_ENTITY_ICON
+ ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 4, this->icon);
+#endif
+ ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 5, this->disabled_by_default);
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 6, static_cast(this->entity_category));
+#ifdef USE_DEVICES
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, this->device_id);
+#endif
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 8, this->capabilities);
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 9, this->frequency_min);
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 10, this->frequency_max);
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 11, this->supported_modulations);
+ return pos;
+}
+uint32_t ListEntitiesRadioFrequencyResponse::calculate_size() const {
+ uint32_t size = 0;
+ size += 2 + this->object_id.size();
+ size += 5;
+ size += 2 + this->name.size();
+#ifdef USE_ENTITY_ICON
+ size += !this->icon.empty() ? 2 + this->icon.size() : 0;
+#endif
+ size += ProtoSize::calc_bool(1, this->disabled_by_default);
+ size += this->entity_category ? 2 : 0;
+#ifdef USE_DEVICES
+ size += ProtoSize::calc_uint32(1, this->device_id);
+#endif
+ size += ProtoSize::calc_uint32(1, this->capabilities);
+ size += ProtoSize::calc_uint32(1, this->frequency_min);
+ size += ProtoSize::calc_uint32(1, this->frequency_max);
+ size += ProtoSize::calc_uint32(1, this->supported_modulations);
+ return size;
+}
+#endif
#ifdef USE_SERIAL_PROXY
bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
@@ -3950,13 +4042,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 7c4755328b..4d5866da0b 100644
--- a/esphome/components/api/api_pb2.h
+++ b/esphome/components/api/api_pb2.h
@@ -11,6 +11,10 @@ namespace esphome::api {
namespace enums {
+enum DisconnectReason : uint32_t {
+ DISCONNECT_REASON_UNSPECIFIED = 0,
+ DISCONNECT_REASON_PROVISIONING_CLOSED = 1,
+};
enum SerialProxyPortType : uint32_t {
SERIAL_PROXY_PORT_TYPE_TTL = 0,
SERIAL_PROXY_PORT_TYPE_RS232 = 1,
@@ -92,6 +96,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 +185,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,
@@ -420,18 +431,22 @@ class HelloResponse final : public ProtoMessage {
protected:
};
-class DisconnectRequest final : public ProtoMessage {
+class DisconnectRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 5;
- static constexpr uint8_t ESTIMATED_SIZE = 0;
+ static constexpr uint8_t ESTIMATED_SIZE = 2;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("disconnect_request"); }
#endif
+ enums::DisconnectReason reason{};
+ uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
+ uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
+ bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
class DisconnectResponse final : public ProtoMessage {
public:
@@ -518,7 +533,7 @@ class SerialProxyInfo final : public ProtoMessage {
class DeviceInfoResponse final : public ProtoMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 10;
- static constexpr uint16_t ESTIMATED_SIZE = 309;
+ static constexpr uint16_t ESTIMATED_SIZE = 312;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
#endif
@@ -573,6 +588,9 @@ class DeviceInfoResponse final : public ProtoMessage {
#endif
#ifdef USE_SERIAL_PROXY
std::array serial_proxies{};
+#endif
+#ifdef USE_API_NOISE
+ bool api_encryption_provisionable{false};
#endif
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
@@ -1195,11 +1213,12 @@ class ParsedTimezone final : public ProtoDecodableMessage {
class GetTimeResponse final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 37;
- static constexpr uint8_t ESTIMATED_SIZE = 22;
+ static constexpr uint8_t ESTIMATED_SIZE = 31;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("get_time_response"); }
#endif
uint32_t epoch_seconds{0};
+ StringRef timezone{};
ParsedTimezone parsed_timezone{};
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
@@ -1371,7 +1390,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
@@ -1393,6 +1412,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
@@ -1470,7 +1490,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
@@ -1479,6 +1499,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
@@ -2426,13 +2447,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
@@ -3053,11 +3076,11 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage {
protected:
};
#endif
-#ifdef USE_IR_RF
+#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 136;
- static constexpr uint8_t ESTIMATED_SIZE = 220;
+ static constexpr uint8_t ESTIMATED_SIZE = 224;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_raw_timings_request"); }
#endif
@@ -3070,6 +3093,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage {
const uint8_t *timings_data_{nullptr};
uint16_t timings_length_{0};
uint16_t timings_count_{0};
+ uint32_t modulation{0};
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
@@ -3100,6 +3124,27 @@ class InfraredRFReceiveEvent final : public ProtoMessage {
protected:
};
#endif
+#ifdef USE_RADIO_FREQUENCY
+class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage {
+ public:
+ static constexpr uint8_t MESSAGE_TYPE = 148;
+ static constexpr uint8_t ESTIMATED_SIZE = 56;
+#ifdef HAS_PROTO_MESSAGE_DUMP
+ const LogString *message_name() const override { return LOG_STR("list_entities_radio_frequency_response"); }
+#endif
+ uint32_t capabilities{0};
+ uint32_t frequency_min{0};
+ uint32_t frequency_max{0};
+ uint32_t supported_modulations{0};
+ uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
+ uint32_t calculate_size() const;
+#ifdef HAS_PROTO_MESSAGE_DUMP
+ const char *dump_to(DumpBuffer &out) const override;
+#endif
+
+ protected:
+};
+#endif
#ifdef USE_SERIAL_PROXY
class SerialProxyConfigureRequest final : public ProtoDecodableMessage {
public:
diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp
index e5ec6618b9..09570b09e4 100644
--- a/esphome/components/api/api_pb2_dump.cpp
+++ b/esphome/components/api/api_pb2_dump.cpp
@@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint
}
#pragma GCC diagnostic pop
+template<> const char *proto_enum_to_string(enums::DisconnectReason value) {
+ switch (value) {
+ case enums::DISCONNECT_REASON_UNSPECIFIED:
+ return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED");
+ case enums::DISCONNECT_REASON_PROVISIONING_CLOSED:
+ return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED");
+ default:
+ return ESPHOME_PSTR("UNKNOWN");
+ }
+}
template<> const char *proto_enum_to_string(enums::SerialProxyPortType value) {
switch (value) {
case enums::SERIAL_PROXY_PORT_TYPE_TTL:
@@ -297,6 +307,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 +497,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");
}
@@ -848,7 +874,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
const char *DisconnectRequest::dump_to(DumpBuffer &out) const {
- out.append_p(ESPHOME_PSTR("DisconnectRequest {}"));
+ MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest"));
+ dump_field(out, ESPHOME_PSTR("reason"), static_cast(this->reason));
return out.c_str();
}
const char *DisconnectResponse::dump_to(DumpBuffer &out) const {
@@ -955,6 +982,9 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
it.dump_to(out);
out.append("\n");
}
+#endif
+#ifdef USE_API_NOISE
+ dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
#endif
return out.c_str();
}
@@ -1387,6 +1417,7 @@ const char *ParsedTimezone::dump_to(DumpBuffer &out) const {
const char *GetTimeResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("GetTimeResponse"));
dump_field(out, ESPHOME_PSTR("epoch_seconds"), this->epoch_seconds);
+ dump_field(out, ESPHOME_PSTR("timezone"), this->timezone);
out.append(2, ' ').append_p(ESPHOME_PSTR("parsed_timezone")).append(": ");
this->parsed_timezone.dump_to(out);
out.append("\n");
@@ -1538,6 +1569,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 {
@@ -1611,6 +1643,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 {
@@ -2155,6 +2188,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 {
@@ -2575,7 +2609,7 @@ const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
-#ifdef USE_IR_RF
+#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("InfraredRFTransmitRawTimingsRequest"));
#ifdef USE_DEVICES
@@ -2590,6 +2624,7 @@ const char *InfraredRFTransmitRawTimingsRequest::dump_to(DumpBuffer &out) const
out.append_p(ESPHOME_PSTR(" values, "));
append_uint(out, this->timings_length_);
out.append_p(ESPHOME_PSTR(" bytes]\n"));
+ dump_field(out, ESPHOME_PSTR("modulation"), this->modulation);
return out.c_str();
}
const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
@@ -2604,6 +2639,27 @@ const char *InfraredRFReceiveEvent::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
+#ifdef USE_RADIO_FREQUENCY
+const char *ListEntitiesRadioFrequencyResponse::dump_to(DumpBuffer &out) const {
+ MessageDumpHelper helper(out, ESPHOME_PSTR("ListEntitiesRadioFrequencyResponse"));
+ dump_field(out, ESPHOME_PSTR("object_id"), this->object_id);
+ dump_field(out, ESPHOME_PSTR("key"), this->key);
+ dump_field(out, ESPHOME_PSTR("name"), this->name);
+#ifdef USE_ENTITY_ICON
+ dump_field(out, ESPHOME_PSTR("icon"), this->icon);
+#endif
+ dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default);
+ dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category));
+#ifdef USE_DEVICES
+ dump_field(out, ESPHOME_PSTR("device_id"), this->device_id);
+#endif
+ dump_field(out, ESPHOME_PSTR("capabilities"), this->capabilities);
+ dump_field(out, ESPHOME_PSTR("frequency_min"), this->frequency_min);
+ dump_field(out, ESPHOME_PSTR("frequency_max"), this->frequency_max);
+ dump_field(out, ESPHOME_PSTR("supported_modulations"), this->supported_modulations);
+ return out.c_str();
+}
+#endif
#ifdef USE_SERIAL_PROXY
const char *SerialProxyConfigureRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyConfigureRequest"));
diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h
index f45e091c6f..70ba579fcc 100644
--- a/esphome/components/api/api_pb2_includes.h
+++ b/esphome/components/api/api_pb2_includes.h
@@ -31,6 +31,13 @@
#include
#include
+#if defined(LOG_LEVEL_NONE)
+// Zephyr defines LOG_LEVEL_NONE as a logging macro that collides with the LogLevel enum value of
+// the same name in the generated api_pb2.h. Undefine it for the rest of this translation unit so
+// the enum parses; nothing below needs Zephyr's logging macro.
+#undef LOG_LEVEL_NONE
+#endif
+
namespace esphome::api {
// This file only provides includes, no actual code
diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp
index b41233eddd..5c9df433dd 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) {
@@ -50,10 +51,12 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
case DisconnectRequest::MESSAGE_TYPE: {
+ DisconnectRequest msg;
+ msg.decode(msg_data, msg_size);
#ifdef HAS_PROTO_MESSAGE_DUMP
- this->log_receive_message_(LOG_STR("on_disconnect_request"));
+ this->log_receive_message_(LOG_STR("on_disconnect_request"), msg);
#endif
- this->on_disconnect_request();
+ this->on_disconnect_request(msg);
break;
}
case DisconnectResponse::MESSAGE_TYPE: {
@@ -625,7 +628,7 @@ void APIConnection::read_message_(uint32_t msg_size, uint32_t msg_type, const ui
break;
}
#endif
-#ifdef USE_IR_RF
+#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
case InfraredRFTransmitRawTimingsRequest::MESSAGE_TYPE: {
InfraredRFTransmitRawTimingsRequest msg;
msg.decode(msg_data, msg_size);
@@ -706,5 +709,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_pb2_service.h b/esphome/components/api/api_pb2_service.h
index 6ff988902f..d1b51f4846 100644
--- a/esphome/components/api/api_pb2_service.h
+++ b/esphome/components/api/api_pb2_service.h
@@ -21,7 +21,7 @@ class APIServerConnectionBase {
void on_hello_request(const HelloRequest &value){};
- void on_disconnect_request(){};
+ void on_disconnect_request(const DisconnectRequest &value){};
void on_disconnect_response(){};
void on_ping_request(){};
void on_ping_response(){};
@@ -211,7 +211,7 @@ class APIServerConnectionBase {
void on_z_wave_proxy_request(const ZWaveProxyRequest &value){};
#endif
-#ifdef USE_IR_RF
+#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &value){};
#endif
diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp
index d9c3cc6846..6e3448121c 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"
@@ -106,8 +107,30 @@ void APIServer::setup() {
// Initialize last_connected_ for reboot timeout tracking
this->last_connected_ = App.get_loop_component_start_time();
- // Set warning status if reboot timeout is enabled
- if (this->reboot_timeout_ != 0) {
+#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
+ // Register with the provisioning manager (provisioning:) as a source and
+ // report our current state (provisioned == an encryption key is set). When the
+ // window closes, disconnect any client still attempting to provision so it learns
+ // the reason. The manager owns the timeout, window state and on_timeout automation.
+ if (provisioning::global_provisioning_manager != nullptr) {
+ this->provisioning_source_ = provisioning::global_provisioning_manager->register_source();
+ provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_,
+ this->noise_ctx_.has_psk());
+ provisioning::global_provisioning_manager->add_on_closed_callback([this]() {
+ for (auto &c : this->active_clients()) {
+ DisconnectRequest req;
+ req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
+ // Best-effort: if the send buffer is full the reason is dropped, but the
+ // client still learns the window is closed when it reconnects (rejected at
+ // hello) or via the socket close.
+ c->send_message(req);
+ }
+ });
+ }
+#endif
+ // Set warning status if reboot timeout is enabled (suppressed while provisioning
+ // is pending so the device waits to be onboarded instead of rebooting).
+ if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
}
}
@@ -118,10 +141,12 @@ void APIServer::loop() {
this->accept_new_connections_();
}
- if (this->clients_.empty()) {
+ if (this->api_connection_count_ == 0) {
// Check reboot timeout - done in loop to avoid scheduler heap churn
- // (cancelled scheduler items sit in heap memory until their scheduled time)
- if (this->reboot_timeout_ != 0) {
+ // (cancelled scheduler items sit in heap memory until their scheduled time).
+ // Suppressed while a provisioning window is pending so the device waits to be
+ // onboarded / reset instead of rebooting itself; resumes once provisioned.
+ if (this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
const uint32_t now = App.get_loop_component_start_time();
if (now - this->last_connected_ > this->reboot_timeout_) {
ESP_LOGE(TAG, "No clients; rebooting");
@@ -135,15 +160,15 @@ void APIServer::loop() {
// Check network connectivity once for all clients
if (!network::is_connected()) {
// Network is down - disconnect all clients
- for (auto &client : this->clients_) {
+ for (auto &client : this->active_clients()) {
client->on_fatal_error();
client->log_client_(ESPHOME_LOG_LEVEL_WARN, LOG_STR("Network down; disconnect"));
}
// Continue to process and clean up the clients below
}
- size_t client_index = 0;
- while (client_index < this->clients_.size()) {
+ uint8_t client_index = 0;
+ while (client_index < this->api_connection_count_) {
auto &client = this->clients_[client_index];
// Common case: process active client
@@ -161,7 +186,7 @@ void APIServer::loop() {
}
}
-void APIServer::remove_client_(size_t client_index) {
+void APIServer::remove_client_(uint8_t client_index) {
auto &client = this->clients_[client_index];
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
@@ -179,14 +204,22 @@ void APIServer::remove_client_(size_t client_index) {
// Close socket now (was deferred from on_fatal_error to allow getpeername)
client->helper_->close();
- // Swap with the last element and pop (avoids expensive vector shifts)
- if (client_index < this->clients_.size() - 1) {
- std::swap(this->clients_[client_index], this->clients_.back());
+ // Swap-and-reset: move the removed client to the trailing slot and null it out so slots
+ // [api_connection_count_, N) remain nullptr.
+ const uint8_t last_index = this->api_connection_count_ - 1;
+ if (client_index < last_index) {
+ std::swap(this->clients_[client_index], this->clients_[last_index]);
}
- this->clients_.pop_back();
+ // Drop the count before resetting the slot. reset() runs ~APIConnection(), which can reenter the
+ // server (e.g. voice_assistant unsubscribes in its disconnect trigger, publishing entity state ->
+ // on_*_update iterating active_clients()). Excluding the dying slot from the active range first
+ // keeps that reentrant iteration from dereferencing the now-null slot.
+ this->api_connection_count_--;
+ this->clients_[last_index].reset();
// Last client disconnected - set warning and start tracking for reboot timeout
- if (this->clients_.empty() && this->reboot_timeout_ != 0) {
+ // (suppressed while provisioning is pending - see loop()).
+ if (this->api_connection_count_ == 0 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_set_warning(LOG_STR("waiting for client connection"));
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -210,8 +243,8 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
sock->getpeername_to(peername);
// Check if we're at the connection limit
- if (this->clients_.size() >= this->max_connections_) {
- ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, peername);
+ if (this->api_connection_count_ >= MAX_API_CONNECTIONS) {
+ ESP_LOGW(TAG, "Max connections (%d), rejecting %s", MAX_API_CONNECTIONS, peername);
// Immediately close - socket destructor will handle cleanup
sock.reset();
continue;
@@ -220,11 +253,11 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
ESP_LOGD(TAG, "Accept %s", peername);
auto *conn = new APIConnection(std::move(sock), this);
- this->clients_.emplace_back(conn);
+ this->clients_[this->api_connection_count_++].reset(conn);
conn->start();
// First client connected - clear warning and update timestamp
- if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) {
+ if (this->api_connection_count_ == 1 && this->reboot_timeout_ != 0 && !this->provisioning_pending_()) {
this->status_clear_warning();
this->last_connected_ = App.get_loop_component_start_time();
}
@@ -232,12 +265,13 @@ void __attribute__((flatten)) APIServer::accept_new_connections_() {
}
void APIServer::dump_config() {
+ char addr_buf[network::USE_ADDRESS_BUFFER_SIZE];
ESP_LOGCONFIG(TAG,
"Server:\n"
" Address: %s:%u\n"
" Listen backlog: %u\n"
" Max connections: %u",
- network::get_use_address(), this->port_, this->listen_backlog_, this->max_connections_);
+ network::get_use_address_to(addr_buf), this->port_, this->listen_backlog_, MAX_API_CONNECTIONS);
#ifdef USE_API_NOISE
ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk()));
if (!this->noise_ctx_.has_psk()) {
@@ -255,7 +289,7 @@ void APIServer::handle_disconnect(APIConnection *conn) {}
void APIServer::on_##entity_name##_update(entity_type *obj) { /* NOLINT(bugprone-macro-parentheses) */ \
if (obj->is_internal()) \
return; \
- for (auto &c : this->clients_) { \
+ for (auto &c : this->active_clients()) { \
if (c->flags_.state_subscription) \
c->send_##entity_name##_state(obj); \
} \
@@ -337,7 +371,7 @@ API_DISPATCH_UPDATE(water_heater::WaterHeater, water_heater)
void APIServer::on_event(event::Event *obj) {
if (obj->is_internal())
return;
- for (auto &c : this->clients_) {
+ for (auto &c : this->active_clients()) {
if (c->flags_.state_subscription)
c->send_event(obj);
}
@@ -349,7 +383,7 @@ void APIServer::on_event(event::Event *obj) {
void APIServer::on_update(update::UpdateEntity *obj) {
if (obj->is_internal())
return;
- for (auto &c : this->clients_) {
+ for (auto &c : this->active_clients()) {
if (c->flags_.state_subscription)
c->send_update_state(obj);
}
@@ -360,12 +394,12 @@ void APIServer::on_update(update::UpdateEntity *obj) {
void APIServer::on_zwave_proxy_request(const ZWaveProxyRequest &msg) {
// We could add code to manage a second subscription type, but, since this message type is
// very infrequent and small, we simply send it to all clients
- for (auto &c : this->clients_)
+ for (auto &c : this->active_clients())
c->send_message(msg);
}
#endif
-#ifdef USE_IR_RF
+#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_id, uint32_t key,
const std::vector *timings) {
InfraredRFReceiveEvent resp{};
@@ -375,7 +409,7 @@ void APIServer::send_infrared_rf_receive_event([[maybe_unused]] uint32_t device_
resp.key = key;
resp.timings = timings;
- for (auto &c : this->clients_)
+ for (auto &c : this->active_clients())
c->send_infrared_rf_receive_event(resp);
}
#endif
@@ -392,8 +426,16 @@ void APIServer::set_batch_delay(uint16_t batch_delay) { this->batch_delay_ = bat
#ifdef USE_API_HOMEASSISTANT_SERVICES
void APIServer::send_homeassistant_action(const HomeassistantActionRequest &call) {
- for (auto &client : this->clients_) {
- client->send_homeassistant_action(call);
+ bool has_subscriber = false;
+ for (auto &client : this->active_clients()) {
+ has_subscriber |= client->send_homeassistant_action(call);
+ }
+ if (!has_subscriber) {
+ // Home Assistant subscribes to actions shortly *after* authenticating, so actions
+ // fired right at connection time (on_client_connected, on_time_sync, ...) can
+ // arrive before the subscription and are lost - warn instead of failing silently.
+ ESP_LOGW(TAG, "Home Assistant %s '%s' dropped; %s", call.is_event ? "event" : "action", call.service.c_str(),
+ this->is_connected() ? "client has not subscribed to actions (yet)" : "no client connected");
}
}
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
@@ -532,7 +574,7 @@ bool APIServer::update_noise_psk_(const SavedNoisePsk &new_psk, const LogString
return;
}
ESP_LOGW(TAG, "Disconnecting all clients to reset PSK");
- for (auto &c : this->clients_) {
+ for (auto &c : this->active_clients()) {
DisconnectRequest req;
c->send_message(req);
}
@@ -563,8 +605,16 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) {
}
SavedNoisePsk new_saved_psk{psk};
- return this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
- make_active);
+ bool result = this->update_noise_psk_(new_saved_psk, LOG_STR("Noise PSK saved"), LOG_STR("Failed to save Noise PSK"),
+ make_active);
+#ifdef USE_PROVISIONING
+ // The device now has a key; report provisioned so the provisioning window is
+ // satisfied and the reboot timeout resumes normal operation.
+ if (result && provisioning::global_provisioning_manager != nullptr) {
+ provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, true);
+ }
+#endif
+ return result;
#endif
}
bool APIServer::clear_noise_psk(bool make_active) {
@@ -575,15 +625,23 @@ bool APIServer::clear_noise_psk(bool make_active) {
return false;
#else
SavedNoisePsk empty_psk{};
- return this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
- make_active);
+ bool result = this->update_noise_psk_(empty_psk, LOG_STR("Noise PSK cleared"), LOG_STR("Failed to clear Noise PSK"),
+ make_active);
+#ifdef USE_PROVISIONING
+ // The key was cleared; report unprovisioned so a subsequent reboot reopens the
+ // provisioning window.
+ if (result && provisioning::global_provisioning_manager != nullptr) {
+ provisioning::global_provisioning_manager->set_source_provisioned(this->provisioning_source_, false);
+ }
+#endif
+ return result;
#endif
}
#endif
#ifdef USE_HOMEASSISTANT_TIME
void APIServer::request_time() {
- for (auto &client : this->clients_) {
+ for (auto &client : this->active_clients()) {
if (!client->flags_.remove && client->is_authenticated()) {
client->send_time_request();
return; // Only request from one client to avoid clock conflicts
@@ -593,8 +651,8 @@ void APIServer::request_time() {
#endif
bool APIServer::is_connected_with_state_subscription() const {
- for (const auto &client : this->clients_) {
- if (client->flags_.state_subscription) {
+ for (uint8_t i = 0; i < this->api_connection_count_; i++) {
+ if (this->clients_[i]->flags_.state_subscription) {
return true;
}
}
@@ -609,7 +667,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size
// we would be filling a buffer we are trying to clear
return;
}
- for (auto &c : this->clients_) {
+ for (auto &c : this->active_clients()) {
if (!c->flags_.remove && c->get_log_subscription_level() >= level)
c->try_send_log_message(level, tag, message, message_len);
}
@@ -618,7 +676,7 @@ void APIServer::on_log(uint8_t level, const char *tag, const char *message, size
#ifdef USE_CAMERA
void APIServer::on_camera_image(const std::shared_ptr &image) {
- for (auto &c : this->clients_) {
+ for (auto &c : this->active_clients()) {
if (!c->flags_.remove)
c->set_camera_state(image);
}
@@ -635,7 +693,7 @@ void APIServer::on_shutdown() {
this->batch_delay_ = 5;
// Send disconnect requests to all connected clients
- for (auto &c : this->clients_) {
+ for (auto &c : this->active_clients()) {
DisconnectRequest req;
if (!c->send_message(req)) {
// If we can't send the disconnect request directly (tx_buffer full),
@@ -653,7 +711,7 @@ bool APIServer::teardown() {
this->loop();
// Return true only when all clients have been torn down
- return this->clients_.empty();
+ return this->api_connection_count_ == 0;
}
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
@@ -674,7 +732,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);
});
@@ -718,7 +776,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,
@@ -730,7 +788,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 65076879a2..248b83a0ff 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,9 @@
#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_PROVISIONING
+#include "esphome/components/provisioning/provisioning.h"
+#endif
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -21,6 +24,8 @@
#include "esphome/components/camera/camera.h"
#endif
+#include
+#include
#include
namespace esphome::api {
@@ -63,7 +68,6 @@ class APIServer final : public Component,
void set_batch_delay(uint16_t batch_delay);
uint16_t get_batch_delay() const { return batch_delay_; }
void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; }
- void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; }
// Get reference to shared buffer for API connections
APIBuffer &get_shared_buffer_ref() { return shared_write_buffer_; }
@@ -182,13 +186,30 @@ class APIServer final : public Component,
#ifdef USE_ZWAVE_PROXY
void on_zwave_proxy_request(const ZWaveProxyRequest &msg);
#endif
-#ifdef USE_IR_RF
+#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
void send_infrared_rf_receive_event(uint32_t device_id, uint32_t key, const std::vector *timings);
#endif
- bool is_connected() const { return !this->clients_.empty(); }
+ bool is_connected() const { return this->api_connection_count_ != 0; }
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
+ // APIConnection but cannot reset/move the slot and break the count invariant.
+ using APIConnectionPtr = std::unique_ptr;
+ class ActiveClientsView {
+ const APIConnectionPtr *begin_;
+ const APIConnectionPtr *end_;
+
+ public:
+ ActiveClientsView(const APIConnectionPtr *b, const APIConnectionPtr *e) : begin_(b), end_(e) {}
+ const APIConnectionPtr *begin() const { return this->begin_; }
+ const APIConnectionPtr *end() const { return this->end_; }
+ };
+ ActiveClientsView active_clients() const {
+ return {this->clients_.data(), this->clients_.data() + this->api_connection_count_};
+ }
+
#ifdef USE_API_HOMEASSISTANT_STATES
struct HomeAssistantStateSubscription {
const char *entity_id; // Pointer to flash (internal) or heap (external)
@@ -234,8 +255,21 @@ class APIServer final : public Component,
protected:
// Accept incoming socket connections. Only called when socket has pending connections.
void __attribute__((noinline)) accept_new_connections_();
- // Remove a disconnected client by index. Swaps with last element and pops.
- void __attribute__((noinline)) remove_client_(size_t client_index);
+ // Remove a disconnected client by index. Swaps with the last populated slot and resets it.
+ void __attribute__((noinline)) remove_client_(uint8_t client_index);
+
+#ifdef USE_PROVISIONING
+ // True while a configured provisioning window is still pending (the device is
+ // unprovisioned). Suppresses the reboot timeout and its warning so the device is
+ // not auto-rebooted while waiting to be provisioned. False when no provisioning
+ // window is configured.
+ bool provisioning_pending_() const {
+ return provisioning::global_provisioning_manager != nullptr &&
+ provisioning::global_provisioning_manager->window_pending();
+ }
+#else
+ bool provisioning_pending_() const { return false; }
+#endif
#ifdef USE_API_NOISE
bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg,
@@ -273,8 +307,9 @@ class APIServer final : public Component,
uint32_t reboot_timeout_{300000};
uint32_t last_connected_{0};
+ // Slots [0, api_connection_count_) are populated; trailing slots are always nullptr.
+ std::array clients_{};
// Vectors and strings (12 bytes each on 32-bit)
- std::vector> clients_;
// Shared proto write buffer for all connections.
// Not pre-allocated: all send paths call prepare_first_message_buffer() which
// reserves the exact needed size. Pre-allocating here would cause heap fragmentation
@@ -309,11 +344,14 @@ class APIServer final : public Component,
uint16_t port_{6053};
uint16_t batch_delay_{100};
// Connection limits - these defaults will be overridden by config values
- // from cv.SplitDefault in __init__.py which sets platform-specific defaults
+ // from cv.SplitDefault in __init__.py which sets platform-specific defaults.
uint8_t listen_backlog_{4};
- uint8_t max_connections_{8};
bool shutting_down_ = false;
- // 7 bytes used, 1 byte padding
+ uint8_t api_connection_count_{0};
+#if defined(USE_PROVISIONING) && defined(USE_API_NOISE)
+ // Index assigned by the provisioning manager for reporting this transport's state.
+ uint8_t provisioning_source_{0};
+#endif
#ifdef USE_API_NOISE
APINoiseContext noise_ctx_;
@@ -323,7 +361,7 @@ class APIServer final : public Component,
extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
-template class APIConnectedCondition : public Condition {
+template class APIConnectedCondition final : public Condition {
TEMPLATABLE_VALUE(bool, state_subscription_only)
public:
bool check(const Ts &...x) override {
diff --git a/esphome/components/api/client.py b/esphome/components/api/client.py
index 0c6c569c7d..7f07146dba 100644
--- a/esphome/components/api/client.py
+++ b/esphome/components/api/client.py
@@ -19,7 +19,7 @@ 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.util import safe_print
from . import CONF_ENCRYPTION
@@ -32,6 +32,51 @@ 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 everything the decoder can raise. aioesphomeapi isolates
+ exceptions raised by log handlers, so an escaping one no longer
+ kills the session, but it does log a full traceback per line. A
+ crash dump carries a PC line plus one per backtrace frame, so the
+ tracebacks bury the dump the user is trying to read. Decoding is a
+ diagnostic nicety; nothing it raises is worth that noise.
+ 2. Disable decoding after the first failure. _decode_pc shells out to
+ the toolchain to resolve addr2line, which is expensive; a single
+ crash dump can contain many PC/BT lines and we don't want to retry
+ the failing subprocess for each one. This only works if every
+ failure is caught, which is why 1 is not narrowed to EsphomeError.
+ """
+
+ def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None:
+ 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 Exception as exc: # noqa: BLE001 # pylint: disable=broad-except
+ self._decode_enabled = False
+ self.backtrace_state = False
+ # _run_idedata raises EsphomeError with no message; fall back
+ # to a generic explanation when str(exc) is empty.
+ detail = str(exc) or "build artifacts not found locally"
+ _LOGGER.debug("Stack-trace decoding failed", exc_info=True)
+ _LOGGER.warning(
+ "Crash trace decoding unavailable: %s. "
+ "Run 'esphome compile' for this device to enable PC decoding.",
+ detail,
+ )
+
+
async def async_run_logs(
config: dict[str, Any],
addresses: list[str],
@@ -59,22 +104,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,18 +130,35 @@ 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)
- stop = await async_run(cli, on_log, name=name, subscribe_states=subscribe_states)
+ # Safe to fall back to plaintext here only for this diagnostics use
+ # case: the stream is one-way from device to client, and this code
+ # never accepts commands or acts on any message the device sends.
+ # An on-path attacker could still both inject fabricated log lines
+ # and passively read the device's log output (and any state data
+ # delivered when subscribe_states is enabled), so this does lose
+ # confidentiality as well as authentication/integrity. That tradeoff
+ # is acceptable for operator-visible logs, which aioesphomeapi also
+ # warns may come from an unverified device. Never mirror this opt-in
+ # for any connection that sends data to the device or uses Home
+ # Assistant actions.
+ stop = await async_run(
+ cli,
+ on_log,
+ name=name,
+ subscribe_states=subscribe_states,
+ allow_plaintext_fallback=True,
+ # A top-level ``deep_sleep:`` block means the device is only awake
+ # briefly; cap the reconnect backoff so a wake window is not missed.
+ deep_sleep="deep_sleep" in config,
+ )
try:
await asyncio.Event().wait()
finally:
diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h
index 9d14061d07..9e0faf9881 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
@@ -103,7 +104,7 @@ class ActionResponse {
template using ActionResponseCallback = std::function;
#endif
-template class HomeAssistantServiceCallAction : public Action {
+template class HomeAssistantServiceCallAction final : public Action {
public:
explicit HomeAssistantServiceCallAction(APIServer *parent, bool is_event) : parent_(parent) {
this->flags_.is_event = is_event;
diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp
index 0a94c1699b..f9e645b506 100644
--- a/esphome/components/api/list_entities.cpp
+++ b/esphome/components/api/list_entities.cpp
@@ -79,6 +79,9 @@ LIST_ENTITIES_HANDLER(water_heater, water_heater::WaterHeater, ListEntitiesWater
#ifdef USE_INFRARED
LIST_ENTITIES_HANDLER(infrared, infrared::Infrared, ListEntitiesInfraredResponse)
#endif
+#ifdef USE_RADIO_FREQUENCY
+LIST_ENTITIES_HANDLER(radio_frequency, radio_frequency::RadioFrequency, ListEntitiesRadioFrequencyResponse)
+#endif
#ifdef USE_EVENT
LIST_ENTITIES_HANDLER(event, event::Event, ListEntitiesEventResponse)
#endif
diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h
index 7d0eb5bb13..88fbdb77c8 100644
--- a/esphome/components/api/list_entities.h
+++ b/esphome/components/api/list_entities.h
@@ -18,80 +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_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 8cac7fff3b..f058f6af22 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 4bbc17018e..09b5640d8a 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 9edf0f0f0c..6b1ae9651d 100644
--- a/esphome/components/api/subscribe_state.h
+++ b/esphome/components/api/subscribe_state.h
@@ -19,75 +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_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/api/user_services.h b/esphome/components/api/user_services.h
index 29eadda927..ea57d0944b 100644
--- a/esphome/components/api/user_services.h
+++ b/esphome/components/api/user_services.h
@@ -164,7 +164,8 @@ template class UserServiceTrig
// Specialization for NONE - no extra trigger arguments
template
-class UserServiceTrigger : public UserServiceBase, public Trigger {
+class UserServiceTrigger final : public UserServiceBase,
+ public Trigger {
public:
UserServiceTrigger(const char *name, const std::array &arg_names)
: UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_NONE) {}
@@ -175,8 +176,8 @@ class UserServiceTrigger : public UserServ
// Specialization for OPTIONAL - call_id and return_response trigger arguments
template
-class UserServiceTrigger : public UserServiceBase,
- public Trigger {
+class UserServiceTrigger final : public UserServiceBase,
+ public Trigger {
public:
UserServiceTrigger(const char *name, const std::array &arg_names)
: UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_OPTIONAL) {}
@@ -189,8 +190,8 @@ class UserServiceTrigger