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 87b4ebb2c6..0000000000
--- a/.clang-tidy.hash
+++ /dev/null
@@ -1 +0,0 @@
-8e48e836c6fc196d3da000d46eb09db243b87fe33518a74e49c8e009d756074a
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..e708ae41b2 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -6,6 +6,7 @@
- [ ] Bugfix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
+- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) — [policy](https://developers.esphome.io/contributing/code/#what-constitutes-a-c-breaking-change)
- [ ] Developer breaking change (an API change that could break external components) — [policy](https://developers.esphome.io/contributing/code/#what-is-considered-public-c-api)
- [ ] Undocumented C++ API change (removal or change of undocumented public methods that lambda users may depend on) — [policy](https://developers.esphome.io/contributing/code/#c-user-expectations)
@@ -16,9 +17,13 @@
- 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#
+
+**Pull request in [developers.esphome.io](https://github.com/esphome/developers.esphome.io) with developer documentation (if applicable):**
+
+- esphome/developers.esphome.io#
## Test Environment
@@ -43,4 +48,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 a895226030..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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.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 6d7d4f8c12..dcd2495809 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ enable-cache: true
+ # Pull request saves land in per-PR scopes nothing else can
+ # reuse; dev pushes seed the shared copy instead.
+ save-cache: ${{ github.event_name != 'pull_request' }}
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: 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 1c33772c4c..b95bc17518 100644
--- a/.github/scripts/auto-label-pr/constants.js
+++ b/.github/scripts/auto-label-pr/constants.js
@@ -4,6 +4,7 @@ module.exports = {
CODEOWNERS_MARKER: '',
TOO_BIG_MARKER: '',
DEPRECATED_COMPONENT_MARKER: '',
+ ORG_FORK_MARKER: '',
MANAGED_LABELS: [
'new-component',
@@ -21,11 +22,13 @@ module.exports = {
'has-tests',
'needs-tests',
'needs-docs',
+ 'needs-developer-docs',
'needs-codeowners',
'too-big',
'labeller-recheck',
'bugfix',
'new-feature',
+ 'new-feature-developer',
'breaking-change',
'developer-breaking-change',
'undocumented-api-change',
@@ -34,7 +37,22 @@ 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+/
+ ],
+
+ DEVELOPER_DOCS_PR_PATTERNS: [
+ /https:\/\/github\.com\/esphome\/developers\.esphome\.io\/pull\/\d+/,
+ /esphome\/developers\.esphome\.io#\d+/
+ ],
+
+ // Files whose developer-facing changes are documented via Python docstrings
+ // only - developers.esphome.io has no reference page for them yet, so PRs
+ // touching nothing but these files (and tests/) skip needs-developer-docs.
+ DEV_DOCS_EXEMPT_FILES: [
+ 'esphome/config_validation.py'
]
};
diff --git a/.github/scripts/auto-label-pr/detectors.js b/.github/scripts/auto-label-pr/detectors.js
index fc63198019..2478ccf959 100644
--- a/.github/scripts/auto-label-pr/detectors.js
+++ b/.github/scripts/auto-label-pr/detectors.js
@@ -1,5 +1,4 @@
-const fs = require('fs');
-const { DOCS_PR_PATTERNS } = require('./constants');
+const { DOCS_PR_PATTERNS, DEVELOPER_DOCS_PR_PATTERNS, DEV_DOCS_EXEMPT_FILES } = require('./constants');
const {
COMPONENT_REGEX,
detectComponents,
@@ -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) {
@@ -194,6 +245,7 @@ async function detectPRTemplateCheckboxes(context) {
const checkboxPatterns = [
{ pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' },
{ pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' },
+ { pattern: /- \[x\] New developer-facing feature \(adds functionality for component developers; no end-user configuration change\)/i, label: 'new-feature-developer' },
{ pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' },
{ pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' },
{ pattern: /- \[x\] Undocumented C\+\+ API change \(removal or change of undocumented public methods that lambda users may depend on\)/i, label: 'undocumented-api-change' },
@@ -235,19 +287,20 @@ async function detectDeprecatedComponents(github, context, changedFiles) {
}
}
- // Get PR head to fetch files from the PR branch
- const prNumber = context.payload.pull_request.number;
+ // Get base branch ref to check if deprecation already exists for the component
+ // This prevents flagging a PR that simply adds deprecation
+ const baseRef = context.payload.pull_request.base.ref;
// Check each component's __init__.py for DEPRECATED_COMPONENT constant
for (const component of components) {
const initFile = `esphome/components/${component}/__init__.py`;
try {
- // Fetch file content from PR head using GitHub API
+ // Fetch file content from base branch using GitHub API
const { data: fileData } = await github.rest.repos.getContent({
owner,
repo,
path: initFile,
- ref: `refs/pull/${prNumber}/head`
+ ref: baseRef
});
// Decode base64 content
@@ -280,17 +333,44 @@ async function detectDeprecatedComponents(github, context, changedFiles) {
return { labels, deprecatedInfo };
}
+// Strategy: Detect when maintainers cannot modify the PR branch
+function detectMaintainerAccess(context) {
+ const pr = context.payload.pull_request;
+
+ // Only relevant for cross-repo PRs (forks)
+ if (!pr.head.repo || pr.head.repo.full_name === pr.base.repo.full_name) {
+ return null;
+ }
+
+ if (pr.maintainer_can_modify) {
+ return null;
+ }
+
+ const isOrgFork = pr.head.repo.owner.type === 'Organization';
+ console.log(`Maintainer cannot modify PR branch (${isOrgFork ? 'org fork: ' + pr.head.repo.owner.login : 'user disabled'})`);
+ return { isOrgFork, orgName: pr.head.repo.owner.login };
+}
+
// Strategy: Requirements detection
-async function detectRequirements(allLabels, prFiles, context) {
+async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable) {
const labels = new Set();
// Check for missing tests
- if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) {
+ if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature') || allLabels.has('new-feature-developer')) && !allLabels.has('has-tests')) {
labels.add('needs-tests');
}
- // Check for missing docs
- 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-feature-developer` is
+ // deliberately excluded here: its docs live on developers.esphome.io and are
+ // checked separately below. `new-component` / `new-platform`
+ // only count when at least one newly added file defines a top-level CONFIG_SCHEMA,
+ // i.e. the new component/platform is actually loadable from YAML.
+ const docsEligible =
+ 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));
@@ -299,6 +379,22 @@ async function detectRequirements(allLabels, prFiles, context) {
}
}
+ // Check for missing developer docs. `new-feature-developer` requires a
+ // developers.esphome.io PR link, unless every changed file outside tests/ is
+ // in DEV_DOCS_EXEMPT_FILES (core validators documented via docstrings only).
+ if (allLabels.has('new-feature-developer')) {
+ const prBody = context.payload.pull_request.body || '';
+ const nonTestFiles = prFiles
+ .map(file => file.filename)
+ .filter(file => !file.startsWith('tests/'));
+ const onlyExemptFiles = nonTestFiles.every(file => DEV_DOCS_EXEMPT_FILES.includes(file));
+ const hasDevDocsLink = DEVELOPER_DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody));
+
+ if (!onlyExemptFiles && !hasDevDocsLink) {
+ labels.add('needs-developer-docs');
+ }
+ }
+
// Check for missing CODEOWNERS
if (allLabels.has('new-component')) {
const codeownersModified = prFiles.some(file =>
@@ -328,5 +424,6 @@ module.exports = {
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
+ detectMaintainerAccess,
detectRequirements
};
diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js
index 42588c0bc8..c8bdcfb2f3 100644
--- a/.github/scripts/auto-label-pr/index.js
+++ b/.github/scripts/auto-label-pr/index.js
@@ -12,9 +12,10 @@ const {
detectTests,
detectPRTemplateCheckboxes,
detectDeprecatedComponents,
+ detectMaintainerAccess,
detectRequirements
} = require('./detectors');
-const { handleReviews } = require('./reviews');
+const { handleReviews, handleMaintainerAccessComment } = require('./reviews');
const { applyLabels, removeOldLabels } = require('./labels');
// Fetch API data
@@ -105,8 +106,8 @@ module.exports = async ({ github, context }) => {
const [
branchLabels,
componentLabels,
- newComponentLabels,
- newPlatformLabels,
+ newComponentResult,
+ newPlatformResult,
coreLabels,
sizeLabels,
dashboardLabels,
@@ -114,22 +115,31 @@ module.exports = async ({ github, context }) => {
codeOwnerLabels,
testLabels,
checkboxLabels,
- deprecatedResult
+ deprecatedResult,
+ maintainerAccess
] = 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),
detectTests(changedFiles),
detectPRTemplateCheckboxes(context),
- detectDeprecatedComponents(github, context, changedFiles)
+ detectDeprecatedComponents(github, context, changedFiles),
+ 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;
@@ -151,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);
}
@@ -177,8 +187,11 @@ module.exports = async ({ github, context }) => {
console.log('Computed labels:', finalLabels.join(', '));
- // Handle reviews
- await handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD);
+ // Handle reviews and org fork comment
+ await Promise.all([
+ handleReviews(github, context, finalLabels, originalLabelCount, deprecatedInfo, prFiles, totalAdditions, totalDeletions, MAX_LABELS, TOO_BIG_THRESHOLD),
+ handleMaintainerAccessComment(github, context, maintainerAccess)
+ ]);
// Apply labels
await applyLabels(github, context, finalLabels);
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 906e2c456a..e9e848da6f 100644
--- a/.github/scripts/auto-label-pr/reviews.js
+++ b/.github/scripts/auto-label-pr/reviews.js
@@ -2,7 +2,8 @@ const {
BOT_COMMENT_MARKER,
CODEOWNERS_MARKER,
TOO_BIG_MARKER,
- DEPRECATED_COMPONENT_MARKER
+ DEPRECATED_COMPONENT_MARKER,
+ ORG_FORK_MARKER
} = require('./constants');
// Generate review messages
@@ -40,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);
}
@@ -136,6 +157,63 @@ async function handleReviews(github, context, finalLabels, originalLabelCount, d
}
}
+// Handle maintainer access warning comment
+async function handleMaintainerAccessComment(github, context, maintainerAccess) {
+ if (!maintainerAccess) {
+ return;
+ }
+
+ const { owner, repo } = context.repo;
+ const pr_number = context.issue.number;
+ const prAuthor = context.payload.pull_request.user.login;
+
+ // Check if we already posted the warning (iterate pages to exit early)
+ let existingComment;
+ for await (const { data: comments } of github.paginate.iterator(
+ github.rest.issues.listComments,
+ { owner, repo, issue_number: pr_number }
+ )) {
+ existingComment = comments.find(comment =>
+ comment.user.type === 'Bot' &&
+ comment.body && comment.body.includes(ORG_FORK_MARKER)
+ );
+ if (existingComment) {
+ break;
+ }
+ }
+
+ if (existingComment) {
+ console.log('Maintainer access warning comment already exists, skipping');
+ return;
+ }
+
+ let body;
+ if (maintainerAccess.isOrgFork) {
+ body = `${ORG_FORK_MARKER}\n### ⚠️ Organization Fork Detected\n\n` +
+ `Hey there @${prAuthor},\n` +
+ `It looks like this PR was submitted from a fork owned by the **${maintainerAccess.orgName}** organization. ` +
+ `GitHub does not allow maintainers to push changes to pull request branches when the fork is owned by an organization. ` +
+ `This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
+ `To allow maintainer collaboration, please re-submit this PR from a personal fork instead.\n\n` +
+ `See: [Setting up the local repository](https://developers.esphome.io/contributing/development-environment/?h=org#set-up-the-local-repository) for more details.`;
+ } else {
+ body = `${ORG_FORK_MARKER}\n### ⚠️ Maintainer Access Disabled\n\n` +
+ `Hey there @${prAuthor},\n` +
+ `It looks like this PR does not have the "Allow edits from maintainers" option enabled. ` +
+ `This means we won't be able to make small adjustments or fixups to your PR directly.\n\n` +
+ `Please enable this option in the PR sidebar to allow maintainer collaboration.`;
+ }
+
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: pr_number,
+ body
+ });
+ console.log('Created maintainer access warning comment');
+}
+
module.exports = {
- handleReviews
+ handleReviews,
+ handleMaintainerAccessComment
};
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..413fdb3f94
--- /dev/null
+++ b/.github/scripts/auto-label-pr/tests/detectors.test.js
@@ -0,0 +1,349 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const {
+ detectNewPlatforms,
+ detectNewComponents,
+ detectPRSize,
+ detectPRTemplateCheckboxes,
+ detectRequirements,
+} = require('../detectors');
+const { MANAGED_LABELS } = require('../constants');
+
+// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
+// to check for CONFIG_SCHEMA in newly added files.
+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);
+ });
+});
+
+// ---------------------------------------------------------------------------
+// detectPRTemplateCheckboxes
+// ---------------------------------------------------------------------------
+
+const NEW_FEATURE_LINE = '- [x] New feature (non-breaking change which adds functionality)';
+const DEV_FEATURE_LINE = '- [x] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
+const DEV_FEATURE_LINE_UNTICKED = '- [ ] New developer-facing feature (adds functionality for component developers; no end-user configuration change)';
+
+function makeBodyContext(body) {
+ return { payload: { pull_request: { body } } };
+}
+
+describe('detectPRTemplateCheckboxes', () => {
+ it('ticked developer-facing feature checkbox adds new-feature-developer only', async () => {
+ const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE));
+ assert.ok(labels.has('new-feature-developer'));
+ assert.ok(!labels.has('new-feature'));
+ });
+
+ it('unticked developer-facing feature checkbox adds no label', async () => {
+ const labels = await detectPRTemplateCheckboxes(makeBodyContext(DEV_FEATURE_LINE_UNTICKED));
+ assert.ok(!labels.has('new-feature-developer'));
+ });
+
+ it('ticked new feature checkbox does not add new-feature-developer', async () => {
+ const labels = await detectPRTemplateCheckboxes(makeBodyContext(NEW_FEATURE_LINE));
+ assert.ok(labels.has('new-feature'));
+ assert.ok(!labels.has('new-feature-developer'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// detectRequirements
+// ---------------------------------------------------------------------------
+
+describe('detectRequirements', () => {
+ // PR body without any docs-PR link.
+ const NO_DOCS_CONTEXT = makeBodyContext('Just a description, no docs link.');
+ const USER_DOCS_CONTEXT = makeBodyContext('Docs: esphome/esphome.io#1234');
+ const DEV_DOCS_CONTEXT = makeBodyContext('Docs: esphome/developers.esphome.io#1234');
+ const DEV_DOCS_URL_CONTEXT = makeBodyContext('Docs: https://github.com/esphome/developers.esphome.io/pull/1234');
+
+ // File sets: a normal source change vs. one confined to the exempt core validators.
+ const SOURCE_FILES = [
+ { filename: 'esphome/components/foo/foo.py' },
+ { filename: 'tests/components/foo/common.yaml' },
+ ];
+ const VALIDATOR_FILES = [
+ { filename: 'esphome/config_validation.py' },
+ { filename: 'tests/unit_tests/test_config_validation.py' },
+ ];
+
+ it('new-feature-developer without has-tests adds needs-tests but not needs-docs', async () => {
+ const labels = await detectRequirements(new Set(['new-feature-developer']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
+ assert.ok(labels.has('needs-tests'));
+ assert.ok(!labels.has('needs-docs'));
+ });
+
+ it('new-feature-developer with has-tests does not add needs-tests', async () => {
+ const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
+ assert.ok(!labels.has('needs-tests'));
+ });
+
+ it('new-feature without a docs link still adds needs-docs', async () => {
+ const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], NO_DOCS_CONTEXT, false);
+ assert.ok(labels.has('needs-docs'));
+ });
+
+ it('new-feature-developer without a developer docs link adds needs-developer-docs', async () => {
+ const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, NO_DOCS_CONTEXT, false);
+ assert.ok(labels.has('needs-developer-docs'));
+ });
+
+ it('a developers.esphome.io shorthand link satisfies needs-developer-docs', async () => {
+ const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_CONTEXT, false);
+ assert.ok(!labels.has('needs-developer-docs'));
+ });
+
+ it('a developers.esphome.io URL link satisfies needs-developer-docs', async () => {
+ const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, DEV_DOCS_URL_CONTEXT, false);
+ assert.ok(!labels.has('needs-developer-docs'));
+ });
+
+ it('a user docs (esphome.io) link does not satisfy needs-developer-docs', async () => {
+ const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), SOURCE_FILES, USER_DOCS_CONTEXT, false);
+ assert.ok(labels.has('needs-developer-docs'));
+ });
+
+ it('a developer docs link does not satisfy needs-docs for new-feature', async () => {
+ const labels = await detectRequirements(new Set(['new-feature', 'has-tests']), [], DEV_DOCS_CONTEXT, false);
+ assert.ok(labels.has('needs-docs'));
+ });
+
+ it('changes confined to core validator files are exempt from needs-developer-docs', async () => {
+ const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), VALIDATOR_FILES, NO_DOCS_CONTEXT, false);
+ assert.ok(!labels.has('needs-developer-docs'));
+ });
+
+ it('validator changes mixed with other source files are not exempt', async () => {
+ const prFiles = [...VALIDATOR_FILES, { filename: 'esphome/components/foo/foo.py' }];
+ const labels = await detectRequirements(new Set(['new-feature-developer', 'has-tests']), prFiles, NO_DOCS_CONTEXT, false);
+ assert.ok(labels.has('needs-developer-docs'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// MANAGED_LABELS
+// ---------------------------------------------------------------------------
+
+describe('MANAGED_LABELS', () => {
+ it('includes new-feature-developer so the workflow syncs it', () => {
+ assert.ok(MANAGED_LABELS.includes('new-feature-developer'));
+ });
+
+ it('includes needs-developer-docs so the workflow syncs it', () => {
+ assert.ok(MANAGED_LABELS.includes('needs-developer-docs'));
+ });
+});
+
+// ---------------------------------------------------------------------------
+// detectPRSize
+// ---------------------------------------------------------------------------
+
+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 6376cf877e..30915b68c7 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
@@ -20,20 +21,24 @@ env:
jobs:
label:
runs-on: ubuntu-latest
- if: github.event.action != 'labeled' || github.event.sender.type != 'Bot'
+ 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Generate a token
id: generate-token
- uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # 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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml
index 6d200956e9..820081cc46 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ enable-cache: true
+ # Pull-request-only workflow: a save could never be shared and
+ # would only consume quota.
+ save-cache: "false"
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ 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
@@ -47,7 +60,7 @@ jobs:
fi
- if: failure()
name: Review PR
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
await github.rest.pulls.createReview({
@@ -62,7 +75,7 @@ jobs:
run: git diff
- if: failure()
name: Archive artifacts
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: generated-proto-files
path: |
@@ -70,7 +83,7 @@ jobs:
esphome/components/api/api_pb2_service.*
- if: success()
name: Dismiss review
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
let reviews = await github.rest.pulls.listReviews({
diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml
deleted file mode 100644
index 5054a62207..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()
- name: Request changes
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.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()
- name: Dismiss review
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.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..926e96078b 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.12"
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
+
+ - name: Log in to the GitHub container registry
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
+ 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - 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..ea039de9b9
--- /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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - 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..0dc653bd39 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Always check out from the base repository (esphome/esphome), never from forks
# Use the PR's target branch to ensure we run trusted code from the main repo
@@ -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 461e676c4e..72ece5b4fd 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment
id: cache-venv
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ enable-cache: true
+ # Pull request saves land in per-PR scopes nothing else can
+ # reuse; dev pushes seed the shared copy instead.
+ save-cache: ${{ github.event_name != 'pull_request' }}
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -106,6 +115,93 @@ jobs:
script/build_codeowners.py --check
script/build_language_schema.py --check
script/generate-esp32-boards.py --check
+ script/generate-rp2-boards.py --check
+ script/ci_check_duplicate_test_ids.py
+
+ import-time:
+ name: Check import esphome.__main__ time
+ runs-on: ubuntu-24.04
+ needs:
+ - common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.import-time == 'true'
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+ - name: 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ path: esphome
+ - name: Check out esphome/device-builder
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ repository: esphome/device-builder
+ ref: main
+ path: device-builder
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.13"
+ - name: Set up uv
+ # Mirrors the install shape device-builder's own CI uses
+ # (esphome/device-builder#192): uv replaces pip for the
+ # install step (order-of-magnitude faster on cold boots,
+ # with its own wheel cache). actions/setup-python still
+ # provides the interpreter.
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ enable-cache: true
+ # Pull request saves land in per-PR scopes nothing else can
+ # reuse; dev pushes seed the shared copy instead.
+ save-cache: ${{ github.event_name != 'pull_request' }}
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
+ - name: Install device-builder + esphome from PR
+ # Install device-builder with its esphome + test extras
+ # first so its pinned versions of pytest/etc. land, then
+ # overlay the PR's esphome so the downstream tests run
+ # against this PR's Python code. ``--system`` installs into
+ # the runner's Python instead of a venv.
+ run: |
+ uv pip install --system -e './device-builder[esphome,test]'
+ uv pip install --system -e ./esphome
+ - name: Run device-builder pytest
+ # ``-n auto`` runs under pytest-xdist (matches device-builder's
+ # own CI). No ``--cov`` here -- this is purely a downstream
+ # smoke check against this PR's esphome code. ``tests/e2e/slow``
+ # is excluded: those are real multi-minute toolchain compiles
+ # (LibreTiny SDK clone, native ESP-IDF install) that device-builder
+ # runs in its own dedicated jobs, not this smoke check.
+ working-directory: device-builder
+ run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow
pytest:
name: Run pytest
@@ -113,7 +209,7 @@ jobs:
fail-fast: false
matrix:
python-version:
- - "3.11"
+ - "3.12"
- "3.13"
- "3.14"
os:
@@ -131,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
@@ -146,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@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
+ 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ 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 }}
@@ -169,10 +267,17 @@ jobs:
needs:
- common
outputs:
+ core-ci: ${{ steps.determine.outputs.core-ci }}
integration-tests: ${{ steps.determine.outputs.integration-tests }}
+ 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 }}
@@ -182,9 +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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Fetch enough history to find the merge base
fetch-depth: 2
@@ -194,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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -204,15 +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-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
@@ -222,48 +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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python 3.13
id: python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.13"
- name: Restore Python virtual environment
id: cache-venv
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ enable-cache: true
+ # Pull request saves land in per-PR scopes nothing else can
+ # reuse; dev pushes seed the shared copy instead.
+ save-cache: ${{ github.event_name != 'pull_request' }}
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: 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:
+ # 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
- pytest -vv --no-cov --tb=native -n auto tests/integration/
+ 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
@@ -274,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -292,6 +436,43 @@ jobs:
script/cpp_unit_test.py $ARGS
fi
+ benchmarks:
+ name: Run CodSpeed benchmarks
+ runs-on: ubuntu-24.04
+ needs:
+ - common
+ - determine-jobs
+ if: >-
+ (github.event_name == 'push' && github.ref_name == 'dev') ||
+ (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+
+ - name: Build benchmarks
+ id: build
+ run: |
+ . venv/bin/activate
+ export BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
+ # --build-only prints BUILD_BINARY= to stdout
+ BINARY=$(script/cpp_benchmark.py --all --build-only | grep '^BUILD_BINARY=' | tail -1 | cut -d= -f2-)
+ echo "binary=$BINARY" >> $GITHUB_OUTPUT
+
+ - name: Run CodSpeed benchmarks
+ uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5
+ with:
+ run: |
+ . venv/bin/activate
+ ${{ steps.build.outputs.binary }}
+ pytest tests/benchmarks/python/ --codspeed --no-cov
+ mode: simulation
+
clang-tidy-single:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
@@ -301,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
@@ -311,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -334,38 +528,44 @@ jobs:
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ 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
@@ -375,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
@@ -391,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
@@ -399,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -412,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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
-
- - name: Cache platformio
- if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
- 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: |
@@ -435,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
@@ -447,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
@@ -470,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -501,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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
-
- - name: Cache platformio
- if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
- 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: |
@@ -524,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
@@ -536,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"
@@ -550,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ 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: |
@@ -569,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
+ - name: Cache 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
@@ -614,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
@@ -635,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 ""
@@ -647,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+
+ - name: Run 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+ - name: Cache pre-commit environments
+ id: cache-pre-commit
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/pre-commit
+ # Must match the restore key in pre-commit-ci-lite
+ # yamllint disable-line rule:line-length
+ key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
+ - name: Install pre-commit hook environments
+ if: steps.cache-pre-commit.outputs.cache-hit != 'true'
+ run: |
+ python -m pip install pre-commit
+ pre-commit install-hooks
pre-commit-ci-lite:
name: pre-commit.ci lite
runs-on: ubuntu-latest
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache
+ # Inlined from esphome/pre-commit-action with a restore-only cache
+ # step: the pre-commit-seed-cache job owns saving this cache, so
+ # pull request runs never write per-PR copies.
+ - name: Restore pre-commit cache
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.cache/pre-commit
+ # Must match the key pre-commit-seed-cache saves
+ # yamllint disable-line rule:line-length
+ key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
+ - name: Run pre-commit
env:
- SKIP: pylint,clang-tidy-hash
+ 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()
@@ -689,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.base_ref }}
@@ -765,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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -789,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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ 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') }}
@@ -816,7 +1242,8 @@ jobs:
python script/test_build_components.py \
-e compile \
-c "$component_list" \
- -t "$platform" 2>&1 | \
+ -t "$platform" \
+ --base-only 2>&1 | \
tee /dev/stderr | \
python script/ci_memory_impact_extract.py \
--output-env \
@@ -830,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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -851,7 +1278,7 @@ jobs:
fi
- name: Upload memory analysis JSON
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memory-analysis-target
path: memory-analysis-target.json
@@ -870,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio
- uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
+ 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') }}
@@ -902,7 +1329,8 @@ jobs:
python script/test_build_components.py \
-e compile \
-c "$component_list" \
- -t "$platform" 2>&1 | \
+ -t "$platform" \
+ --base-only 2>&1 | \
tee /dev/stderr | \
python script/ci_memory_impact_extract.py \
--output-env \
@@ -915,7 +1343,7 @@ jobs:
--platform "$platform"
- name: Upload memory analysis JSON
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: memory-analysis-pr
path: memory-analysis-pr.json
@@ -932,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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -981,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
@@ -998,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 34ff934b77..bb1d1e2d7a 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |
@@ -34,7 +34,7 @@ jobs:
CODEOWNERS
- name: Check codeowner approval and update label
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
with:
diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml
index a89c03ba04..38a4b8ff0e 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.base.sha }}
- - name: Request reviews from component codeowners
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ - 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 1a0c54da6d..953f19c0a9 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
+ uses: github/codeql-action/init@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
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@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
+ uses: github/codeql-action/analyze@e0647621c2984b5ed2f768cb892365bf2a616ad1 # v4.37.2
with:
category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml
index 4fa020f63d..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: Add external component comment
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ - name: Generate a token
+ id: generate-token
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
+ 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: ${{ 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 6faf956c87..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:
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Notify codeowners for component issues
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const owner = context.repo.owner;
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 2ad023ed1b..3a89c26cd3 100644
--- a/.github/workflows/pr-title-check.yml
+++ b/.github/workflows/pr-title-check.yml
@@ -3,19 +3,22 @@ name: PR Title Check
on:
pull_request:
types: [opened, edited, synchronize, reopened]
+ branches-ignore:
+ - release
+ - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const {
@@ -26,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;
}
@@ -65,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 0ed41d99c7..b8dbb7414d 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.x"
- name: Build
@@ -70,7 +70,7 @@ jobs:
pip3 install build
python3 -m build
- name: Publish
- uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
+ uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1
with:
skip-existing: true
@@ -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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
+ uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -138,7 +138,7 @@ jobs:
# version: ${{ needs.init.outputs.tag }}
- name: Upload digests
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: digests-${{ matrix.platform.arch }}
path: /tmp/digests
@@ -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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- 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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.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@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.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@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ 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 cca70815b9..72987c25b1 100644
--- a/.github/workflows/status-check-labels.yml
+++ b/.github/workflows/status-check-labels.yml
@@ -2,30 +2,32 @@ name: Status check labels
on:
pull_request:
- types: [labeled, unlabeled]
+ types: [opened, reopened, labeled, unlabeled, synchronize]
+
+permissions:
+ pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
jobs:
check:
- name: Check ${{ matrix.label }}
+ name: Check blocking labels
runs-on: ubuntu-latest
- strategy:
- fail-fast: false
- matrix:
- label:
- - needs-docs
- - merge-after-release
- - chained-pr
steps:
- - name: Check for ${{ matrix.label }} label
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+ - name: Check for blocking labels
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
+ const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr'];
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number
});
- const hasLabel = labels.find(label => label.name === '${{ matrix.label }}');
- if (hasLabel) {
- core.setFailed('Pull request cannot be merged, it is labeled as ${{ matrix.label }}');
+ const labelNames = labels.map(l => l.name);
+ const found = blockingLabels.filter(bl => labelNames.includes(bl));
+ if (found.length > 0) {
+ core.setFailed(`Pull request cannot be merged, it has blocking label(s): ${found.join(', ')}`);
}
diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml
index b0d966555b..5bec463a33 100644
--- a/.github/workflows/sync-device-classes.yml
+++ b/.github/workflows/sync-device-classes.yml
@@ -6,42 +6,94 @@ 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Checkout Home Assistant
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: home-assistant/core
path: lib/home-assistant
- name: Setup Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
- python-version: 3.13
+ 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@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+ with:
+ enable-cache: true
+ # Pin uv version so the action does not have to fetch the
+ # manifest from raw.githubusercontent.com on every cache
+ # miss; that fetch flakes on Windows runners.
+ version: "0.11.15"
- name: Install Home Assistant
run: |
- python -m pip install --upgrade pip
- pip install -e lib/home-assistant
- pip install -r requirements_test.txt pre-commit
+ uv pip install --system -e lib/home-assistant
+ uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
- name: Sync
run: |
python ./script/sync-device_class.py
- - name: Run pre-commit hooks
- run: |
- python script/run-in-env.py pre-commit run --all-files
+ - name: Apply pre-commit auto-fixes
+ # First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
+ # files. pre-commit exits non-zero whenever a hook touches anything,
+ # which would otherwise abort the workflow before the auto-fixes
+ # can flow into the sync PR.
+ #
+ # SKIP:
+ # - no-commit-to-branch is a local guard against committing on
+ # dev/release/beta; CI runs on dev by definition, and
+ # peter-evans/create-pull-request creates the branch itself.
+ # - pylint surfaces import-error / relative-beyond-top-level
+ # noise here because this workflow installs only a subset of
+ # the runtime deps (HA + requirements*.txt); main CI already
+ # gates pylint on real PRs.
+ env:
+ SKIP: pylint,no-commit-to-branch
+ run: python script/run-in-env.py pre-commit run --all-files || true
+
+ - name: Verify pre-commit clean
+ # Second pass: re-run all hooks against the now-fixed tree.
+ # Auto-fixers exit 0 (nothing to change); any remaining failure
+ # from a check-only hook (flake8 / yamllint / ci-custom) is a
+ # real issue and fails the workflow loudly. Same SKIP list as
+ # above for the same reasons.
+ env:
+ SKIP: pylint,no-commit-to-branch
+ run: python script/run-in-env.py pre-commit run --all-files
- name: Commit changes
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
+ uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
commit-message: "Synchronise Device Classes from Home Assistant"
committer: esphomebot
@@ -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 5e2bfe09ce..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.6
+ 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,13 +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: python script/run-in-env.py script/ci-custom.py
+ language: system
diff --git a/.ai/instructions.md b/AGENTS.md
similarity index 68%
rename from .ai/instructions.md
rename to AGENTS.md
index 240a47a52f..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:
@@ -124,6 +136,28 @@ This document provides essential context for AI models interacting with this pro
* **Indentation:** Use spaces (two per indentation level), not tabs
* **Type aliases:** Prefer `using type_t = int;` over `typedef int type_t;`
* **Line length:** Wrap lines at no more than 120 characters
+ * **Constructor parameters vs setters:** Component properties that are both **required** and **invariant**
+ (never change after construction) should be constructor parameters rather than set via setter methods.
+ This makes the dependency explicit and prevents use of the object in an incompletely-initialized state.
+ In code generation, when calling `cg.new_Pvariable()` or the relevant helper function to create the component, pass these as arguments.
+ ```cpp
+ // Good - required invariant dependency as constructor parameter
+ class SourceTextSensor : public text_sensor::TextSensor, public Component {
+ public:
+ explicit SourceTextSensor(text::Text *source) : source_(source) {}
+ protected:
+ text::Text *source_;
+ };
+ ```
+ ```cpp
+ // Bad - required invariant dependency as setter
+ class SourceTextSensor : public text_sensor::TextSensor, public Component {
+ public:
+ void set_source(text::Text *source) { this->source_ = source; }
+ protected:
+ text::Text *source_{nullptr};
+ };
+ ```
* **Component Structure:**
* **Standard Files:**
@@ -217,6 +251,123 @@ This document provides essential context for AI models interacting with this pro
var = await switch.new_switch(config)
```
+* **Automations (Triggers, Actions, Conditions):**
+
+ Automations have three building blocks: **Triggers** (fire when something happens), **Actions** (do something), and **Conditions** (check if something is true).
+
+ * **Triggers -- Callback method (preferred):**
+
+ Use `build_callback_automation()` for simple triggers. This eliminates the need for a C++ Trigger class by using a lightweight pointer-sized forwarder struct registered directly as a callback. No `CONF_TRIGGER_ID` in the schema.
+
+ **Python:**
+ ```python
+ from esphome import automation
+
+ CONFIG_SCHEMA = cv.Schema({
+ cv.GenerateID(): cv.declare_id(MyComponent),
+ cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
+ }).extend(cv.COMPONENT_SCHEMA)
+
+ async def to_code(config):
+ var = cg.new_Pvariable(config[CONF_ID])
+ await cg.register_component(var, config)
+ for conf in config.get(CONF_ON_STATE, []):
+ await automation.build_callback_automation(
+ var, "add_on_state_callback", [(bool, "x")], conf
+ )
+ ```
+
+ `build_callback_automation` arguments: `parent`, `callback_method` (C++ method name), `args` (template args as `[(type, name)]` tuples), `config`, and optional `forwarder` (defaults to `TriggerForwarder`).
+
+ For boolean filtering (e.g. `on_press`/`on_release`), use built-in forwarders with `args=[]`:
+ ```python
+ for conf_key, forwarder in (
+ (CONF_ON_PRESS, automation.TriggerOnTrueForwarder),
+ (CONF_ON_RELEASE, automation.TriggerOnFalseForwarder),
+ ):
+ for conf in config.get(conf_key, []):
+ await automation.build_callback_automation(
+ var, "add_on_state_callback", [], conf, forwarder=forwarder
+ )
+ ```
+
+ **C++ -- no trigger class needed.** The callback registration method must be templatized to accept both `std::function` and lightweight forwarder structs (which avoid heap allocation):
+ ```cpp
+ class MyComponent : public Component {
+ public:
+ // Must be a template -- accepts both std::function and pointer-sized forwarder structs
+ template void add_on_state_callback(F &&callback) {
+ this->state_callback_.add(std::forward(callback));
+ }
+ protected:
+ // Use CallbackManager when callbacks are always registered (e.g. core components)
+ CallbackManager state_callback_;
+ // Use LazyCallbackManager when callbacks are often not registered -- saves 8 bytes
+ // (nullptr vs empty std::vector) per instance when no callbacks are added
+ // LazyCallbackManager state_callback_;
+ };
+ ```
+
+ * **Triggers -- Trigger class method:**
+
+ Use `build_automation()` with a `Trigger` subclass only when the forwarder needs **mutable state beyond a single `Automation*` pointer** (e.g. edge detection tracking previous state, timing logic).
+
+ **Python:**
+ ```python
+ TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
+
+ CONFIG_SCHEMA = cv.Schema({
+ cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
+ {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
+ ),
+ })
+
+ async def to_code(config):
+ for conf in config.get(CONF_ON_TURN_ON, []):
+ trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
+ await automation.build_automation(trigger, [], conf)
+ ```
+
+ **C++:**
+ ```cpp
+ class TurnOnTrigger : public Trigger<> {
+ public:
+ explicit TurnOnTrigger(MyComponent *parent) : last_on_{false} {
+ parent->add_on_state_callback([this](bool state) {
+ if (state && !this->last_on_)
+ this->trigger();
+ this->last_on_ = state;
+ });
+ }
+ protected:
+ bool last_on_;
+ };
+ ```
+
+ * **Actions:**
+ ```cpp
+ template class MyAction : public Action {
+ public:
+ explicit MyAction(MyComponent *parent) : parent_(parent) {}
+ void play(const Ts &...) override { this->parent_->do_something(); }
+ protected:
+ MyComponent *parent_;
+ };
+ ```
+ Register with `@automation.register_action("my_component.do_something", MyAction, schema, synchronous=True)`. Use `synchronous=True` for actions that run to completion inside `play()` without deferring. Use `synchronous=False` if the action may suspend/defer execution (e.g. `delay`, `wait_until`, `script.wait`) or store trigger arguments for later use.
+
+ * **Conditions:**
+ ```cpp
+ template class MyCondition : public Condition {
+ public:
+ explicit MyCondition(MyComponent *parent) : parent_(parent) {}
+ bool check(const Ts &...) override { return this->parent_->is_active(); }
+ protected:
+ MyComponent *parent_;
+ };
+ ```
+ Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
+
* **Configuration Validation:**
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
@@ -252,10 +403,50 @@ This document provides essential context for AI models interacting with this pro
* **Component Tests:** YAML-based compilation tests are located in `tests/`. The structure is as follows:
```
tests/
- ├── test_build_components/ # Base test configurations
- └── components/[component]/ # Component-specific tests
+ ├── test_build_components/
+ │ └── common/ # Shared bus packages (uart, i2c, spi, etc.)
+ │ ├── uart/ # UART at default baud rate
+ │ ├── uart_115200/ # UART at 115200 baud
+ │ ├── i2c/ # I2C bus
+ │ └── spi/ # SPI bus
+ └── components/[component]/
+ ├── common.yaml # Component-only config (no bus definitions)
+ ├── test.esp32-idf.yaml # config + compile
+ ├── test.esp8266-ard.yaml # config + compile
+ ├── test-variant.esp32-idf.yaml # variant test, config + compile
+ ├── validate.esp32-idf.yaml # config-only (never compiled)
+ └── validate-legacy.esp32-idf.yaml # config-only variant
```
Run them using `script/test_build_components`. Use `-c ` to test specific components and `-t ` for specific platforms.
+
+ * **Config-only test files (`validate.*.yaml`):** Use this prefix when a YAML file only needs to exercise schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with `esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
+ - `validate..yaml` — base config-only test
+ - `validate-..yaml` — config-only variant
+
+ Use this for things like deprecated-syntax migration tests, schema edge cases, or platform-specific validation branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and `validate.*.yaml` files. Validate files never participate in bus-grouping; each one runs as its own `esphome config` invocation.
+
+ When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
+
+ * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`.
+
+ 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 — everything included via named packages
+ packages:
+ uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
+ my_component: !include common.yaml
+ ```
+ ```yaml
+ # common.yaml — component config only, NO bus definitions
+ my_component:
+ id: my_instance
+
+ sensor:
+ - platform: my_component
+ name: My Sensor
+ ```
+ Components that define buses directly are flagged as "NEEDS MIGRATION" and cannot be grouped, increasing CI build time.
+
* **Testing All Components Together:** To verify that all components can be tested together without ID conflicts or configuration issues, use:
```bash
./script/test_component_grouping.py -e config --all
@@ -265,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`.
@@ -284,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.
@@ -395,6 +585,30 @@ This document provides essential context for AI models interacting with this pro
Note: Avoiding heap allocation after `setup()` is always required regardless of component type. The prioritization above is about the effort spent on container optimization (e.g., migrating from `std::vector` to `StaticVector`).
+ **Callback Managers:**
+
+ ESPHome provides two callback manager types in `esphome/core/helpers.h` for the observer pattern. Both support `std::function`, lambdas, and lightweight forwarder structs via their templatized `add()` method.
+
+ | Type | Idle overhead (32-bit) | When to use |
+ |------|----------------------|-------------|
+ | `CallbackManager` | 12 bytes (empty `std::vector`) | Callbacks are always or almost always registered |
+ | `LazyCallbackManager` | 4 bytes (`nullptr`) | Callbacks are often not registered (common case) |
+
+ `LazyCallbackManager` is a drop-in replacement for `CallbackManager` that defers allocation until the first callback is added. Prefer it for entity-level callbacks where most instances have no subscribers.
+
+ **Important:** Registration methods that add to a callback manager **must always be templatized** to accept both `std::function` and pointer-sized forwarder structs (used by `build_callback_automation`). Never use `std::function` in the method signature:
+ ```cpp
+ // Bad -- forces heap allocation for forwarder structs
+ void add_on_state_callback(std::function &&callback) {
+ this->state_callback_.add(std::move(callback));
+ }
+
+ // Good -- accepts any callable without forcing std::function wrapping
+ template void add_on_state_callback(F &&callback) {
+ this->state_callback_.add(std::forward(callback));
+ }
+ ```
+
* **State Management:** Use `CORE.data` for component state that needs to persist during configuration generation. Avoid module-level mutable globals.
**Bad Pattern (Module-Level Globals):**
@@ -443,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
@@ -479,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++):**
@@ -496,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 e72b164761..b73ed319c8 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
@@ -74,6 +74,7 @@ esphome/components/bl0939/* @ziceva
esphome/components/bl0940/* @dan-s-github @tobias-
esphome/components/bl0942/* @dbuezas @dwmw2
esphome/components/ble_client/* @buxtronix @clydebarrow
+esphome/components/ble_device_base/* @Bl00d-B0b
esphome/components/ble_nus/* @tomaszduda23
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
esphome/components/bm8563/* @abmantis
@@ -83,6 +84,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
@@ -92,6 +94,7 @@ esphome/components/bmp3xx_i2c/* @latonita
esphome/components/bmp3xx_spi/* @latonita
esphome/components/bmp581_base/* @danielkent-net @kahrendt
esphome/components/bmp581_i2c/* @danielkent-net @kahrendt
+esphome/components/bmp581_spi/* @danielkent-net @kahrendt
esphome/components/bp1658cj/* @Cossid
esphome/components/bp5758d/* @Cossid
esphome/components/bthome_mithermometer/* @nagyrobi
@@ -120,7 +123,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
@@ -137,16 +142,18 @@ 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/dsmr/* @glmnet @PolarGoose @zuidwijk
+esphome/components/ds248x/* @tomwellnitz
+esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duty_time/* @dudanov
esphome/components/ee895/* @Stock-M
esphome/components/ektf2232/touchscreen/* @jesserockz
esphome/components/emc2101/* @ellull
esphome/components/emmeti/* @E440QF
+esphome/components/emontx/* @FredM67 @glynhudson @TrystanLea
esphome/components/ens160/* @latonita
esphome/components/ens160_base/* @latonita @vincentscode
esphome/components/ens160_i2c/* @latonita
@@ -182,6 +189,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
@@ -203,6 +211,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
@@ -216,6 +225,7 @@ esphome/components/hbridge/light/* @DotNetDann
esphome/components/hbridge/switch/* @dwmw2
esphome/components/hc8/* @omartijn
esphome/components/hdc2010/* @optimusprimespace @ssieb
+esphome/components/hdc2080/* @G-Pereira @jesserockz
esphome/components/hdc302x/* @joshuasing
esphome/components/he60r/* @clydebarrow
esphome/components/heatpumpir/* @rob-deutsch
@@ -244,7 +254,6 @@ esphome/components/hyt271/* @Philippe12
esphome/components/i2c/* @esphome/core
esphome/components/i2c_device/* @gabest11
esphome/components/i2s_audio/* @jesserockz
-esphome/components/i2s_audio/media_player/* @jesserockz
esphome/components/i2s_audio/microphone/* @jesserockz
esphome/components/i2s_audio/speaker/* @jesserockz @kahrendt
esphome/components/iaqcore/* @yozik04
@@ -263,6 +272,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
@@ -288,6 +298,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
@@ -330,6 +341,7 @@ esphome/components/mipi_dsi/* @clydebarrow
esphome/components/mipi_rgb/* @clydebarrow
esphome/components/mipi_spi/* @clydebarrow
esphome/components/mitsubishi/* @RubyBailey
+esphome/components/mitsubishi_cn105/* @crnjan
esphome/components/mixer/speaker/* @kahrendt
esphome/components/mlx90393/* @functionpointer
esphome/components/mlx90614/* @jesserockz
@@ -343,9 +355,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
@@ -374,9 +388,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
@@ -392,14 +408,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
@@ -410,7 +429,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
@@ -434,7 +455,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
@@ -458,6 +484,9 @@ esphome/components/sn74hc165/* @jesserockz
esphome/components/socket/* @esphome/core
esphome/components/sonoff_d1/* @anatoly-savchenkov
esphome/components/sound_level/* @kahrendt
+esphome/components/spa06_base/* @danielkent-net
+esphome/components/spa06_i2c/* @danielkent-net
+esphome/components/spa06_spi/* @danielkent-net
esphome/components/speaker/* @jesserockz @kahrendt
esphome/components/speaker/media_player/* @kahrendt @synesthesiam
esphome/components/speaker_source/* @kahrendt
@@ -478,6 +507,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
@@ -542,6 +572,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
@@ -558,6 +589,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
@@ -579,6 +611,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
@@ -593,6 +626,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 cfdb74bd19..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.4.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 ed65edc656..1626261fb6 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -4,4 +4,6 @@ include requirements.txt
recursive-include esphome *.yaml
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/README.md b/README.md
index b8ce8d091d..16497ee0be 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# ESPHome [](https://discord.gg/KhAMKrd) [](https://GitHub.com/esphome/esphome/releases/)
+# ESPHome [](https://discord.gg/KhAMKrd) [](https://GitHub.com/esphome/esphome/releases/) [](https://codspeed.io/esphome/esphome)
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..8649dbcd77 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.9
+
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 4b0fc2cec7..e56b504398 100644
--- a/esphome/__main__.py
+++ b/esphome/__main__.py
@@ -16,18 +16,15 @@ import sys
import time
from typing import Protocol
-import argcomplete
-
# Note: Do not import modules from esphome.components here, as this would
# cause them to be loaded before external components are processed, resulting
# in the built-in version being used instead of the external component one.
-from esphome import const, writer, yaml_util
-import esphome.codegen as cg
-from esphome.config import iter_component_configs, read_config, strip_default_ids
+from esphome import const
from esphome.const import (
ALLOWED_NAME_CHARS,
ARGUMENT_HELP_DEVICE,
CONF_API,
+ CONF_AUTH,
CONF_BAUD_RATE,
CONF_BROKER,
CONF_DEASSERT_RTS_DTR,
@@ -39,6 +36,7 @@ from esphome.const import (
CONF_MDNS,
CONF_MQTT,
CONF_NAME,
+ CONF_NAME_ADD_MAC_SUFFIX,
CONF_OTA,
CONF_PASSWORD,
CONF_PLATFORM,
@@ -46,22 +44,22 @@ from esphome.const import (
CONF_PORT,
CONF_SUBSTITUTIONS,
CONF_TOPIC,
+ CONF_USERNAME,
+ CONF_WEB_SERVER,
+ CONF_WIFI,
ENV_NOGITIGNORE,
- KEY_CORE,
- KEY_NATIVE_IDF,
- KEY_TARGET_PLATFORM,
- PLATFORM_ESP32,
- PLATFORM_ESP8266,
- PLATFORM_RP2040,
SECRETS_FILES,
+ Toolchain,
)
from esphome.core import CORE, EsphomeError, coroutine
from esphome.enum import StrEnum
from esphome.helpers import get_bool_env, indent, is_ip_address
from esphome.log import AnsiFore, color, setup_log
from esphome.types import ConfigType
+from esphome.upload_targets import PortType, get_port_type
from esphome.util import (
PICOTOOL_PACKAGE,
+ FlashImage,
detect_rp2040_bootsel,
get_picotool_path,
get_serial_ports,
@@ -72,6 +70,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 +148,6 @@ class ArgsProtocol(Protocol):
configuration: str
name: str
upload_speed: str | None
- native_idf: bool
def choose_prompt(options, purpose: str = None):
@@ -184,14 +187,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 +199,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,
@@ -242,25 +328,18 @@ def choose_upload_log_host(
resolved.append("MQTT")
if has_api() and has_non_ip_address() and has_resolvable_address():
- resolved.extend(_resolve_with_cache(CORE.address, purpose))
+ 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 +351,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 +360,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"))
+ 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 +451,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 +472,16 @@ def has_ota() -> bool:
)
+def has_web_server_ota() -> bool:
+ """Check if web_server OTA upload is available (``platform: web_server``)."""
+ if CONF_OTA not in CORE.config:
+ return False
+ return any(
+ ota_item.get(CONF_PLATFORM) == CONF_WEB_SERVER
+ for ota_item in CORE.config[CONF_OTA]
+ )
+
+
def has_mqtt_ip_lookup() -> bool:
"""Check if MQTT is available and IP lookup is supported."""
from esphome.components.mqtt import CONF_DISCOVER_IP
@@ -381,17 +500,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 +524,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 +560,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 +587,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 +617,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 +634,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 +665,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 +682,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,7 +699,9 @@ 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):
+ import esphome.codegen as cg
+
coro = coroutine(comp.to_code)
@functools.wraps(comp.to_code)
@@ -583,33 +720,42 @@ 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
+ from esphome.config import iter_component_configs
+
_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 +768,48 @@ 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)
+
+ # Keep this here, NOT in codegen: config-hash and --only-generate must keep
+ # working on machines that cannot run the toolchain.
+ if CORE.is_esp8266:
+ from esphome.components.esp8266 import check_rosetta
+
+ check_rosetta()
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
- 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 +836,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,24 +898,35 @@ 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
- ),
- *idedata.extra_flash_images,
+ FlashImage(path=idedata.firmware_bin_path, offset=firmware_offset),
]
+ for image in idedata.extra_flash_images:
+ if not image.path.is_file():
+ _LOGGER.warning(
+ "Skipping missing flash image declared by platform: %s",
+ image.path,
+ )
+ continue
+ flash_images.append(image)
mcu = "esp8266"
if CORE.is_esp32:
@@ -811,13 +986,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"
@@ -827,15 +1002,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)
@@ -848,9 +1023,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():
@@ -923,7 +1098,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:
@@ -968,13 +1143,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,
@@ -985,45 +1176,239 @@ 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]:
+ web_conf = config.get(CONF_WEB_SERVER)
+ if not web_conf:
+ raise EsphomeError(
+ f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
+ f"is not configured."
+ )
+
+ remote_port = int(web_conf[CONF_PORT])
+ auth = web_conf.get(CONF_AUTH) or {}
+ username = auth.get(CONF_USERNAME)
+ password = auth.get(CONF_PASSWORD)
+
+ from esphome import web_server_ota
+
+ return web_server_ota.run_ota(
+ network_devices, remote_port, username, password, binary
+ )
+
+
+# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
+# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
+# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
+# trailing checksum entry. Padding past the last entry is 0xFF. The full table is
+# exactly ESP_PARTITION_TABLE_MAX_LEN bytes.
+_PARTITION_TABLE_MAX_LEN = 0xC00
+_ESP_PARTITION_MAGIC = 0x50AA
+_ESP_PARTITION_MAGIC_MD5 = 0xEBEB
+_ESP_IMAGE_HEADER_MAGIC = 0xE9
+
+
+def _validate_partition_table_binary(binary: Path) -> None:
+ """Validate that ``binary`` looks like an ESP32 partition table image.
+
+ Catches common mistakes (wrong file, truncated build output, swapped --file path)
+ before opening a network connection so the failure mode is a clear local error
+ instead of a post-handshake device rejection.
+ """
+ try:
+ data = binary.read_bytes()
+ except OSError as err:
+ raise EsphomeError(
+ f"Cannot read partition table file '{binary}': {err}"
+ ) from err
+
+ if len(data) != _PARTITION_TABLE_MAX_LEN:
+ raise EsphomeError(
+ f"Partition table file '{binary}' has wrong size: expected "
+ f"{_PARTITION_TABLE_MAX_LEN} bytes, got {len(data)}. "
+ "Pass the partition table image (e.g. partitions.bin / partition-table.bin), "
+ "not the firmware image."
+ )
+
+ first_magic = data[0] | (data[1] << 8)
+ if first_magic != _ESP_PARTITION_MAGIC:
+ raise EsphomeError(
+ f"Partition table file '{binary}' does not start with the expected "
+ f"partition magic 0x{_ESP_PARTITION_MAGIC:04X} (got 0x{first_magic:04X}). "
+ "This file does not look like an ESP32 partition table."
+ )
+
+ # The MD5 checksum entry is required: without it the device-side
+ # esp_partition_table_verify will accept the table but the bootloader will
+ # refuse to boot from it. Scan the 32-byte entries for the MD5 magic.
+ if not any(
+ (data[off] | (data[off + 1] << 8)) == _ESP_PARTITION_MAGIC_MD5
+ for off in range(0, _PARTITION_TABLE_MAX_LEN, 32)
+ ):
+ raise EsphomeError(
+ f"Partition table file '{binary}' is missing the MD5 checksum entry. "
+ "Regenerate the partition table with gen_esp32part.py or rebuild the project."
+ )
+
+
+def _validate_bootloader_binary(binary: Path) -> None:
+ """Validate that ``binary`` looks like an ESP32 bootloader image."""
+ try:
+ data = binary.read_bytes()
+ except OSError as err:
+ raise EsphomeError(f"Cannot read bootloader file '{binary}': {err}") from err
+
+ if not data:
+ raise EsphomeError(
+ f"Bootloader file '{binary}' is empty. "
+ "This file does not look like an ESP32 bootloader."
+ )
+
+ first_magic = data[0]
+ if first_magic != _ESP_IMAGE_HEADER_MAGIC:
+ raise EsphomeError(
+ f"Bootloader file '{binary}' does not start with the expected "
+ f"image header magic 0x{_ESP_IMAGE_HEADER_MAGIC:02X} (got 0x{first_magic:02X}). "
+ "This file does not look like an ESP32 bootloader."
+ )
+
+
+def _should_subscribe_states(args: ArgsProtocol) -> bool:
+ """Determine whether entity state changes should be shown in log output.
+
+ The ``--states``/``--no-states`` command line flags take precedence. When
+ neither is given, the ``ESPHOME_LOG_STATES`` environment variable controls
+ the behavior, defaulting to showing states.
+ """
+ states = getattr(args, "states", None)
+ if states is not None:
+ return states
+ return get_bool_env("ESPHOME_LOG_STATES", True)
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
try:
module = importlib.import_module("esphome.components." + CORE.target_platform)
- if getattr(module, "show_logs")(config, args, devices):
+ if module.show_logs(config, args, devices):
return 0
except AttributeError:
pass
@@ -1046,7 +1431,11 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
):
from esphome.components.api.client import run_logs
- return run_logs(config, network_devices)
+ return run_logs(
+ config,
+ network_devices,
+ subscribe_states=_should_subscribe_states(args),
+ )
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
from esphome import mqtt
@@ -1073,20 +1462,96 @@ 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
+ from esphome.config import strip_default_ids
+
+ 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[5m\2\\033[6m", 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
+ key = m.group("key")
+ if not in_substitutions:
+ # Public keys (e.g. wireguard's peer_public_key) are not secret;
+ # redacting them and telling maintainers to mark them cv.sensitive
+ # would be wrong on both counts. Substitution keys are user-named
+ # with no schema behind them, so anything secret-shaped there
+ # (public or not) stays conservatively redacted.
+ if "public" in key.split("_"):
+ continue
+ unmarked.add(key)
+ lines[i] = (
+ f"{line[: m.start()]}{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
@@ -1096,8 +1561,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:
@@ -1107,9 +1571,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.")
@@ -1152,8 +1621,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)
@@ -1161,9 +1629,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)
@@ -1189,10 +1662,7 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
# After BOOTSEL upload, wait for a new serial port to appear
# so it shows up in the log chooser
- if (
- successful_device is None
- and CORE.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()
@@ -1214,6 +1684,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:
@@ -1229,8 +1701,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
@@ -1238,10 +1712,46 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
return 0
-def command_dashboard(args: ArgsProtocol) -> int | None:
- from esphome.dashboard import dashboard
+def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
+ from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator
- return dashboard.start_dashboard(args)
+ creator = ConfigBundleCreator(config)
+
+ if args.list_only:
+ files = creator.discover_files()
+ for bf in sorted(files, key=lambda f: f.path):
+ safe_print(f" {bf.path}")
+ _LOGGER.info("Found %d files", len(files))
+ return 0
+
+ result = creator.create_bundle()
+
+ if args.output:
+ output_path = Path(args.output)
+ else:
+ stem = CORE.config_path.stem
+ output_path = CORE.config_dir / f"{stem}{BUNDLE_EXTENSION}"
+
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_bytes(result.data)
+
+ _LOGGER.info(
+ "Bundle created: %s (%d files, %.1f KB)",
+ output_path,
+ len(result.files),
+ len(result.data) / 1024,
+ )
+ return 0
+
+
+def command_dashboard(args: ArgsProtocol) -> int | None:
+ 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(
@@ -1318,12 +1828,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
@@ -1337,7 +1869,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
@@ -1351,12 +1882,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)
@@ -1366,8 +1910,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,
)
@@ -1383,7 +1927,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()
@@ -1392,13 +1936,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:
@@ -1424,11 +1970,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 (
@@ -1451,7 +2018,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))}"
)
@@ -1503,6 +2103,7 @@ PRE_CONFIG_ACTIONS = {
POST_CONFIG_ACTIONS = {
"config": command_config,
+ "config-hash": command_config_hash,
"compile": command_compile,
"upload": command_upload,
"logs": command_logs,
@@ -1513,6 +2114,7 @@ POST_CONFIG_ACTIONS = {
"rename": command_rename,
"discover": command_discover,
"analyze-memory": command_analyze_memory,
+ "bundle": command_bundle,
}
SIMPLE_CONFIG_ACTIONS = [
@@ -1522,6 +2124,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(
@@ -1571,6 +2196,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]
@@ -1603,6 +2239,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."
@@ -1615,11 +2264,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",
@@ -1642,6 +2286,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",
@@ -1664,6 +2329,7 @@ def parse_args(argv):
help="Reset the device before starting serial logs.",
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
)
+ _add_states_args(parser_logs)
parser_discover = subparsers.add_parser(
"discover",
@@ -1694,6 +2360,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",
@@ -1702,9 +2371,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(
@@ -1732,50 +2407,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")
@@ -1809,6 +2465,24 @@ def parse_args(argv):
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
+ parser_bundle = subparsers.add_parser(
+ "bundle",
+ help="Create a self-contained config bundle for remote compilation.",
+ )
+ parser_bundle.add_argument(
+ "configuration", help="Your YAML configuration file(s).", nargs="+"
+ )
+ parser_bundle.add_argument(
+ "-o",
+ "--output",
+ help="Output path for the bundle archive.",
+ )
+ parser_bundle.add_argument(
+ "--list-only",
+ help="List discovered files without creating the archive.",
+ action="store_true",
+ )
+
# Keep backward compatibility with the old command line format of
# esphome .
#
@@ -1824,7 +2498,12 @@ def parse_args(argv):
# a deprecation warning).
arguments = argv[1:]
- argcomplete.autocomplete(parser)
+ # argcomplete only does anything when the shell-completion machinery
+ # invokes us with _ARGCOMPLETE set; skip the import otherwise.
+ if "_ARGCOMPLETE" in os.environ:
+ import argcomplete
+
+ argcomplete.autocomplete(parser)
if len(arguments) > 0 and arguments[0] in SIMPLE_CONFIG_ACTIONS:
args, unknown_args = parser.parse_known_args(arguments)
@@ -1852,11 +2531,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:
@@ -1887,19 +2562,72 @@ def run_esphome(argv):
_LOGGER.warning("Skipping secrets file %s", conf_path)
return 0
+ # Bundle support: if the configuration is a .esphomebundle, extract it
+ # and rewrite conf_path to the extracted YAML config.
+ from esphome.bundle import is_bundle_path, prepare_bundle_for_compile
+
+ if is_bundle_path(conf_path):
+ _LOGGER.info("Extracting config bundle %s...", conf_path)
+ conf_path = prepare_bundle_for_compile(conf_path)
+ # Update the argument so downstream code sees the extracted path
+ args.configuration[0] = str(conf_path)
+
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:
+ from esphome.config import read_config
+
+ 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 bf1bcbfa05..1198562218 100644
--- a/esphome/analyze_memory/__init__.py
+++ b/esphome/analyze_memory/__init__.py
@@ -1,6 +1,6 @@
"""Memory usage analyzer for ESPHome compiled binaries."""
-from collections import defaultdict
+from collections import Counter, defaultdict
from dataclasses import dataclass, field
import logging
from pathlib import Path
@@ -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__)
@@ -40,6 +40,15 @@ _READELF_SECTION_PATTERN = re.compile(
r"\s*\[\s*\d+\]\s+([\.\w]+)\s+\w+\s+[\da-fA-F]+\s+[\da-fA-F]+\s+([\da-fA-F]+)"
)
+# Regex for extracting call targets from objdump disassembly
+# Matches direct call instructions across architectures:
+# Xtensa: call0/call4/call8/call12/callx0/callx4/callx8/callx12
+# ARM: bl/blx
+# Captures the mangled symbol name inside angle brackets.
+_CALL_TARGET_PATTERN = re.compile(
+ r"\t(?:call(?:0|4|8|12)|callx(?:0|4|8|12)|blx?)\s+[\da-fA-F]+ <([^>]+)>"
+)
+
# Component category prefixes
_COMPONENT_PREFIX_ESPHOME = "[esphome]"
_COMPONENT_PREFIX_EXTERNAL = "[external]"
@@ -47,6 +56,10 @@ _COMPONENT_PREFIX_LIB = "[lib]"
_COMPONENT_CORE = f"{_COMPONENT_PREFIX_ESPHOME}core"
_COMPONENT_API = f"{_COMPONENT_PREFIX_ESPHOME}api"
+# Placement new storage suffix (generated by codegen Pvariable)
+_PSTORAGE_SUFFIX = "__pstorage"
+
+
# C++ namespace prefixes
_NAMESPACE_ESPHOME = "esphome::"
_NAMESPACE_STD = "std::"
@@ -192,20 +205,27 @@ class MemoryAnalyzer:
self._cswtch_symbols: list[tuple[str, int, str, str]] = []
# Library symbol mapping: symbol_name -> library_name
self._lib_symbol_map: dict[str, str] = {}
+ # Source file symbol mapping: symbol_name -> component_name
+ # Used for extern "C" and other symbols without C++ namespace
+ self._source_symbol_map: dict[str, str] = {}
# Library dir to name mapping: "lib641" -> "espsoftwareserial",
# "espressif__mdns" -> "mdns"
self._lib_hash_to_name: dict[str, str] = {}
# Heuristic category to library redirect: "mdns_lib" -> "[lib]mdns"
self._heuristic_to_lib: dict[str, str] = {}
+ # Function call counts: mangled_name -> call_count
+ self._function_call_counts: Counter[str] = Counter()
def analyze(self) -> dict[str, ComponentMemory]:
"""Analyze the ELF file and return component memory usage."""
self._parse_sections()
self._parse_symbols()
self._scan_libraries()
+ self._scan_source_symbols()
self._categorize_symbols()
self._analyze_cswtch_symbols()
self._analyze_sdk_libraries()
+ self._analyze_function_calls()
return dict(self.components)
def _parse_sections(self) -> None:
@@ -316,6 +336,13 @@ class MemoryAnalyzer:
# Demangle C++ names if needed
demangled = self._demangle_symbol(symbol_name)
+ # Check for placement new storage symbols (generated by codegen)
+ # Format: {component}__{id}__pstorage
+ if demangled.endswith(_PSTORAGE_SUFFIX) and (
+ component := self._match_pstorage_component(demangled)
+ ):
+ return component
+
# Check for special component classes first (before namespace pattern)
# This handles cases like esphome::ESPHomeOTAComponent which should map to ota
if _NAMESPACE_ESPHOME in demangled:
@@ -351,6 +378,11 @@ class MemoryAnalyzer:
if lib_name := self._lib_symbol_map.get(symbol_name):
return f"{_COMPONENT_PREFIX_LIB}{lib_name}"
+ # Check source file mapping (catches extern "C" functions in ESPHome sources)
+ # Must be before heuristic patterns since source attribution is authoritative
+ if component := self._source_symbol_map.get(symbol_name):
+ return component
+
# Check against symbol patterns
for component, patterns in SYMBOL_PATTERNS.items():
if any(pattern in symbol_name for pattern in patterns):
@@ -378,14 +410,33 @@ class MemoryAnalyzer:
# Track uncategorized symbols for analysis
return "other"
+ def _match_pstorage_component(self, symbol_name: str) -> str | None:
+ """Match a __pstorage symbol to its ESPHome component.
+
+ Symbol format: {component}__{id}__pstorage
+ The component namespace is embedded by codegen before the double underscore.
+ """
+ prefix = symbol_name[: -len(_PSTORAGE_SUFFIX)]
+ # Extract component namespace before the first double underscore
+ dunder_pos = prefix.find("__")
+ if dunder_pos == -1:
+ return None
+ component_name = prefix[:dunder_pos]
+ if component_name in get_esphome_components():
+ return f"{_COMPONENT_PREFIX_ESPHOME}{component_name}"
+ if component_name in self.external_components:
+ return f"{_COMPONENT_PREFIX_EXTERNAL}{component_name}"
+ return None
+
def _batch_demangle_symbols(self, symbols: list[str]) -> None:
"""Batch demangle C++ symbol names for efficiency."""
if not symbols:
return
_LOGGER.info("Demangling %d symbols", len(symbols))
- self._demangle_cache = batch_demangle(symbols, objdump_path=self.objdump_path)
- _LOGGER.info("Successfully demangled %d symbols", len(self._demangle_cache))
+ demangled = batch_demangle(symbols, objdump_path=self.objdump_path)
+ self._demangle_cache.update(demangled)
+ _LOGGER.info("Successfully demangled %d symbols", len(demangled))
def _demangle_symbol(self, symbol: str) -> str:
"""Get demangled C++ symbol name from cache."""
@@ -640,6 +691,7 @@ class MemoryAnalyzer:
return None
symbol_map: dict[str, str] = {}
+ source_symbol_map: dict[str, str] = {}
current_symbol: str | None = None
section_prefixes = (".text.", ".rodata.", ".data.", ".bss.", ".literal.")
@@ -675,9 +727,18 @@ class MemoryAnalyzer:
if dir_key in source_path:
symbol_map[current_symbol] = lib_name
break
+ else:
+ # Map ESPHome source files to components for extern "C"
+ # and other symbols without C++ namespace
+ component = self._source_file_to_component(source_path)
+ if component.startswith(
+ (_COMPONENT_PREFIX_ESPHOME, _COMPONENT_PREFIX_EXTERNAL)
+ ):
+ source_symbol_map[current_symbol] = component
current_symbol = None
+ self._source_symbol_map = source_symbol_map
return symbol_map or None
def _scan_libraries(self) -> None:
@@ -728,6 +789,115 @@ class MemoryAnalyzer:
len(libraries),
)
+ def _scan_source_symbols(self) -> None:
+ """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/`` (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()``.
+ """
+ if self._source_symbol_map or not self.nm_path:
+ return
+
+ obj_dir = self._find_object_files_dir()
+ if obj_dir is None:
+ return
+
+ # 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(src_dir.rglob("*.o"))
+ if not obj_files:
+ return
+
+ # Run nm with --print-file-name to get file:symbol mapping
+ result = run_tool(
+ [self.nm_path, "--print-file-name", "-g", "--defined-only"]
+ + [str(f) for f in obj_files],
+ )
+ if result is None or result.returncode != 0:
+ _LOGGER.debug("nm scan of source objects failed")
+ return
+
+ self._source_symbol_map = self._parse_nm_source_output(result.stdout, obj_dir)
+ if self._source_symbol_map:
+ _LOGGER.info(
+ "Built source symbol map from nm: %d symbols",
+ len(self._source_symbol_map),
+ )
+
+ def _parse_nm_source_output(self, output: str, base_dir: Path) -> dict[str, str]:
+ """Parse nm output to map non-namespaced symbols to ESPHome components.
+
+ Extracts global defined symbols from ESPHome source object files that
+ don't use C++ namespacing (e.g. ``extern "C"`` functions).
+
+ Args:
+ output: Raw stdout from ``nm --print-file-name -g --defined-only``
+ or ``nm --print-file-name -S``.
+ base_dir: Build directory for computing relative paths.
+
+ Returns:
+ Dict mapping symbol names to component names.
+ """
+ source_map: dict[str, str] = {}
+ for line in output.splitlines():
+ # Format: /path/to/file.o: addr type name
+ # or: /path/to/file.o: addr size type name (with -S)
+ colon_idx = line.rfind(".o:")
+ if colon_idx == -1:
+ continue
+
+ file_path = line[: colon_idx + 2]
+ fields = line[colon_idx + 3 :].split()
+ if len(fields) < 3:
+ continue
+
+ # With -S flag, format is: addr size type name
+ # Without -S flag: addr type name
+ # type is a single char; size is hex digits
+ # Detect by checking if fields[1] is a single uppercase letter (type)
+ if len(fields[1]) == 1 and fields[1].isalpha():
+ # addr type name
+ sym_type = fields[1]
+ symbol_name = fields[2]
+ elif len(fields) >= 4:
+ # addr size type name
+ sym_type = fields[2]
+ symbol_name = fields[3]
+ else:
+ continue
+
+ # Only global defined symbols (uppercase type)
+ if not sym_type.isupper() or sym_type == "U":
+ continue
+
+ # Skip symbols already in esphome:: namespace
+ if symbol_name.startswith("_ZN7esphome"):
+ continue
+
+ # Make path relative to base_dir for _source_file_to_component
+ try:
+ rel_path = str(Path(file_path).relative_to(base_dir))
+ except ValueError:
+ continue
+
+ component = self._source_file_to_component(rel_path)
+ if component.startswith(
+ (_COMPONENT_PREFIX_ESPHOME, _COMPONENT_PREFIX_EXTERNAL)
+ ):
+ source_map[symbol_name] = component
+
+ return source_map
+
def _find_object_files_dir(self) -> Path | None:
"""Find the directory containing object files for this build.
@@ -897,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
@@ -1011,6 +1185,43 @@ class MemoryAnalyzer:
total_size,
)
+ def _analyze_function_calls(self) -> None:
+ """Count function call sites by parsing disassembly output.
+
+ Parses direct call instructions (call0/call8/bl/blx) from objdump -d
+ to count how many times each function is called. This helps identify
+ inlining candidates — frequently called small functions benefit most
+ from inlining.
+ """
+ result = run_tool(
+ [self.objdump_path, "-d", str(self.elf_path)],
+ timeout=60,
+ )
+ if result is None or result.returncode != 0:
+ _LOGGER.debug("Failed to disassemble ELF for function call analysis")
+ return
+
+ self._function_call_counts = Counter(
+ match.group(1)
+ for line in result.stdout.splitlines()
+ if (match := _CALL_TARGET_PATTERN.search(line))
+ )
+
+ # Demangle any call targets not already in the cache
+ missing = [
+ name
+ for name in self._function_call_counts
+ if name not in self._demangle_cache
+ ]
+ if missing:
+ self._batch_demangle_symbols(missing)
+
+ _LOGGER.debug(
+ "Function call analysis: %d unique targets, %d total calls",
+ len(self._function_call_counts),
+ sum(self._function_call_counts.values()),
+ )
+
def get_unattributed_ram(self) -> tuple[int, int, int]:
"""Get unattributed RAM sizes (SDK/framework overhead).
diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py
index dbc19c6b89..ab20e4d076 100644
--- a/esphome/analyze_memory/cli.py
+++ b/esphome/analyze_memory/cli.py
@@ -6,6 +6,7 @@ from collections import defaultdict
from collections.abc import Callable
import heapq
from operator import itemgetter
+from pathlib import Path
import sys
from typing import TYPE_CHECKING
@@ -15,14 +16,27 @@ from . import (
_COMPONENT_PREFIX_ESPHOME,
_COMPONENT_PREFIX_EXTERNAL,
_COMPONENT_PREFIX_LIB,
+ _PSTORAGE_SUFFIX,
RAM_SECTIONS,
MemoryAnalyzer,
)
+from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
if TYPE_CHECKING:
from . import ComponentMemory
+def _format_pstorage_name(name: str) -> str:
+ """Format a __pstorage symbol as 'storage for {id}'."""
+ if not name.endswith(_PSTORAGE_SUFFIX):
+ return name
+ prefix = name[: -len(_PSTORAGE_SUFFIX)]
+ # Strip component namespace prefix: {component}__{id} -> {id}
+ dunder_pos = prefix.find("__")
+ var_id = prefix[dunder_pos + 2 :] if dunder_pos != -1 else prefix
+ return f"storage for {var_id}"
+
+
class MemoryAnalyzerCLI(MemoryAnalyzer):
"""Memory analyzer with CLI-specific report generation."""
@@ -148,11 +162,14 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
If section is one of the RAM sections (.data or .bss), a label like
" [data]" or " [bss]" is appended. For non-RAM sections or when
section is None, no section label is added.
+
+ Placement new storage symbols are formatted as "storage for {id}".
"""
+ display_name = _format_pstorage_name(demangled)
section_label = ""
if section in RAM_SECTIONS:
section_label = f" [{section[1:]}]" # .data -> [data], .bss -> [bss]
- return f"{demangled} ({size:,} B){section_label}"
+ return f"{display_name} ({size:,} B){section_label}"
def _add_top_symbols(self, lines: list[str]) -> None:
"""Add a section showing the top largest symbols in the binary."""
@@ -175,11 +192,13 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
for i, (_, demangled, size, section, component) in enumerate(top_symbols):
# Format section label
section_label = f"[{section[1:]}]" if section else ""
- # Truncate demangled name if too long
+ # Format storage symbols readably
+ display_name = _format_pstorage_name(demangled)
+ # Truncate if too long
demangled_display = (
- f"{demangled[:truncate_limit]}..."
- if len(demangled) > self.COL_TOP_SYMBOL_NAME
- else demangled
+ f"{display_name[:truncate_limit]}..."
+ if len(display_name) > self.COL_TOP_SYMBOL_NAME
+ else display_name
)
lines.append(
f"{i + 1:>2}. {size:>7,} B {section_label:<8} {demangled_display:<{self.COL_TOP_SYMBOL_NAME}} {component}"
@@ -231,6 +250,110 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(f" {size:>6,} B {sym_name}")
lines.append("")
+ # Number of top called functions to show
+ TOP_CALLS_LIMIT: int = 50
+ # Number of inlining candidates to show
+ INLINE_CANDIDATES_LIMIT: int = 25
+ # Maximum function size in bytes to consider for inlining
+ INLINE_SIZE_THRESHOLD: int = 16
+
+ def _build_symbol_sizes(self) -> dict[str, int]:
+ """Build a size lookup from all component symbols: mangled_name -> size."""
+ return {
+ symbol: size
+ for symbols in self._component_symbols.values()
+ for symbol, _, size, _ in symbols
+ }
+
+ def _format_call_row(
+ self, index: int, mangled: str, count: int, symbol_sizes: dict[str, int]
+ ) -> str:
+ """Format a single row for call frequency tables."""
+ demangled = self._demangle_cache.get(mangled, mangled)
+ if len(demangled) > 80:
+ demangled = f"{demangled[:77]}..."
+ size = symbol_sizes.get(mangled)
+ size_str = f"{size:>5,} B" if size is not None else " ?"
+ return f"{index:>3} {count:>5} {size_str} {demangled}"
+
+ def _add_call_table_header(self, lines: list[str]) -> None:
+ """Add the header row for call frequency tables."""
+ lines.append(f"{'#':>3} {'Calls':>5} {'Size':>7} Function")
+ lines.append(f"{'---':>3} {'-----':>5} {'-------':>7} {'-' * 60}")
+
+ def _add_function_call_analysis(self, lines: list[str]) -> None:
+ """Add function call frequency analysis section.
+
+ Shows the most frequently called functions by call site count.
+ """
+ self._add_section_header(lines, "Top Called Functions")
+
+ symbol_sizes = self._build_symbol_sizes()
+
+ # Sort by call count descending
+ sorted_calls = sorted(
+ self._function_call_counts.items(), key=lambda x: x[1], reverse=True
+ )
+
+ self._add_call_table_header(lines)
+
+ for i, (mangled, count) in enumerate(sorted_calls[: self.TOP_CALLS_LIMIT]):
+ lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes))
+
+ total_calls = sum(self._function_call_counts.values())
+ lines.append("")
+ lines.append(
+ f"Total: {len(self._function_call_counts)} unique targets, "
+ f"{total_calls:,} call sites"
+ )
+ lines.append("")
+
+ def _add_inline_candidates(self, lines: list[str]) -> None:
+ """Add inlining candidates section.
+
+ Shows frequently called functions that are small enough to benefit
+ from inlining (< 16 bytes). These are the best candidates for
+ reducing call overhead.
+ """
+ self._add_section_header(
+ lines,
+ f"Inlining Candidates (<{self.INLINE_SIZE_THRESHOLD} B, by call count)",
+ )
+
+ symbol_sizes = self._build_symbol_sizes()
+
+ # Filter to small functions with known size, sort by call count
+ candidates = sorted(
+ (
+ (mangled, count)
+ for mangled, count in self._function_call_counts.items()
+ if mangled in symbol_sizes
+ and symbol_sizes[mangled] < self.INLINE_SIZE_THRESHOLD
+ ),
+ key=lambda x: x[1],
+ reverse=True,
+ )
+
+ if not candidates:
+ lines.append("No candidates found.")
+ lines.append("")
+ return
+
+ self._add_call_table_header(lines)
+
+ for i, (mangled, count) in enumerate(
+ candidates[: self.INLINE_CANDIDATES_LIMIT]
+ ):
+ lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes))
+
+ lines.append("")
+ lines.append(
+ f"Showing top {min(len(candidates), self.INLINE_CANDIDATES_LIMIT)} "
+ f"of {len(candidates)} functions under "
+ f"{self.INLINE_SIZE_THRESHOLD} B"
+ )
+ lines.append("")
+
def generate_report(self, detailed: bool = False) -> str:
"""Generate a formatted memory report."""
components = sorted(
@@ -388,7 +511,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(
@@ -469,17 +592,18 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(f"Total size: {comp_mem.flash_total:,} B")
lines.append("")
- # Show all symbols above threshold for better visibility
+ # Show symbols above threshold, always include storage symbols
large_symbols = [
(sym, dem, size, sec)
for sym, dem, size, sec in sorted_symbols
if size > self.SYMBOL_SIZE_THRESHOLD
+ or dem.endswith(_PSTORAGE_SUFFIX)
]
lines.append(
- f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_symbols)} symbols):"
+ 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)}"
)
@@ -500,7 +624,10 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
# Sort by size descending
sorted_ram_syms = sorted(ram_syms, key=lambda x: x[2], reverse=True)
large_ram_syms = [
- s for s in sorted_ram_syms if s[2] > self.RAM_SYMBOL_SIZE_THRESHOLD
+ s
+ for s in sorted_ram_syms
+ if s[2] > self.RAM_SYMBOL_SIZE_THRESHOLD
+ or s[1].endswith(_PSTORAGE_SUFFIX)
]
lines.append(f"{name} ({mem.ram_total:,} B total RAM):")
@@ -515,16 +642,17 @@ 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)
# Add ellipsis if name is truncated
- demangled_display = (
- f"{demangled[:70]}..." if len(demangled) > 70 else demangled
- )
- lines.append(
- f" {size:>6,} B [{section_label}] {demangled_display}"
+ display_name = (
+ f"{display_name[:70]}..."
+ if len(display_name) > 70
+ else display_name
)
+ lines.append(f" {size:>6,} B [{section_label}] {display_name}")
if len(large_ram_syms) > 10:
lines.append(f" ... and {len(large_ram_syms) - 10} more")
lines.append("")
@@ -533,6 +661,11 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
if self._cswtch_symbols:
self._add_cswtch_analysis(lines)
+ # Function call frequency analysis
+ if self._function_call_counts:
+ self._add_function_call_analysis(lines)
+ self._add_inline_candidates(lines)
+
lines.append(
"Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included."
)
@@ -568,7 +701,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)
@@ -606,9 +739,8 @@ def main():
# Load build directory
import json
- from pathlib import Path
- from esphome.platformio_api import IDEData
+ from esphome.platformio.toolchain import IDEData
build_path = Path(build_dir)
@@ -628,45 +760,25 @@ def main():
print(f"Error: {build_path} is not a directory", file=sys.stderr)
sys.exit(1)
- # Find firmware.elf
- elf_file = None
- for elf_candidate in [
- build_path / "firmware.elf",
- build_path / ".pioenvs" / build_path.name / "firmware.elf",
- ]:
- if elf_candidate.exists():
- elf_file = str(elf_candidate)
- break
-
- if not elf_file:
- print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
+ elf_path = find_elf_path(build_path)
+ if not elf_path:
+ print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr)
sys.exit(1)
-
- # Find idedata.json - check current directory first, then home
- device_name = build_path.name
- idedata_candidates = [
- Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
- Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
- ]
+ elf_file = str(elf_path)
idedata = None
- for idedata_path in idedata_candidates:
- if not idedata_path.exists():
- continue
+ if idedata_path := find_idedata_path(build_path):
try:
- with 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/const.py b/esphome/analyze_memory/const.py
index 3bdf555ae3..0c871d0727 100644
--- a/esphome/analyze_memory/const.py
+++ b/esphome/analyze_memory/const.py
@@ -408,7 +408,6 @@ SYMBOL_PATTERNS = {
],
"arduino_core": [
"pinMode",
- "resetPins",
"millis",
"micros",
"delay(", # More specific - Arduino delay function with parenthesis
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 36ab30b654..1689d29c42 100644
--- a/esphome/automation.py
+++ b/esphome/automation.py
@@ -1,3 +1,4 @@
+from dataclasses import dataclass, field
import logging
import esphome.codegen as cg
@@ -126,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)
@@ -137,6 +138,9 @@ UpdateComponentAction = cg.esphome_ns.class_("UpdateComponentAction", Action)
SuspendComponentAction = cg.esphome_ns.class_("SuspendComponentAction", Action)
ResumeComponentAction = cg.esphome_ns.class_("ResumeComponentAction", Action)
Automation = cg.esphome_ns.class_("Automation")
+TriggerForwarder = cg.esphome_ns.class_("TriggerForwarder")
+TriggerOnTrueForwarder = cg.esphome_ns.class_("TriggerOnTrueForwarder")
+TriggerOnFalseForwarder = cg.esphome_ns.class_("TriggerOnFalseForwarder")
LambdaCondition = cg.esphome_ns.class_("LambdaCondition", Condition)
StatelessLambdaCondition = cg.esphome_ns.class_("StatelessLambdaCondition", Condition)
@@ -195,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)]
@@ -247,7 +250,9 @@ async def and_condition_to_code(
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
- return cg.new_Pvariable(condition_id, template_arg, conditions)
+ return cg.new_Pvariable(
+ condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
+ )
@register_condition("or", OrCondition, validate_condition_list)
@@ -258,7 +263,9 @@ async def or_condition_to_code(
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
- return cg.new_Pvariable(condition_id, template_arg, conditions)
+ return cg.new_Pvariable(
+ condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
+ )
@register_condition("all", AndCondition, validate_condition_list)
@@ -269,7 +276,9 @@ async def all_condition_to_code(
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
- return cg.new_Pvariable(condition_id, template_arg, conditions)
+ return cg.new_Pvariable(
+ condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
+ )
@register_condition("any", OrCondition, validate_condition_list)
@@ -280,7 +289,9 @@ async def any_condition_to_code(
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
- return cg.new_Pvariable(condition_id, template_arg, conditions)
+ return cg.new_Pvariable(
+ condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
+ )
@register_condition("not", NotCondition, validate_potentially_and_condition)
@@ -302,7 +313,9 @@ async def xor_condition_to_code(
args: TemplateArgsType,
) -> MockObj:
conditions = await build_condition_list(config, template_arg, args)
- return cg.new_Pvariable(condition_id, template_arg, conditions)
+ return cg.new_Pvariable(
+ condition_id, cg.TemplateArguments(len(conditions), *template_arg), conditions
+ )
@register_condition("lambda", LambdaCondition, cv.returning_lambda)
@@ -383,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
@@ -413,13 +425,16 @@ async def if_action_to_code(
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
+ has_else = CONF_ELSE in config
+ # Prepend HasElse bool to template arguments: IfAction
+ if_template_arg = cg.TemplateArguments(has_else, *template_arg)
cond_conf = next(el for el in config if el in (CONF_ANY, CONF_ALL, CONF_CONDITION))
condition = await build_condition(config[cond_conf], template_arg, args)
- var = cg.new_Pvariable(action_id, template_arg, condition)
+ var = cg.new_Pvariable(action_id, if_template_arg, condition)
if CONF_THEN in config:
actions = await build_action_list(config[CONF_THEN], template_arg, args)
cg.add(var.add_then(actions))
- if CONF_ELSE in config:
+ if has_else:
actions = await build_action_list(config[CONF_ELSE], template_arg, args)
cg.add(var.add_else(actions))
return var
@@ -581,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
@@ -658,3 +673,76 @@ async def build_automation(
actions = await build_action_list(config[CONF_THEN], templ, args)
cg.add(obj.add_actions(actions))
return obj
+
+
+async def build_callback_automation(
+ parent: MockObj,
+ callback_method: str,
+ args: TemplateArgsType,
+ config: ConfigType,
+ forwarder: MockObj | MockObjClass | None = None,
+) -> None:
+ """Build an Automation and register it as a callback on the parent.
+
+ Eliminates the need for a Trigger wrapper object by registering the
+ automation's trigger() directly as a callback on the parent component.
+
+ Uses template forwarder structs so the compiler deduplicates the operator()
+ body across all call sites with the same signature. The forwarder must be
+ pointer-sized (single Automation* field) to fit inline in Callback::ctx_
+ and avoid heap allocation.
+
+ :param parent: The component object (e.g., button, sensor).
+ :param callback_method: Name of the callback method (e.g., "add_on_press_callback").
+ :param args: Automation template args as list of (type, name) tuples.
+ :param config: The automation config dict.
+ :param forwarder: Optional forwarder type to use instead of the default
+ TriggerForwarder. Pass any struct type whose aggregate init takes
+ a single Automation pointer (e.g., TriggerOnTrueForwarder).
+ """
+ arg_types = [arg[0] for arg in args]
+ templ = cg.TemplateArguments(*arg_types)
+ obj = cg.new_Pvariable(config[CONF_AUTOMATION_ID], templ)
+ actions = await build_action_list(config[CONF_THEN], templ, args)
+ cg.add(obj.add_actions(actions))
+ # Use template forwarder structs for deduplication. The compiler generates
+ # one operator() per forwarder type; different automation pointers are just
+ # data in the struct.
+ if forwarder is None:
+ forwarder = TriggerForwarder.template(templ)
+ # RawExpression for aggregate init — both forwarder and obj are codegen
+ # MockObjs (not user input), and there's no Expression type for positional
+ # aggregate initialization (StructInitializer uses named fields).
+ cg.add(getattr(parent, callback_method)(cg.RawExpression(f"{forwarder}{{{obj}}}")))
+
+
+@dataclass(frozen=True, slots=True)
+class CallbackAutomation:
+ """A single callback automation entry for build_callback_automations."""
+
+ conf_key: str
+ callback_method: str
+ args: TemplateArgsType = field(default_factory=list)
+ forwarder: MockObj | MockObjClass | None = None
+
+
+async def build_callback_automations(
+ parent: MockObj,
+ config: ConfigType,
+ entries: tuple[CallbackAutomation, ...],
+) -> None:
+ """Build multiple callback automations from a tuple of entries.
+
+ :param parent: The component object (e.g., button, sensor).
+ :param config: The full component config dict.
+ :param entries: Tuple of CallbackAutomation entries to process.
+ """
+ for entry in entries:
+ for conf in config.get(entry.conf_key, []):
+ await build_callback_automation(
+ parent,
+ entry.callback_method,
+ entry.args,
+ conf,
+ forwarder=entry.forwarder,
+ )
diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py
index 9df9b1069c..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,71 +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("-", "")
+ # 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 "{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 definitions from build flags (-DXXX -> XXX)
- compile_defs = [flag[2:] for flag in CORE.build_flags if flag.startswith("-D")]
- compile_defs_str = "\n ".join(sorted(compile_defs)) if compile_defs else ""
-
- # 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 definitions
-target_compile_definitions(${{COMPONENT_LIB}} PUBLIC
- {compile_defs_str}
-)
-
-# ESPHome compile options
-target_compile_options(${{COMPONENT_LIB}} PUBLIC
- {compile_opts_str}
+ REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
)
# ESPHome linker options
@@ -129,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
new file mode 100644
index 0000000000..dcaea03646
--- /dev/null
+++ b/esphome/bundle.py
@@ -0,0 +1,826 @@
+"""Config bundle creator and extractor for ESPHome.
+
+A bundle is a self-contained .tar.gz archive containing a YAML config
+and every local file it depends on. Bundles can be created from a config
+and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from enum import StrEnum
+import io
+import json
+import logging
+from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
+import re
+import shutil
+import tarfile
+from typing import Any
+
+from esphome import const, yaml_util
+from esphome.const import (
+ CONF_ESPHOME,
+ CONF_EXTERNAL_COMPONENTS,
+ CONF_INCLUDES,
+ CONF_INCLUDES_C,
+ CONF_PATH,
+ CONF_SOURCE,
+ CONF_TYPE,
+)
+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
+MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB
+MAX_MANIFEST_SIZE = 1024 * 1024 # 1 MB
+
+# Directories preserved across bundle extractions (build caches)
+_PRESERVE_DIRS = (".esphome", ".pioenvs", ".pio")
+_BUNDLE_STAGING_DIR = ".bundle_staging"
+
+
+class ManifestKey(StrEnum):
+ """Keys used in bundle manifest.json."""
+
+ MANIFEST_VERSION = "manifest_version"
+ ESPHOME_VERSION = "esphome_version"
+ CONFIG_FILENAME = "config_filename"
+ CONFIG_DIR = "config_dir"
+ FILES = "files"
+ HAS_SECRETS = "has_secrets"
+
+
+# String prefixes that are never local file paths
+_NON_PATH_PREFIXES = ("http://", "https://", "ftp://", "mdi:", "<")
+
+# File extensions recognized when resolving relative path strings.
+# A relative string with one of these extensions is resolved against the
+# config directory and included if the file exists.
+_KNOWN_FILE_EXTENSIONS = frozenset(
+ {
+ # Fonts
+ ".ttf",
+ ".otf",
+ ".woff",
+ ".woff2",
+ ".pcf",
+ ".bdf",
+ # Images
+ ".png",
+ ".jpg",
+ ".jpeg",
+ ".bmp",
+ ".gif",
+ ".svg",
+ ".ico",
+ ".webp",
+ # Certificates
+ ".pem",
+ ".crt",
+ ".key",
+ ".der",
+ ".p12",
+ ".pfx",
+ # C/C++ includes
+ ".h",
+ ".hpp",
+ ".c",
+ ".cpp",
+ ".ino",
+ # Web assets
+ ".css",
+ ".js",
+ ".html",
+ }
+)
+
+
+# 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]:
+ """Scan YAML files for ``!secret `` references."""
+ keys: set[str] = set()
+ for fpath in yaml_files:
+ try:
+ text = fpath.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError):
+ continue
+ for match in _SECRET_RE.finditer(text):
+ keys.add(match.group(1))
+ return keys
+
+
+@dataclass
+class BundleData:
+ """Files components asked to include, keyed under DOMAIN in CORE.data."""
+
+ extra_files: list[Path] = field(default_factory=list)
+ # Original config dir parsed from an extracted bundle's manifest.json,
+ # kept in the path flavor of the machine the bundle was created on.
+ # The checked flag makes the manifest lookup happen at most once per run;
+ # CORE.data is cleared between runs.
+ original_config_dir: PurePath | None = None
+ original_config_dir_checked: bool = False
+
+
+def _get_data() -> BundleData:
+ if DOMAIN not in CORE.data:
+ CORE.data[DOMAIN] = BundleData()
+ return CORE.data[DOMAIN]
+
+
+def add_bundle_file(path: Path) -> None:
+ """Register a file that a bundle must include.
+
+ Bundle discovery walks the validated config, so it only finds files the config
+ names. Components call this during validation for files it cannot see, such as a
+ file that is referenced from inside another file.
+
+ A relative path is taken as relative to the config directory. Files outside the
+ config directory are skipped when the bundle is built.
+ """
+ _get_data().extra_files.append(CORE.relative_config_path(path))
+
+
+# Windows paths start with a drive letter or contain backslashes; POSIX
+# paths do neither in practice, so this is how the flavor of a recorded
+# path string is recognized on any host.
+_WINDOWS_DRIVE_RE = re.compile(r"^[A-Za-z]:")
+
+
+def _path_flavor(value: str) -> type[PurePath]:
+ """Pick the pure path class matching the flavor ``value`` was written in."""
+ if "\\" in value or _WINDOWS_DRIVE_RE.match(value):
+ return PureWindowsPath
+ return PurePosixPath
+
+
+def _load_original_config_dir() -> PurePath | None:
+ """Read the original config dir from an extracted bundle's manifest.
+
+ Returns None when the current config dir is not an extracted bundle or
+ the manifest does not record the original config dir.
+ """
+ manifest_path = CORE.config_dir / MANIFEST_FILENAME
+ try:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ except FileNotFoundError:
+ # The common case: this config dir is not an extracted bundle.
+ return None
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as err:
+ # A manifest.json is present but unreadable or malformed. Say so
+ # instead of letting it look identical to "not a bundle".
+ _LOGGER.warning("Bundle: ignoring unreadable %s: %s", manifest_path, err)
+ return None
+ if not isinstance(manifest, dict):
+ return None
+ # A manifest.json in the config dir does not have to be ours. Only trust
+ # one that looks like a bundle manifest for exactly this config file.
+ version = manifest.get(ManifestKey.MANIFEST_VERSION)
+ if not isinstance(version, int) or version < 1:
+ return None
+ if manifest.get(ManifestKey.CONFIG_FILENAME) != CORE.config_path.name:
+ return None
+ config_dir = manifest.get(ManifestKey.CONFIG_DIR)
+ if not isinstance(config_dir, str) or not config_dir:
+ return None
+ return _path_flavor(config_dir)(config_dir)
+
+
+def remap_bundle_path(value: str) -> Path | None:
+ """Remap an absolute path from the machine a bundle was created on.
+
+ A bundled config may reference files by absolute path. The referenced
+ files ship inside the bundle at their config-relative locations, but the
+ YAML text is copied verbatim, so after extraction on another machine the
+ absolute reference points at a path that only existed on the creating
+ machine. The bundle manifest records that machine's config dir; when
+ ``value`` names a path that lived under it, return the corresponding
+ file next to the extracted config.
+
+ ``value`` is the raw path string from the config. It is parsed with the
+ original machine's path flavor, so a bundle created on Windows remaps on
+ a POSIX build server and vice versa.
+
+ Returns None when not compiling an extracted bundle, when ``value`` was
+ not under the original config dir, or when the bundle does not contain
+ the file.
+ """
+ data = _get_data()
+ if not data.original_config_dir_checked:
+ data.original_config_dir_checked = True
+ data.original_config_dir = _load_original_config_dir()
+ original_dir = data.original_config_dir
+ if original_dir is None:
+ return None
+ path = type(original_dir)(value)
+ if not path.is_absolute():
+ return None
+ try:
+ rel = path.relative_to(original_dir)
+ except ValueError:
+ return None
+ # relative_to is lexical, so ".." segments survive it. Refuse them: the
+ # remapped file must land strictly inside the extracted config tree.
+ if ".." in rel.parts:
+ return None
+ remapped = CORE.relative_config_path(Path(*rel.parts))
+ if not remapped.exists():
+ return None
+ return remapped
+
+
+@dataclass
+class BundleFile:
+ """A file to include in the bundle."""
+
+ path: str # Relative path inside the archive
+ source: Path # Absolute path on disk
+
+
+@dataclass
+class BundleResult:
+ """Result of creating a bundle."""
+
+ data: bytes
+ manifest: dict[str, Any]
+ files: list[BundleFile]
+
+
+@dataclass
+class BundleManifest:
+ """Parsed and validated bundle manifest."""
+
+ manifest_version: int
+ esphome_version: str
+ config_filename: str
+ files: list[str]
+ has_secrets: bool
+ config_dir: str | None = None
+
+
+class ConfigBundleCreator:
+ """Creates a self-contained bundle from an ESPHome config."""
+
+ def __init__(self, config: dict[str, Any]) -> None:
+ self._config = config
+ 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()
+
+ def discover_files(self) -> list[BundleFile]:
+ """Discover all files needed for the bundle."""
+ self._files = []
+ self._seen_paths = set()
+ self._secrets_paths = set()
+
+ # The main config file
+ self._add_file(self._config_path)
+
+ # Phase 1: YAML includes (tracked during config loading)
+ self._discover_yaml_includes()
+
+ # Phase 2: Component-referenced files from validated config
+ self._discover_component_files()
+
+ return list(self._files)
+
+ def create_bundle(self) -> BundleResult:
+ """Create the bundle archive."""
+ files = self.discover_files()
+
+ # Determine which secret keys are actually referenced by the
+ # bundled YAML files so we only ship those, not the entire
+ # secrets.yaml which may contain secrets for other devices.
+ yaml_sources = [
+ bf.source for bf in files if bf.source.suffix in (".yaml", ".yml")
+ ]
+ used_secret_keys = _find_used_secret_keys(yaml_sources)
+ filtered_secrets = self._build_filtered_secrets(used_secret_keys)
+
+ has_secrets = bool(filtered_secrets)
+ if has_secrets:
+ _LOGGER.warning(
+ "Bundle contains secrets (e.g. Wi-Fi passwords). "
+ "Do not share it with untrusted parties."
+ )
+
+ manifest = self._build_manifest(files, has_secrets=has_secrets)
+
+ buf = io.BytesIO()
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
+ # Add manifest first
+ manifest_data = json.dumps(manifest, indent=2).encode("utf-8")
+ _add_bytes_to_tar(tar, MANIFEST_FILENAME, manifest_data)
+
+ # Add filtered secrets files
+ for rel_path, data in sorted(filtered_secrets.items()):
+ _add_bytes_to_tar(tar, rel_path, data)
+
+ # Add files in sorted order for determinism, skipping secrets
+ # files which were already added above with filtered content
+ for bf in sorted(files, key=lambda f: f.path):
+ if bf.source in self._secrets_paths:
+ continue
+ self._add_to_tar(tar, bf)
+
+ return BundleResult(data=buf.getvalue(), manifest=manifest, files=files)
+
+ def _add_file(self, abs_path: Path) -> bool:
+ """Add a file to the bundle. Returns False if already added."""
+ abs_path = abs_path.resolve()
+ if abs_path in self._seen_paths:
+ return False
+ if not abs_path.is_file():
+ _LOGGER.warning("Bundle: skipping missing file %s", abs_path)
+ return False
+
+ rel_path = self._relative_to_config_dir(abs_path)
+ if rel_path is None:
+ _LOGGER.warning(
+ "Bundle: skipping file outside config directory: %s", abs_path
+ )
+ return False
+
+ self._seen_paths.add(abs_path)
+ self._files.append(BundleFile(path=rel_path, source=abs_path))
+ return True
+
+ def _add_directory(self, abs_path: Path) -> None:
+ """Recursively add all files in a directory."""
+ abs_path = abs_path.resolve()
+ if not abs_path.is_dir():
+ _LOGGER.warning("Bundle: skipping missing directory %s", abs_path)
+ return
+ for child in sorted(abs_path.rglob("*")):
+ if child.is_file() and "__pycache__" not in child.parts:
+ self._add_file(child)
+
+ def _relative_to_config_dir(self, abs_path: Path) -> str | None:
+ """Get a path relative to the config directory. Returns None if outside.
+
+ Always uses forward slashes for consistency in tar archives.
+ """
+ try:
+ return abs_path.relative_to(self._config_dir).as_posix()
+ except ValueError:
+ return None
+
+ def _discover_yaml_includes(self) -> None:
+ """Discover YAML files loaded during config parsing.
+
+ 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.
+ """
+ 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
+ self._add_file(fpath)
+
+ def _discover_component_files(self) -> None:
+ """Walk the validated config for file references.
+
+ Uses a generic recursive walk to find file paths instead of
+ hardcoding per-component knowledge about config dict formats.
+ After validation, components typically resolve paths to absolute
+ using CORE.relative_config_path() or cv.file_(). Relative paths
+ with known file extensions are also resolved and checked.
+
+ Core ESPHome concepts that use relative paths or directories
+ 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
+ esphome_conf = config.get(CONF_ESPHOME, {})
+ for include_path in esphome_conf.get(CONF_INCLUDES, []):
+ resolved = _resolve_include_path(include_path)
+ if resolved is None:
+ continue
+ if resolved.is_dir():
+ self._add_directory(resolved)
+ else:
+ self._add_file(resolved)
+ for include_path in esphome_conf.get(CONF_INCLUDES_C, []):
+ resolved = _resolve_include_path(include_path)
+ if resolved is not None:
+ self._add_file(resolved)
+
+ # external_components with source: local - directories
+ for ext_conf in config.get(CONF_EXTERNAL_COMPONENTS, []):
+ source = ext_conf.get(CONF_SOURCE, {})
+ if not isinstance(source, dict):
+ continue
+ if source.get(CONF_TYPE) != "local":
+ continue
+ path = source.get(CONF_PATH)
+ if not path:
+ continue
+ p = Path(path)
+ if not p.is_absolute():
+ p = CORE.relative_config_path(p)
+ self._add_directory(p)
+
+ def _walk_config_for_files(self, obj: Any) -> None:
+ """Recursively walk the config dict looking for file path references."""
+ if isinstance(obj, dict):
+ for value in obj.values():
+ self._walk_config_for_files(value)
+ elif isinstance(obj, (list, tuple)):
+ for item in obj:
+ self._walk_config_for_files(item)
+ elif isinstance(obj, Path):
+ if obj.is_absolute() and obj.is_file():
+ self._add_file(obj)
+ elif isinstance(obj, str):
+ self._check_string_path(obj)
+
+ def _check_string_path(self, value: str) -> None:
+ """Check if a string value is a local file reference."""
+ # Fast exits for strings that cannot be file paths
+ if len(value) < 2 or "\n" in value:
+ return
+ if value.startswith(_NON_PATH_PREFIXES):
+ return
+ # File paths must contain a path separator or a dot (for extension)
+ if "/" not in value and "\\" not in value and "." not in value:
+ return
+
+ p = Path(value)
+
+ # Absolute path - check if it points to an existing file
+ if p.is_absolute():
+ if p.is_file():
+ self._add_file(p)
+ return
+
+ # Relative path with a known file extension - likely a component
+ # validator that forgot to resolve to absolute via cv.file_() or
+ # CORE.relative_config_path(). Warn and try to resolve.
+ if p.suffix.lower() in _KNOWN_FILE_EXTENSIONS:
+ _LOGGER.warning(
+ "Bundle: non-absolute path in validated config: %s "
+ "(component validator should return absolute paths)",
+ value,
+ )
+ resolved = CORE.relative_config_path(p)
+ if resolved.is_file():
+ self._add_file(resolved)
+
+ def _build_filtered_secrets(self, used_keys: set[str]) -> dict[str, bytes]:
+ """Build filtered secrets files containing only the referenced keys.
+
+ Returns a dict mapping relative archive path to YAML bytes.
+ """
+ if not used_keys or not self._secrets_paths:
+ return {}
+
+ result: dict[str, bytes] = {}
+ for secrets_path in self._secrets_paths:
+ rel_path = self._relative_to_config_dir(secrets_path)
+ if rel_path is None:
+ continue
+ try:
+ all_secrets = yaml_util.load_yaml(secrets_path, clear_secrets=False)
+ except EsphomeError:
+ _LOGGER.warning("Bundle: failed to load secrets file %s", secrets_path)
+ continue
+ if not isinstance(all_secrets, dict):
+ continue
+ filtered = {k: v for k, v in all_secrets.items() if k in used_keys}
+ if filtered:
+ data = yaml_util.dump(filtered, show_secrets=True).encode("utf-8")
+ result[rel_path] = data
+ return result
+
+ def _build_manifest(
+ self, files: list[BundleFile], *, has_secrets: bool
+ ) -> dict[str, Any]:
+ """Build the manifest.json content."""
+ return {
+ ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
+ ManifestKey.ESPHOME_VERSION: const.__version__,
+ ManifestKey.CONFIG_FILENAME: self._config_path.name,
+ ManifestKey.CONFIG_DIR: str(self._config_dir),
+ ManifestKey.FILES: [f.path for f in files],
+ ManifestKey.HAS_SECRETS: has_secrets,
+ }
+
+ @staticmethod
+ def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None:
+ """Add a BundleFile to the tar archive with deterministic metadata."""
+ with bf.source.open("rb") as f:
+ _add_bytes_to_tar(tar, bf.path, f.read())
+
+
+def extract_bundle(
+ bundle_path: Path,
+ target_dir: Path | None = None,
+) -> Path:
+ """Extract a bundle archive and return the path to the config YAML.
+
+ Sanity checks reject path traversal, symlinks, absolute paths, and
+ oversized archives to prevent accidental file overwrites or extraction
+ outside the target directory. These are **not** a security boundary —
+ bundles are assumed to come from the user's own machine or a trusted
+ build pipeline.
+
+ Args:
+ bundle_path: Path to the .tar.gz bundle file.
+ target_dir: Directory to extract into. If None, extracts next to
+ the bundle file in a directory named after it.
+
+ Returns:
+ Absolute path to the extracted config YAML file.
+
+ Raises:
+ EsphomeError: If the bundle is invalid or extraction fails.
+ """
+
+ bundle_path = bundle_path.resolve()
+ if not bundle_path.is_file():
+ raise EsphomeError(f"Bundle file not found: {bundle_path}")
+
+ if target_dir is None:
+ target_dir = _default_target_dir(bundle_path)
+
+ target_dir = target_dir.resolve()
+ target_dir.mkdir(parents=True, exist_ok=True)
+
+ # Read and validate the archive
+ try:
+ with tarfile.open(bundle_path, "r:gz") as tar:
+ manifest = _read_manifest_from_tar(tar)
+ _validate_tar_members(tar, target_dir)
+ tar.extractall(path=target_dir, filter="data")
+ except tarfile.TarError as err:
+ raise EsphomeError(f"Failed to extract bundle: {err}") from err
+
+ config_filename = manifest[ManifestKey.CONFIG_FILENAME]
+ config_path = target_dir / config_filename
+ if not config_path.is_file():
+ raise EsphomeError(
+ f"Bundle manifest references config '{config_filename}' "
+ f"but it was not found in the archive"
+ )
+
+ return config_path
+
+
+def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
+ """Read and validate the manifest from a bundle without full extraction.
+
+ Args:
+ bundle_path: Path to the .tar.gz bundle file.
+
+ Returns:
+ Parsed BundleManifest.
+
+ Raises:
+ EsphomeError: If the manifest is missing, invalid, or version unsupported.
+ """
+
+ try:
+ with tarfile.open(bundle_path, "r:gz") as tar:
+ manifest = _read_manifest_from_tar(tar)
+ except tarfile.TarError as err:
+ raise EsphomeError(f"Failed to read bundle: {err}") from err
+
+ config_dir = manifest.get(ManifestKey.CONFIG_DIR)
+ return BundleManifest(
+ manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
+ esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
+ config_filename=manifest[ManifestKey.CONFIG_FILENAME],
+ files=manifest.get(ManifestKey.FILES, []),
+ has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
+ config_dir=config_dir if isinstance(config_dir, str) else None,
+ )
+
+
+def _read_manifest_from_tar(tar: tarfile.TarFile) -> dict[str, Any]:
+ """Read and validate manifest.json from an open tar archive."""
+
+ try:
+ member = tar.getmember(MANIFEST_FILENAME)
+ except KeyError:
+ raise EsphomeError("Invalid bundle: missing manifest.json") from None
+
+ f = tar.extractfile(member)
+ if f is None:
+ raise EsphomeError("Invalid bundle: manifest.json is not a regular file")
+
+ if member.size > MAX_MANIFEST_SIZE:
+ raise EsphomeError(
+ f"Invalid bundle: manifest.json too large "
+ f"({member.size} bytes, max {MAX_MANIFEST_SIZE})"
+ )
+
+ try:
+ manifest = json.loads(f.read())
+ except (json.JSONDecodeError, UnicodeDecodeError) as err:
+ raise EsphomeError(f"Invalid bundle: malformed manifest.json: {err}") from err
+
+ # Version check
+ version = manifest.get(ManifestKey.MANIFEST_VERSION)
+ if version is None:
+ raise EsphomeError("Invalid bundle: manifest.json missing 'manifest_version'")
+ if not isinstance(version, int) or version < 1:
+ raise EsphomeError(
+ f"Invalid bundle: manifest_version must be a positive integer, got {version!r}"
+ )
+ if version > CURRENT_MANIFEST_VERSION:
+ raise EsphomeError(
+ f"Bundle manifest version {version} is newer than this ESPHome "
+ f"version supports (max {CURRENT_MANIFEST_VERSION}). "
+ f"Please upgrade ESPHome to compile this bundle."
+ )
+
+ # Required fields
+ if ManifestKey.CONFIG_FILENAME not in manifest:
+ raise EsphomeError("Invalid bundle: manifest.json missing 'config_filename'")
+
+ return manifest
+
+
+def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None:
+ """Sanity-check tar members to prevent mistakes and accidental overwrites.
+
+ This is not a security boundary — bundles are created locally or come
+ from a trusted build pipeline. The checks catch malformed archives
+ and common mistakes (stray absolute paths, ``..`` components) that
+ could silently overwrite unrelated files.
+ """
+
+ total_size = 0
+ for member in tar.getmembers():
+ # Reject absolute paths (Unix and Windows)
+ if member.name.startswith(("/", "\\")):
+ raise EsphomeError(
+ f"Invalid bundle: absolute path in archive: {member.name}"
+ )
+
+ # Reject path traversal (split on both / and \ for cross-platform)
+ parts = re.split(r"[/\\]", member.name)
+ if ".." in parts:
+ raise EsphomeError(
+ f"Invalid bundle: path traversal in archive: {member.name}"
+ )
+
+ # Reject symlinks
+ if member.issym() or member.islnk():
+ raise EsphomeError(f"Invalid bundle: symlink in archive: {member.name}")
+
+ # Ensure extraction stays within target_dir
+ target_path = (target_dir / member.name).resolve()
+ if not target_path.is_relative_to(target_dir):
+ raise EsphomeError(
+ f"Invalid bundle: file would extract outside target: {member.name}"
+ )
+
+ # Track total decompressed size
+ total_size += member.size
+ if total_size > MAX_DECOMPRESSED_SIZE:
+ raise EsphomeError(
+ f"Invalid bundle: decompressed size exceeds "
+ f"{MAX_DECOMPRESSED_SIZE // (1024 * 1024)}MB limit"
+ )
+
+
+def is_bundle_path(path: Path) -> bool:
+ """Check if a path looks like a bundle file."""
+ return path.name.lower().endswith(BUNDLE_EXTENSION)
+
+
+def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
+ """Add in-memory bytes to a tar archive with deterministic metadata."""
+ info = tarfile.TarInfo(name=name)
+ info.size = len(data)
+ info.mtime = 0
+ info.uid = 0
+ info.gid = 0
+ info.mode = 0o644
+ tar.addfile(info, io.BytesIO(data))
+
+
+def _resolve_include_path(include_path: Any) -> Path | None:
+ """Resolve an include path to absolute, skipping system includes."""
+ if isinstance(include_path, str) and include_path.startswith("<"):
+ return None # System include, not a local file
+ p = Path(include_path)
+ if not p.is_absolute():
+ p = CORE.relative_config_path(p)
+ return p
+
+
+def _default_target_dir(bundle_path: Path) -> Path:
+ """Compute the default extraction directory for a bundle."""
+ name = bundle_path.name
+ if name.lower().endswith(BUNDLE_EXTENSION):
+ name = name[: -len(BUNDLE_EXTENSION)]
+ return bundle_path.parent / name
+
+
+def _restore_preserved_dirs(preserved: dict[str, Path], target_dir: Path) -> None:
+ """Move preserved build cache directories back into target_dir.
+
+ If the bundle contained entries under a preserved directory name,
+ the extracted copy is removed so the original cache always wins.
+ """
+ for dirname, src in preserved.items():
+ dst = target_dir / dirname
+ if dst.exists():
+ shutil.rmtree(dst)
+ shutil.move(str(src), str(dst))
+
+
+def prepare_bundle_for_compile(
+ bundle_path: Path,
+ target_dir: Path | None = None,
+) -> Path:
+ """Extract a bundle for compilation, preserving build caches.
+
+ Unlike extract_bundle(), this preserves .esphome/ and .pioenvs/
+ directories in the target if they already exist (for incremental builds).
+
+ Args:
+ bundle_path: Path to the .tar.gz bundle file.
+ target_dir: Directory to extract into. Must be specified for
+ build server use.
+
+ Returns:
+ Absolute path to the extracted config YAML file.
+ """
+
+ bundle_path = bundle_path.resolve()
+ if not bundle_path.is_file():
+ raise EsphomeError(f"Bundle file not found: {bundle_path}")
+
+ if target_dir is None:
+ target_dir = _default_target_dir(bundle_path)
+
+ target_dir = target_dir.resolve()
+ target_dir.mkdir(parents=True, exist_ok=True)
+
+ preserved: dict[str, Path] = {}
+
+ # Temporarily move preserved dirs out of the way
+ staging = target_dir / _BUNDLE_STAGING_DIR
+ for dirname in _PRESERVE_DIRS:
+ src = target_dir / dirname
+ if src.is_dir():
+ dst = staging / dirname
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ shutil.move(str(src), str(dst))
+ preserved[dirname] = dst
+
+ try:
+ # Clean non-preserved content and extract fresh
+ for item in target_dir.iterdir():
+ if item.name == _BUNDLE_STAGING_DIR:
+ continue
+ if item.is_dir():
+ shutil.rmtree(item)
+ else:
+ item.unlink()
+
+ config_path = extract_bundle(bundle_path, target_dir)
+ finally:
+ # Restore preserved dirs (idempotent) and clean staging
+ _restore_preserved_dirs(preserved, target_dir)
+ if staging.is_dir():
+ shutil.rmtree(staging)
+
+ return config_path
diff --git a/esphome/codegen.py b/esphome/codegen.py
index 30e3135360..0694eb4d84 100644
--- a/esphome/codegen.py
+++ b/esphome/codegen.py
@@ -25,6 +25,7 @@ from esphome.cpp_generator import ( # noqa: F401
add,
add_build_flag,
add_build_unflag,
+ add_cxx_build_flag,
add_define,
add_global,
add_library,
@@ -52,6 +53,7 @@ from esphome.cpp_helpers import ( # noqa: F401
past_safe_mode,
register_component,
register_parented,
+ set_setup_priority,
)
from esphome.cpp_types import ( # noqa: F401
NAN,
@@ -79,6 +81,7 @@ from esphome.cpp_types import ( # noqa: F401
float_,
global_ns,
gpio_Flags,
+ int8,
int16,
int32,
int64,
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.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp
index 40676f8655..3137a59fad 100644
--- a/esphome/components/absolute_humidity/absolute_humidity.cpp
+++ b/esphome/components/absolute_humidity/absolute_humidity.cpp
@@ -1,22 +1,29 @@
#include "esphome/core/log.h"
#include "absolute_humidity.h"
-namespace esphome {
-namespace absolute_humidity {
+namespace esphome::absolute_humidity {
-static const char *const TAG = "absolute_humidity.sensor";
+static const char *const TAG{"absolute_humidity.sensor"};
void AbsoluteHumidityComponent::setup() {
+ this->temperature_sensor_->add_on_state_callback([this](float state) {
+ this->temperature_ = state;
+ this->enable_loop();
+ });
ESP_LOGD(TAG, " Added callback for temperature '%s'", this->temperature_sensor_->get_name().c_str());
- this->temperature_sensor_->add_on_state_callback([this](float state) { this->temperature_callback_(state); });
+ // Get initial value
if (this->temperature_sensor_->has_state()) {
- this->temperature_callback_(this->temperature_sensor_->get_state());
+ this->temperature_ = this->temperature_sensor_->get_state();
}
+ this->humidity_sensor_->add_on_state_callback([this](float state) {
+ this->humidity_ = state;
+ this->enable_loop();
+ });
ESP_LOGD(TAG, " Added callback for relative humidity '%s'", this->humidity_sensor_->get_name().c_str());
- this->humidity_sensor_->add_on_state_callback([this](float state) { this->humidity_callback_(state); });
+ // Get initial value
if (this->humidity_sensor_->has_state()) {
- this->humidity_callback_(this->humidity_sensor_->get_state());
+ this->humidity_ = this->humidity_sensor_->get_state();
}
}
@@ -46,14 +53,12 @@ void AbsoluteHumidityComponent::dump_config() {
}
void AbsoluteHumidityComponent::loop() {
- if (!this->next_update_) {
- return;
- }
- this->next_update_ = false;
+ // Only run once
+ this->disable_loop();
// Ensure we have source data
- const bool no_temperature = std::isnan(this->temperature_);
- const bool no_humidity = std::isnan(this->humidity_);
+ const bool no_temperature{std::isnan(this->temperature_)};
+ const bool no_humidity{std::isnan(this->humidity_)};
if (no_temperature || no_humidity) {
if (no_temperature) {
ESP_LOGW(TAG, "No valid state from temperature sensor!");
@@ -67,9 +72,9 @@ void AbsoluteHumidityComponent::loop() {
}
// Convert to desired units
- const float temperature_c = this->temperature_;
- const float temperature_k = temperature_c + 273.15;
- const float hr = this->humidity_ / 100;
+ const float temperature_c{this->temperature_};
+ const float temperature_k{temperature_c + 273.15f};
+ const float hr{this->humidity_ / 100.0f};
// Calculate saturation vapor pressure
float es;
@@ -90,7 +95,7 @@ void AbsoluteHumidityComponent::loop() {
}
// Calculate absolute humidity
- const float absolute_humidity = vapor_density(es, hr, temperature_k);
+ const float absolute_humidity{vapor_density(es, hr, temperature_k)};
ESP_LOGD(TAG, "Saturation vapor pressure %f kPa, absolute humidity %f g/m³", es, absolute_humidity);
@@ -103,16 +108,16 @@ void AbsoluteHumidityComponent::loop() {
// More accurate than Tetens in normal meteorologic conditions
float AbsoluteHumidityComponent::es_buck(float temperature_c) {
float a, b, c, d;
- if (temperature_c >= 0) {
- a = 0.61121;
- b = 18.678;
- c = 234.5;
- d = 257.14;
+ if (temperature_c >= 0.0f) {
+ a = 0.61121f;
+ b = 18.678f;
+ c = 234.5f;
+ d = 257.14f;
} else {
- a = 0.61115;
- b = 18.678;
- c = 233.7;
- d = 279.82;
+ a = 0.61115f;
+ b = 18.678f;
+ c = 233.7f;
+ d = 279.82f;
}
return a * expf((b - (temperature_c / c)) * (temperature_c / (d + temperature_c)));
}
@@ -120,14 +125,14 @@ float AbsoluteHumidityComponent::es_buck(float temperature_c) {
// Tetens equation (https://en.wikipedia.org/wiki/Tetens_equation)
float AbsoluteHumidityComponent::es_tetens(float temperature_c) {
float a, b;
- if (temperature_c >= 0) {
- a = 17.27;
- b = 237.3;
+ if (temperature_c >= 0.0f) {
+ a = 17.27f;
+ b = 237.3f;
} else {
- a = 21.875;
- b = 265.5;
+ a = 21.875f;
+ b = 265.5f;
}
- return 0.61078 * expf((a * temperature_c) / (temperature_c + b));
+ return 0.61078f * expf((a * temperature_c) / (temperature_c + b));
}
// Wobus equation
@@ -146,18 +151,18 @@ float AbsoluteHumidityComponent::es_wobus(float t) {
//
// Baker, Schlatter 17-MAY-1982 Original version.
- const float c0 = +0.99999683e00;
- const float c1 = -0.90826951e-02;
- const float c2 = +0.78736169e-04;
- const float c3 = -0.61117958e-06;
- const float c4 = +0.43884187e-08;
- const float c5 = -0.29883885e-10;
- const float c6 = +0.21874425e-12;
- const float c7 = -0.17892321e-14;
- const float c8 = +0.11112018e-16;
- const float c9 = -0.30994571e-19;
- const float p = c0 + t * (c1 + t * (c2 + t * (c3 + t * (c4 + t * (c5 + t * (c6 + t * (c7 + t * (c8 + t * (c9)))))))));
- return 0.61078 / pow(p, 8);
+ constexpr float c0{+0.99999683e+00f};
+ constexpr float c1{-0.90826951e-02f};
+ constexpr float c2{+0.78736169e-04f};
+ constexpr float c3{-0.61117958e-06f};
+ constexpr float c4{+0.43884187e-08f};
+ constexpr float c5{-0.29883885e-10f};
+ constexpr float c6{+0.21874425e-12f};
+ constexpr float c7{-0.17892321e-14f};
+ constexpr float c8{+0.11112018e-16f};
+ constexpr float c9{-0.30994571e-19f};
+ const float p{c0 + t * (c1 + t * (c2 + t * (c3 + t * (c4 + t * (c5 + t * (c6 + t * (c7 + t * (c8 + t * (c9)))))))))};
+ return 0.61078f / powf(p, 8.0f);
}
// From https://www.environmentalbiophysics.org/chalk-talk-how-to-calculate-absolute-humidity/
@@ -168,11 +173,10 @@ float AbsoluteHumidityComponent::vapor_density(float es, float hr, float ta) {
// hr = relative humidity [0-1]
// ta = absolute temperature (K)
- const float ea = hr * es * 1000; // vapor pressure of the air (Pa)
- const float mw = 18.01528; // molar mass of water (g⋅mol⁻¹)
- const float r = 8.31446261815324; // molar gas constant (J⋅K⁻¹)
+ const float ea{hr * es * 1000.0f}; // vapor pressure of the air (Pa)
+ const float mw{18.01528f}; // molar mass of water (g⋅mol⁻¹)
+ const float r{8.31446261815324f}; // molar gas constant (J⋅K⁻¹)
return (ea * mw) / (r * ta);
}
-} // namespace absolute_humidity
-} // namespace esphome
+} // namespace esphome::absolute_humidity
diff --git a/esphome/components/absolute_humidity/absolute_humidity.h b/esphome/components/absolute_humidity/absolute_humidity.h
index 71feee2c42..9989bb17fc 100644
--- a/esphome/components/absolute_humidity/absolute_humidity.h
+++ b/esphome/components/absolute_humidity/absolute_humidity.h
@@ -3,8 +3,7 @@
#include "esphome/core/component.h"
#include "esphome/components/sensor/sensor.h"
-namespace esphome {
-namespace absolute_humidity {
+namespace esphome::absolute_humidity {
/// Enum listing all implemented saturation vapor pressure equations.
enum SaturationVaporPressureEquation {
@@ -14,10 +13,8 @@ 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:
- AbsoluteHumidityComponent() = default;
-
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; }
void set_equation(SaturationVaporPressureEquation equation) { this->equation_ = equation; }
@@ -27,15 +24,6 @@ class AbsoluteHumidityComponent : public sensor::Sensor, public Component {
void loop() override;
protected:
- void temperature_callback_(float state) {
- this->next_update_ = true;
- this->temperature_ = state;
- }
- void humidity_callback_(float state) {
- this->next_update_ = true;
- this->humidity_ = state;
- }
-
/** Buck equation for saturation vapor pressure in kPa.
*
* @param temperature_c Air temperature in °C.
@@ -57,19 +45,15 @@ class AbsoluteHumidityComponent : public sensor::Sensor, public Component {
* @param es Saturation vapor pressure in kPa.
* @param hr Relative humidity 0 to 1.
* @param ta Absolute temperature in K.
- * @param heater_duration The duration in ms that the heater should turn on for when measuring.
*/
static float vapor_density(float es, float hr, float ta);
sensor::Sensor *temperature_sensor_{nullptr};
sensor::Sensor *humidity_sensor_{nullptr};
- bool next_update_{false};
-
float temperature_{NAN};
float humidity_{NAN};
SaturationVaporPressureEquation equation_;
};
-} // namespace absolute_humidity
-} // namespace esphome
+} // namespace esphome::absolute_humidity
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 91cf4eaafc..7131898747 100644
--- a/esphome/components/adc/adc_sensor.h
+++ b/esphome/components/adc/adc_sensor.h
@@ -17,12 +17,12 @@
#include
#endif
-namespace esphome {
-namespace adc {
+namespace esphome::adc {
#ifdef USE_ESP32
// clang-format off
-#if (ESP_IDF_VERSION_MAJOR == 5 && \
+#if ESP_IDF_VERSION_MAJOR >= 6 || \
+ (ESP_IDF_VERSION_MAJOR == 5 && \
((ESP_IDF_VERSION_MINOR == 0 && ESP_IDF_VERSION_PATCH >= 5) || \
(ESP_IDF_VERSION_MINOR == 1 && ESP_IDF_VERSION_PATCH >= 3) || \
(ESP_IDF_VERSION_MINOR >= 2)) \
@@ -54,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.
@@ -123,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};
@@ -152,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 1d3138623e..a761b37749 100644
--- a/esphome/components/adc/adc_sensor_esp32.cpp
+++ b/esphome/components/adc/adc_sensor_esp32.cpp
@@ -2,9 +2,9 @@
#include "adc_sensor.h"
#include "esphome/core/log.h"
+#include
-namespace esphome {
-namespace adc {
+namespace esphome::adc {
static const char *const TAG = "adc.esp32";
@@ -346,7 +346,8 @@ float ADCSensor::sample_autorange_() {
ESP_LOGVV(TAG, "Autorange summary:");
ESP_LOGVV(TAG, " Raw readings: 12db=%d, 6db=%d, 2.5db=%d, 0db=%d", raw12, raw6, raw2, raw0);
ESP_LOGVV(TAG, " Voltages: 12db=%.6f, 6db=%.6f, 2.5db=%.6f, 0db=%.6f", mv12, mv6, mv2, mv0);
- ESP_LOGVV(TAG, " Coefficients: c12=%u, c6=%u, c2=%u, c0=%u, sum=%u", c12, c6, c2, c0, csum);
+ ESP_LOGVV(TAG, " Coefficients: c12=%" PRIu32 ", c6=%" PRIu32 ", c2=%" PRIu32 ", c0=%" PRIu32 ", sum=%" PRIu32, c12,
+ c6, c2, c0, csum);
if (csum == 0) {
ESP_LOGE(TAG, "Invalid weight sum in autorange calculation");
@@ -354,13 +355,14 @@ float ADCSensor::sample_autorange_() {
}
const float final_result = (mv12 * c12 + mv6 * c6 + mv2 * c2 + mv0 * c0) / csum;
- ESP_LOGV(TAG, "Autorange final: (%.6f*%u + %.6f*%u + %.6f*%u + %.6f*%u)/%u = %.6fV", mv12, c12, mv6, c6, mv2, c2, mv0,
- c0, csum, final_result);
+ ESP_LOGV(TAG,
+ "Autorange final: (%.6f*%" PRIu32 " + %.6f*%" PRIu32 " + %.6f*%" PRIu32 " + %.6f*%" PRIu32 ")/%" PRIu32
+ " = %.6fV",
+ mv12, c12, mv6, c6, mv2, c2, mv0, c0, csum, final_result);
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 bab2762f00..86e2b771ab 100644
--- a/esphome/components/adc/sensor.py
+++ b/esphome/components/adc/sensor.py
@@ -2,7 +2,11 @@ import logging
import esphome.codegen as cg
from esphome.components import sensor, voltage_sampler
-from esphome.components.esp32 import get_esp32_variant, include_builtin_idf_component
+from esphome.components.esp32 import (
+ get_esp32_variant,
+ include_builtin_idf_component,
+ require_adc_oneshot_iram,
+)
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
from esphome.components.zephyr import (
zephyr_add_overlay,
@@ -24,6 +28,7 @@ from esphome.const import (
PlatformFramework,
)
from esphome.core import CORE
+from esphome.types import ConfigType
from . import (
ATTENUATION_MODES,
@@ -65,6 +70,13 @@ def validate_config(config):
return config
+def _require_adc_iram(config: ConfigType) -> ConfigType:
+ """Register ADC oneshot IRAM requirement during config validation."""
+ if CORE.is_esp32:
+ require_adc_oneshot_iram()
+ return config
+
+
ADCSensor = adc_ns.class_(
"ADCSensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler
)
@@ -95,6 +107,7 @@ CONFIG_SCHEMA = cv.All(
)
.extend(cv.polling_component_schema("60s")),
validate_config,
+ _require_adc_iram,
)
CONF_ADC_CHANNEL_ID = "adc_channel_id"
@@ -188,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 821e4a3105..1adf44f8f7 100644
--- a/esphome/components/ade7953_base/ade7953_base.cpp
+++ b/esphome/components/ade7953_base/ade7953_base.cpp
@@ -3,11 +3,13 @@
#include
-namespace esphome {
-namespace ade7953_base {
+namespace esphome::ade7953_base {
static const char *const TAG = "ade7953";
+constexpr uint16_t CONFIG_DEFAULT = 0x8004u;
+constexpr uint16_t CONFIG_LOCK_BIT = 0x8000u;
+
static const float ADE_POWER_FACTOR = 154.0f;
static const float ADE_WATTSEC_POWER_FACTOR = ADE_POWER_FACTOR * ADE_POWER_FACTOR / 3600;
@@ -18,7 +20,12 @@ void ADE7953::setup() {
// The chip might take up to 100ms to initialise
this->set_timeout(100, [this]() {
- // this->ade_write_8(0x0010, 0x04);
+ // Lock communication interface (SPI or I2C)
+ uint16_t config_v = CONFIG_DEFAULT;
+ this->ade_read_16(CONFIG_16, &config_v);
+ config_v &= static_cast(~CONFIG_LOCK_BIT); // Clear the lock bit
+ this->ade_write_16(CONFIG_16, config_v);
+ // Configure optimum settings according to datasheet
this->ade_write_8(0x00FE, 0xAD);
this->ade_write_16(0x0120, 0x0030);
// Set gains
@@ -152,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 bcafddca4e..a1dfea23b0 100644
--- a/esphome/components/ade7953_base/ade7953_base.h
+++ b/esphome/components/ade7953_base/ade7953_base.h
@@ -6,34 +6,37 @@
#include
-namespace esphome {
-namespace ade7953_base {
+namespace esphome::ade7953_base {
-static const uint8_t PGA_V_8 =
+static constexpr uint8_t PGA_V_8 =
0x007; // PGA_V, (R/W) Default: 0x00, Unsigned, Voltage channel gain configuration (Bits[2:0])
-static const uint8_t PGA_IA_8 =
+static constexpr uint8_t PGA_IA_8 =
0x008; // PGA_IA, (R/W) Default: 0x00, Unsigned, Current Channel A gain configuration (Bits[2:0])
-static const uint8_t PGA_IB_8 =
+static constexpr uint8_t PGA_IB_8 =
0x009; // PGA_IB, (R/W) Default: 0x00, Unsigned, Current Channel B gain configuration (Bits[2:0])
-static const uint32_t AIGAIN_32 =
+static constexpr uint16_t CONFIG_16 = 0x102; // CONFIG, (R/W) Default: 0x8004, Unsigned, Configuration register
+
+static constexpr uint16_t AIGAIN_32 =
0x380; // AIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel A)(32 bit)
-static const uint32_t AVGAIN_32 = 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
-static const uint32_t AWGAIN_32 =
+static constexpr uint16_t AVGAIN_32 =
+ 0x381; // AVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
+static constexpr uint16_t AWGAIN_32 =
0x382; // AWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel A)(32 bit)
-static const uint32_t AVARGAIN_32 =
+static constexpr uint16_t AVARGAIN_32 =
0x383; // AVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel A)(32 bit)
-static const uint32_t AVAGAIN_32 =
+static constexpr uint16_t AVAGAIN_32 =
0x384; // AVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel A)(32 bit)
-static const uint32_t BIGAIN_32 =
+static constexpr uint16_t BIGAIN_32 =
0x38C; // BIGAIN, (R/W) Default: 0x400000, Unsigned,Current channel gain (Current Channel B)(32 bit)
-static const uint32_t BVGAIN_32 = 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
-static const uint32_t BWGAIN_32 =
+static constexpr uint16_t BVGAIN_32 =
+ 0x38D; // BVGAIN, (R/W) Default: 0x400000, Unsigned,Voltage channel gain(32 bit)
+static constexpr uint16_t BWGAIN_32 =
0x38E; // BWGAIN, (R/W) Default: 0x400000, Unsigned,Active power gain (Current Channel B)(32 bit)
-static const uint32_t BVARGAIN_32 =
+static constexpr uint16_t BVARGAIN_32 =
0x38F; // BVARGAIN, (R/W) Default: 0x400000, Unsigned, Reactive power gain (Current Channel B)(32 bit)
-static const uint32_t BVAGAIN_32 =
+static constexpr uint16_t BVAGAIN_32 =
0x390; // BVAGAIN, (R/W) Default: 0x400000, Unsigned,Apparent power gain (Current Channel B)(32 bit)
class ADE7953 : public PollingComponent, public sensor::Sensor {
@@ -127,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 6b16d933a2..c2d85231d6 100644
--- a/esphome/components/ade7953_spi/ade7953_spi.cpp
+++ b/esphome/components/ade7953_spi/ade7953_spi.cpp
@@ -2,11 +2,13 @@
#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";
+// Datasheet requires at least 1.2µs after clearing CONFIG LOCK_BIT before raising CS
+constexpr uint8_t CONFIG_LOCK_SETTLE_US = 2;
+
void AdE7953Spi::setup() {
this->spi_setup();
ade7953_base::ADE7953::setup();
@@ -32,6 +34,9 @@ bool AdE7953Spi::ade_write_16(uint16_t reg, uint16_t value) {
this->write_byte16(reg);
this->transfer_byte(0);
this->write_byte16(value);
+ if (reg == ade7953_base::CONFIG_16) {
+ delayMicroseconds(CONFIG_LOCK_SETTLE_US);
+ }
this->disable();
return false;
}
@@ -77,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 d96852b9bb..657397db4e 100644
--- a/esphome/components/ade7953_spi/ade7953_spi.h
+++ b/esphome/components/ade7953_spi/ade7953_spi.h
@@ -8,11 +8,10 @@
#include
-namespace esphome {
-namespace ade7953_spi {
+namespace esphome::ade7953_spi {
class AdE7953Spi : public ade7953_base::ADE7953,
- public spi::SPIDevice {
public:
void setup() override;
@@ -28,5 +27,4 @@ class AdE7953Spi : public ade7953_base::ADE7953,
bool ade_read_32(uint16_t reg, uint32_t *value) override;
};
-} // namespace ade7953_spi
-} // namespace esphome
+} // namespace esphome::ade7953_spi
diff --git a/esphome/components/ads1115/ads1115.cpp b/esphome/components/ads1115/ads1115.cpp
index d493a6a6d3..58704bf7c6 100644
--- a/esphome/components/ads1115/ads1115.cpp
+++ b/esphome/components/ads1115/ads1115.cpp
@@ -2,8 +2,7 @@
#include "esphome/core/hal.h"
#include "esphome/core/log.h"
-namespace esphome {
-namespace ads1115 {
+namespace esphome::ads1115 {
static const char *const TAG = "ads1115";
static const uint8_t ADS1115_REGISTER_CONVERSION = 0x00;
@@ -208,5 +207,4 @@ float ADS1115Component::request_measurement(ADS1115Multiplexer multiplexer, ADS1
return millivolts / 1e3f;
}
-} // namespace ads1115
-} // namespace esphome
+} // namespace esphome::ads1115
diff --git a/esphome/components/ads1115/ads1115.h b/esphome/components/ads1115/ads1115.h
index e827a739d2..0b7f7ae700 100644
--- a/esphome/components/ads1115/ads1115.h
+++ b/esphome/components/ads1115/ads1115.h
@@ -5,8 +5,7 @@
#include
-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/__init__.py b/esphome/components/ads1118/__init__.py
index 128e0d0701..45d47a329e 100644
--- a/esphome/components/ads1118/__init__.py
+++ b/esphome/components/ads1118/__init__.py
@@ -12,11 +12,15 @@ CONF_ADS1118_ID = "ads1118_id"
ads1118_ns = cg.esphome_ns.namespace("ads1118")
ADS1118 = ads1118_ns.class_("ADS1118", cg.Component, spi.SPIDevice)
-CONFIG_SCHEMA = cv.Schema(
- {
- cv.GenerateID(): cv.declare_id(ADS1118),
- }
-).extend(spi.spi_device_schema(cs_pin_required=True))
+CONFIG_SCHEMA = (
+ cv.Schema(
+ {
+ cv.GenerateID(): cv.declare_id(ADS1118),
+ }
+ )
+ .extend(spi.spi_device_schema(cs_pin_required=True))
+ .extend(cv.COMPONENT_SCHEMA)
+)
async def to_code(config):
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/ags10/sensor.py b/esphome/components/ags10/sensor.py
index 4cfa9e67ec..6491d7d810 100644
--- a/esphome/components/ags10/sensor.py
+++ b/esphome/components/ags10/sensor.py
@@ -35,7 +35,7 @@ CONFIG_SCHEMA = (
cv.Schema(
{
cv.GenerateID(): cv.declare_id(AGS10Component),
- cv.Optional(CONF_TVOC): sensor.sensor_schema(
+ cv.Required(CONF_TVOC): sensor.sensor_schema(
unit_of_measurement=UNIT_PARTS_PER_BILLION,
icon=ICON_RADIATOR,
accuracy_decimals=0,
@@ -97,7 +97,7 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
async def ags10newi2caddress_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
- address = await cg.templatable(config[CONF_ADDRESS], args, int)
+ address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8)
cg.add(var.set_new_address(address))
return var
@@ -112,7 +112,9 @@ AGS10_SET_ZERO_POINT_ACTION_MODE = {
AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.use_id(AGS10Component),
- cv.Required(CONF_MODE): cv.enum(AGS10_SET_ZERO_POINT_ACTION_MODE, upper=True),
+ cv.Required(CONF_MODE): cv.templatable(
+ cv.enum(AGS10_SET_ZERO_POINT_ACTION_MODE, upper=True)
+ ),
cv.Optional(CONF_VALUE, default=0xFFFF): cv.templatable(cv.uint16_t),
},
)
@@ -127,8 +129,10 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema(
async def ags10setzeropoint_to_code(config, action_id, template_arg, args):
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
- mode = await cg.templatable(config.get(CONF_MODE), args, enumerate)
+ mode = await cg.templatable(
+ config.get(CONF_MODE), args, AGS10SetZeroPointActionMode
+ )
cg.add(var.set_mode(mode))
- value = await cg.templatable(config[CONF_VALUE], args, int)
+ value = await cg.templatable(config[CONF_VALUE], args, cg.uint16)
cg.add(var.set_value(value))
return var
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/audio_dac.py b/esphome/components/aic3204/audio_dac.py
index a644638f69..b478b573a3 100644
--- a/esphome/components/aic3204/audio_dac.py
+++ b/esphome/components/aic3204/audio_dac.py
@@ -43,7 +43,7 @@ async def aic3204_set_volume_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)
- template_ = await cg.templatable(config.get(CONF_MODE), args, int)
+ template_ = await cg.templatable(config.get(CONF_MODE), args, cg.uint8)
cg.add(var.set_auto_mute_mode(template_))
return var
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 aefb18d25c..2f5d4c7c2b 100644
--- a/esphome/components/alarm_control_panel/__init__.py
+++ b/esphome/components/alarm_control_panel/__init__.py
@@ -10,11 +10,14 @@ from esphome.const import (
CONF_ID,
CONF_MQTT_ID,
CONF_ON_STATE,
- CONF_TRIGGER_ID,
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"]
@@ -34,39 +37,9 @@ CONF_ON_READY = "on_ready"
alarm_control_panel_ns = cg.esphome_ns.namespace("alarm_control_panel")
AlarmControlPanel = alarm_control_panel_ns.class_("AlarmControlPanel", cg.EntityBase)
-StateTrigger = alarm_control_panel_ns.class_(
- "StateTrigger", automation.Trigger.template()
-)
-TriggeredTrigger = alarm_control_panel_ns.class_(
- "TriggeredTrigger", automation.Trigger.template()
-)
-ClearedTrigger = alarm_control_panel_ns.class_(
- "ClearedTrigger", automation.Trigger.template()
-)
-ArmingTrigger = alarm_control_panel_ns.class_(
- "ArmingTrigger", automation.Trigger.template()
-)
-PendingTrigger = alarm_control_panel_ns.class_(
- "PendingTrigger", automation.Trigger.template()
-)
-ArmedHomeTrigger = alarm_control_panel_ns.class_(
- "ArmedHomeTrigger", automation.Trigger.template()
-)
-ArmedNightTrigger = alarm_control_panel_ns.class_(
- "ArmedNightTrigger", automation.Trigger.template()
-)
-ArmedAwayTrigger = alarm_control_panel_ns.class_(
- "ArmedAwayTrigger", automation.Trigger.template()
-)
-DisarmedTrigger = alarm_control_panel_ns.class_(
- "DisarmedTrigger", automation.Trigger.template()
-)
-ChimeTrigger = alarm_control_panel_ns.class_(
- "ChimeTrigger", automation.Trigger.template()
-)
-ReadyTrigger = alarm_control_panel_ns.class_(
- "ReadyTrigger", automation.Trigger.template()
-)
+StateAnyForwarder = alarm_control_panel_ns.class_("StateAnyForwarder")
+StateEnterForwarder = alarm_control_panel_ns.class_("StateEnterForwarder")
+AlarmControlPanelState = alarm_control_panel_ns.enum("AlarmControlPanelState")
ArmAwayAction = alarm_control_panel_ns.class_("ArmAwayAction", automation.Action)
ArmHomeAction = alarm_control_panel_ns.class_("ArmHomeAction", automation.Action)
@@ -89,61 +62,17 @@ _ALARM_CONTROL_PANEL_SCHEMA = (
cv.OnlyWith(CONF_MQTT_ID, "mqtt"): cv.declare_id(
mqtt.MQTTAlarmControlPanelComponent
),
- cv.Optional(CONF_ON_STATE): automation.validate_automation(
- {
- cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StateTrigger),
- }
- ),
- cv.Optional(CONF_ON_TRIGGERED): automation.validate_automation(
- {
- cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TriggeredTrigger),
- }
- ),
- cv.Optional(CONF_ON_ARMING): automation.validate_automation(
- {
- cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmingTrigger),
- }
- ),
- cv.Optional(CONF_ON_PENDING): automation.validate_automation(
- {
- cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PendingTrigger),
- }
- ),
- cv.Optional(CONF_ON_ARMED_HOME): automation.validate_automation(
- {
- cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedHomeTrigger),
- }
- ),
- cv.Optional(CONF_ON_ARMED_NIGHT): automation.validate_automation(
- {
- cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedNightTrigger),
- }
- ),
- cv.Optional(CONF_ON_ARMED_AWAY): automation.validate_automation(
- {
- cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ArmedAwayTrigger),
- }
- ),
- cv.Optional(CONF_ON_DISARMED): automation.validate_automation(
- {
- cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(DisarmedTrigger),
- }
- ),
- cv.Optional(CONF_ON_CLEARED): automation.validate_automation(
- {
- cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ClearedTrigger),
- }
- ),
- cv.Optional(CONF_ON_CHIME): automation.validate_automation(
- {
- cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ChimeTrigger),
- }
- ),
- cv.Optional(CONF_ON_READY): automation.validate_automation(
- {
- cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(ReadyTrigger),
- }
- ),
+ cv.Optional(CONF_ON_STATE): automation.validate_automation({}),
+ cv.Optional(CONF_ON_TRIGGERED): automation.validate_automation({}),
+ cv.Optional(CONF_ON_ARMING): automation.validate_automation({}),
+ cv.Optional(CONF_ON_PENDING): automation.validate_automation({}),
+ cv.Optional(CONF_ON_ARMED_HOME): automation.validate_automation({}),
+ cv.Optional(CONF_ON_ARMED_NIGHT): automation.validate_automation({}),
+ cv.Optional(CONF_ON_ARMED_AWAY): automation.validate_automation({}),
+ cv.Optional(CONF_ON_DISARMED): automation.validate_automation({}),
+ cv.Optional(CONF_ON_CLEARED): automation.validate_automation({}),
+ cv.Optional(CONF_ON_CHIME): automation.validate_automation({}),
+ cv.Optional(CONF_ON_READY): automation.validate_automation({}),
}
)
)
@@ -186,41 +115,66 @@ ALARM_CONTROL_PANEL_CONDITION_SCHEMA = maybe_simple_id(
)
+_CALLBACK_AUTOMATIONS = (
+ automation.CallbackAutomation(
+ CONF_ON_STATE, "add_on_state_callback", forwarder=StateAnyForwarder
+ ),
+ automation.CallbackAutomation(
+ CONF_ON_TRIGGERED,
+ "add_on_state_callback",
+ forwarder=StateEnterForwarder.template(
+ AlarmControlPanelState.ACP_STATE_TRIGGERED
+ ),
+ ),
+ automation.CallbackAutomation(
+ CONF_ON_ARMING,
+ "add_on_state_callback",
+ forwarder=StateEnterForwarder.template(AlarmControlPanelState.ACP_STATE_ARMING),
+ ),
+ automation.CallbackAutomation(
+ CONF_ON_PENDING,
+ "add_on_state_callback",
+ forwarder=StateEnterForwarder.template(
+ AlarmControlPanelState.ACP_STATE_PENDING
+ ),
+ ),
+ automation.CallbackAutomation(
+ CONF_ON_ARMED_HOME,
+ "add_on_state_callback",
+ forwarder=StateEnterForwarder.template(
+ AlarmControlPanelState.ACP_STATE_ARMED_HOME
+ ),
+ ),
+ automation.CallbackAutomation(
+ CONF_ON_ARMED_NIGHT,
+ "add_on_state_callback",
+ forwarder=StateEnterForwarder.template(
+ AlarmControlPanelState.ACP_STATE_ARMED_NIGHT
+ ),
+ ),
+ automation.CallbackAutomation(
+ CONF_ON_ARMED_AWAY,
+ "add_on_state_callback",
+ forwarder=StateEnterForwarder.template(
+ AlarmControlPanelState.ACP_STATE_ARMED_AWAY
+ ),
+ ),
+ automation.CallbackAutomation(
+ CONF_ON_DISARMED,
+ "add_on_state_callback",
+ forwarder=StateEnterForwarder.template(
+ AlarmControlPanelState.ACP_STATE_DISARMED
+ ),
+ ),
+ automation.CallbackAutomation(CONF_ON_CLEARED, "add_on_cleared_callback"),
+ automation.CallbackAutomation(CONF_ON_CHIME, "add_on_chime_callback"),
+ automation.CallbackAutomation(CONF_ON_READY, "add_on_ready_callback"),
+)
+
+
@setup_entity("alarm_control_panel")
async def setup_alarm_control_panel_core_(var, config):
- for conf in config.get(CONF_ON_STATE, []):
- trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
- await automation.build_automation(trigger, [], conf)
- for conf in config.get(CONF_ON_TRIGGERED, []):
- trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
- await automation.build_automation(trigger, [], conf)
- for conf in config.get(CONF_ON_ARMING, []):
- trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
- await automation.build_automation(trigger, [], conf)
- for conf in config.get(CONF_ON_PENDING, []):
- trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
- await automation.build_automation(trigger, [], conf)
- for conf in config.get(CONF_ON_ARMED_HOME, []):
- trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
- await automation.build_automation(trigger, [], conf)
- for conf in config.get(CONF_ON_ARMED_NIGHT, []):
- trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
- await automation.build_automation(trigger, [], conf)
- for conf in config.get(CONF_ON_ARMED_AWAY, []):
- trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
- await automation.build_automation(trigger, [], conf)
- for conf in config.get(CONF_ON_DISARMED, []):
- trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
- await automation.build_automation(trigger, [], conf)
- for conf in config.get(CONF_ON_CLEARED, []):
- trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
- await automation.build_automation(trigger, [], conf)
- for conf in config.get(CONF_ON_CHIME, []):
- trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
- await automation.build_automation(trigger, [], conf)
- for conf in config.get(CONF_ON_READY, []):
- trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
- await automation.build_automation(trigger, [], conf)
+ await automation.build_callback_automations(var, config, _CALLBACK_AUTOMATIONS)
if web_server_config := config.get(CONF_WEB_SERVER):
await web_server.add_entity_config(var, web_server_config)
if mqtt_id := config.get(CONF_MQTT_ID):
@@ -231,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/alarm_control_panel.cpp b/esphome/components/alarm_control_panel/alarm_control_panel.cpp
index ab0a780cef..fc72c13ce3 100644
--- a/esphome/components/alarm_control_panel/alarm_control_panel.cpp
+++ b/esphome/components/alarm_control_panel/alarm_control_panel.cpp
@@ -31,12 +31,12 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) {
this->last_update_ = millis();
if (state != this->current_state_) {
auto prev_state = this->current_state_;
- ESP_LOGD(TAG, "'%s' >> %s (was %s)", this->get_name().c_str(),
+ ESP_LOGV(TAG, "'%s' >> %s (was %s)", this->get_name().c_str(),
LOG_STR_ARG(alarm_control_panel_state_to_string(state)),
LOG_STR_ARG(alarm_control_panel_state_to_string(prev_state)));
this->current_state_ = state;
- // Single state callback - triggers check get_state() for specific states
- this->state_callback_.call();
+ // Single state callback - listeners receive the new state as an argument
+ this->state_callback_.call(state);
#if defined(USE_ALARM_CONTROL_PANEL) && defined(USE_CONTROLLER_REGISTRY)
ControllerRegistry::notify_alarm_control_panel_update(this);
#endif
@@ -51,22 +51,6 @@ void AlarmControlPanel::publish_state(AlarmControlPanelState state) {
}
}
-void AlarmControlPanel::add_on_state_callback(std::function &&callback) {
- this->state_callback_.add(std::move(callback));
-}
-
-void AlarmControlPanel::add_on_cleared_callback(std::function &&callback) {
- this->cleared_callback_.add(std::move(callback));
-}
-
-void AlarmControlPanel::add_on_chime_callback(std::function &&callback) {
- this->chime_callback_.add(std::move(callback));
-}
-
-void AlarmControlPanel::add_on_ready_callback(std::function &&callback) {
- this->ready_callback_.add(std::move(callback));
-}
-
void AlarmControlPanel::arm_with_code_(AlarmControlPanelCall &(AlarmControlPanelCall::*arm_method)(),
const char *code) {
auto call = this->make_call();
diff --git a/esphome/components/alarm_control_panel/alarm_control_panel.h b/esphome/components/alarm_control_panel/alarm_control_panel.h
index e8dc197e26..e748b8621b 100644
--- a/esphome/components/alarm_control_panel/alarm_control_panel.h
+++ b/esphome/components/alarm_control_panel/alarm_control_panel.h
@@ -37,25 +37,24 @@ class AlarmControlPanel : public EntityBase {
*
* @param callback The callback function
*/
- void add_on_state_callback(std::function &&callback);
+ template void add_on_state_callback(F &&callback) {
+ this->state_callback_.add(std::forward(callback));
+ }
- /** Add a callback for when the state of the alarm_control_panel clears from triggered
- *
- * @param callback The callback function
- */
- void add_on_cleared_callback(std::function &&callback);
+ /** Add a callback for when the state of the alarm_control_panel clears from triggered. */
+ template void add_on_cleared_callback(F &&callback) {
+ this->cleared_callback_.add(std::forward(callback));
+ }
- /** Add a callback for when a chime zone goes from closed to open
- *
- * @param callback The callback function
- */
- void add_on_chime_callback(std::function &&callback);
+ /** Add a callback for when a chime zone goes from closed to open. */
+ template void add_on_chime_callback(F &&callback) {
+ this->chime_callback_.add(std::forward(callback));
+ }
- /** Add a callback for when a ready state changes
- *
- * @param callback The callback function
- */
- void add_on_ready_callback(std::function &&callback);
+ /** Add a callback for when a ready state changes. */
+ template void add_on_ready_callback(F &&callback) {
+ this->ready_callback_.add(std::forward(callback));
+ }
/** A numeric representation of the supported features as per HomeAssistant
*
@@ -146,8 +145,8 @@ class AlarmControlPanel : public EntityBase {
uint32_t last_update_;
// the call control function
virtual void control(const AlarmControlPanelCall &call) = 0;
- // state callback - triggers check get_state() for specific state
- LazyCallbackManager state_callback_{};
+ // state callback - passes the new state to listeners
+ LazyCallbackManager state_callback_{};
// clear callback - fires when leaving TRIGGERED state
LazyCallbackManager cleared_callback_{};
// chime callback
diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h
index 4ff34de0d5..dcb5121c60 100644
--- a/esphome/components/alarm_control_panel/automation.h
+++ b/esphome/components/alarm_control_panel/automation.h
@@ -5,62 +5,29 @@
namespace esphome::alarm_control_panel {
-/// Trigger on any state change
-class StateTrigger : public Trigger<> {
- public:
- explicit StateTrigger(AlarmControlPanel *alarm_control_panel) {
- alarm_control_panel->add_on_state_callback([this]() { this->trigger(); });
+/// Callback forwarder that triggers an Automation<> on any state change.
+/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_.
+struct StateAnyForwarder {
+ Automation<> *automation;
+ void operator()(AlarmControlPanelState /*state*/) const { this->automation->trigger(); }
+};
+
+/// Callback forwarder that triggers an Automation<> only when the alarm enters a specific state.
+/// Pointer-sized (single Automation* field) to fit inline in Callback::ctx_.
+template struct StateEnterForwarder {
+ Automation<> *automation;
+ void operator()(AlarmControlPanelState state) const {
+ if (state == State)
+ this->automation->trigger();
}
};
-/// Template trigger that fires when entering a specific state
-template class StateEnterTrigger : public Trigger<> {
- public:
- explicit StateEnterTrigger(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {
- alarm_control_panel->add_on_state_callback([this]() {
- if (this->alarm_control_panel_->get_state() == State)
- this->trigger();
- });
- }
+static_assert(sizeof(StateAnyForwarder) <= sizeof(void *));
+static_assert(std::is_trivially_copyable_v);
+static_assert(sizeof(StateEnterForwarder) <= sizeof(void *));
+static_assert(std::is_trivially_copyable_v>);
- protected:
- AlarmControlPanel *alarm_control_panel_;
-};
-
-// Type aliases for state-specific triggers
-using TriggeredTrigger = StateEnterTrigger;
-using ArmingTrigger = StateEnterTrigger;
-using PendingTrigger = StateEnterTrigger;
-using ArmedHomeTrigger = StateEnterTrigger;
-using ArmedNightTrigger = StateEnterTrigger;
-using ArmedAwayTrigger = StateEnterTrigger;
-using DisarmedTrigger = StateEnterTrigger;
-
-/// Trigger when leaving TRIGGERED state (alarm cleared)
-class ClearedTrigger : public Trigger<> {
- public:
- explicit ClearedTrigger(AlarmControlPanel *alarm_control_panel) {
- alarm_control_panel->add_on_cleared_callback([this]() { this->trigger(); });
- }
-};
-
-/// Trigger on chime event (zone opened while disarmed)
-class ChimeTrigger : public Trigger<> {
- public:
- explicit ChimeTrigger(AlarmControlPanel *alarm_control_panel) {
- alarm_control_panel->add_on_chime_callback([this]() { this->trigger(); });
- }
-};
-
-/// Trigger on ready state change
-class ReadyTrigger : public Trigger<> {
- public:
- explicit ReadyTrigger(AlarmControlPanel *alarm_control_panel) {
- alarm_control_panel->add_on_ready_callback([this]() { this->trigger(); });
- }
-};
-
-template class ArmAwayAction : public Action {
+template class ArmAwayAction final : public Action {
public:
explicit ArmAwayAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -72,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) {}
@@ -84,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) {}
@@ -96,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) {}
@@ -108,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) {}
@@ -118,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) {}
@@ -128,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/alpha3/sensor.py b/esphome/components/alpha3/sensor.py
index 361e1d101f..279ab214cf 100644
--- a/esphome/components/alpha3/sensor.py
+++ b/esphome/components/alpha3/sensor.py
@@ -9,6 +9,10 @@ from esphome.const import (
CONF_POWER,
CONF_SPEED,
CONF_VOLTAGE,
+ DEVICE_CLASS_CURRENT,
+ DEVICE_CLASS_POWER,
+ DEVICE_CLASS_VOLTAGE,
+ STATE_CLASS_MEASUREMENT,
UNIT_AMPERE,
UNIT_CUBIC_METER_PER_HOUR,
UNIT_METER,
@@ -27,26 +31,35 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_FLOW): sensor.sensor_schema(
unit_of_measurement=UNIT_CUBIC_METER_PER_HOUR,
accuracy_decimals=2,
+ state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_HEAD): sensor.sensor_schema(
unit_of_measurement=UNIT_METER,
accuracy_decimals=2,
+ state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_POWER): sensor.sensor_schema(
unit_of_measurement=UNIT_WATT,
accuracy_decimals=2,
+ device_class=DEVICE_CLASS_POWER,
+ state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CURRENT): sensor.sensor_schema(
unit_of_measurement=UNIT_AMPERE,
accuracy_decimals=2,
+ device_class=DEVICE_CLASS_CURRENT,
+ state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_SPEED): sensor.sensor_schema(
unit_of_measurement=UNIT_REVOLUTIONS_PER_MINUTE,
accuracy_decimals=2,
+ state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_VOLTAGE): sensor.sensor_schema(
unit_of_measurement=UNIT_VOLT,
accuracy_decimals=2,
+ device_class=DEVICE_CLASS_VOLTAGE,
+ state_class=STATE_CLASS_MEASUREMENT,
),
}
)
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/__init__.py b/esphome/components/am43/sensor/__init__.py
index 4b3e1716a4..2697d364ad 100644
--- a/esphome/components/am43/sensor/__init__.py
+++ b/esphome/components/am43/sensor/__init__.py
@@ -8,6 +8,7 @@ from esphome.const import (
DEVICE_CLASS_BATTERY,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_BRIGHTNESS_5,
+ STATE_CLASS_MEASUREMENT,
UNIT_PERCENT,
)
@@ -26,11 +27,13 @@ CONFIG_SCHEMA = (
device_class=DEVICE_CLASS_BATTERY,
accuracy_decimals=0,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
+ state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_ILLUMINANCE): sensor.sensor_schema(
unit_of_measurement=UNIT_PERCENT,
icon=ICON_BRIGHTNESS_5,
accuracy_decimals=0,
+ state_class=STATE_CLASS_MEASUREMENT,
),
}
)
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 91973d8e33..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;
@@ -35,10 +34,9 @@ class Am43 : public esphome::ble_client::BLEClientNode, public PollingComponent
uint8_t current_sensor_;
// The AM43 often gets into a state where it spams loads of battery update
// notifications. Here we will limit to no more than every 10s.
- uint8_t last_battery_update_;
+ 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 9ea95d8570..a4df00ff05 100644
--- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h
+++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h
@@ -1,13 +1,13 @@
#pragma once
+#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#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;
@@ -18,10 +18,9 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina
protected:
sensor::Sensor *sensor_{nullptr};
- TemplatableValue upper_threshold_{};
- TemplatableValue lower_threshold_{};
+ TemplatableFn upper_threshold_{};
+ TemplatableFn lower_threshold_{};
bool raw_state_{false}; // Pre-filter state for hysteresis logic
};
-} // namespace analog_threshold
-} // namespace esphome
+} // namespace esphome::analog_threshold
diff --git a/esphome/components/analog_threshold/binary_sensor.py b/esphome/components/analog_threshold/binary_sensor.py
index b5f87b9b5c..8c13727755 100644
--- a/esphome/components/analog_threshold/binary_sensor.py
+++ b/esphome/components/analog_threshold/binary_sensor.py
@@ -40,10 +40,10 @@ async def to_code(config):
cg.add(var.set_sensor(sens))
if isinstance(config[CONF_THRESHOLD], dict):
- lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], float)
- upper = await cg.templatable(config[CONF_THRESHOLD][CONF_UPPER], [], float)
+ lower = await cg.templatable(config[CONF_THRESHOLD][CONF_LOWER], [], cg.float_)
+ upper = await cg.templatable(config[CONF_THRESHOLD][CONF_UPPER], [], cg.float_)
else:
- lower = await cg.templatable(config[CONF_THRESHOLD], [], float)
+ lower = await cg.templatable(config[CONF_THRESHOLD], [], cg.float_)
upper = lower
cg.add(var.set_upper_threshold(upper))
cg.add(var.set_lower_threshold(lower))
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 260de82d14..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";
@@ -251,11 +250,11 @@ void APDS9960::read_gesture_data_() {
uint8_t buf[128];
for (uint8_t pos = 0; pos < fifo_level * 4; pos += 32) {
- // The ESP's i2c driver has a limited buffer size.
- // This way of retrieving the data should be wrong according to the datasheet
- // but it seems to work.
+ // Read in 32-byte chunks due to ESP8266 I2C buffer limit.
+ // Always read from 0xFC — the FIFO auto-increments through 0xFC-0xFF
+ // and advances its internal pointer after every 4th byte.
uint8_t read = std::min(32, fifo_level * 4 - pos);
- APDS9960_WARNING_CHECK(this->read_bytes(0xFC + pos, buf + pos, read), "Reading FIFO buffer failed.");
+ APDS9960_WARNING_CHECK(this->read_bytes(0xFC, buf + pos, read), "Reading FIFO buffer failed.");
}
if (millis() - this->gesture_start_ > 500) {
@@ -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 9772e6afca..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,30 +317,33 @@ 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
# Platform defaults based on available RAM and typical message rates:
+ # CONF_MAX_SEND_QUEUE defaults are power of 2 for efficient modulo
cv.SplitDefault(
CONF_MAX_SEND_QUEUE,
- esp8266=5, # Limited RAM, need to fail fast
+ esp8266=4, # Limited RAM, need to fail fast
esp32=8, # More RAM, can buffer more
- rp2040=5, # Limited RAM
+ rp2=8, # Moderate RAM
bk72xx=8, # Moderate RAM
nrf52=8, # Moderate RAM
rtl87xx=8, # Moderate RAM
@@ -316,6 +354,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_consume_api_sockets,
+ _register_provisioning_source,
)
@@ -335,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
@@ -381,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)
@@ -447,13 +488,19 @@ 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")
cg.add_define("USE_API_NOISE")
cg.add_library("esphome/noise-c", "0.1.11")
+ # Enable optimized memzero/memcmp in libsodium instead of volatile byte loops
+ cg.add_build_flag("-DHAVE_WEAK_SYMBOLS=1")
+ cg.add_build_flag("-DHAVE_INLINE_ASM=1")
else:
cg.add_define("USE_API_PLAINTEXT")
@@ -514,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,
@@ -618,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 84839d1461..8ca3e70100 100644
--- a/esphome/components/api/api.proto
+++ b/esphome/components/api/api.proto
@@ -132,11 +132,12 @@ message HelloResponse {
// A string identifying the server (ESP); like client info this may be empty
// and only exists for debugging/logging purposes.
- // For example "ESPHome v1.10.0 on ESP8266"
- string server_info = 3;
+ // Currently set to ESPHOME_VERSION string literal.
+ string server_info = 3 [(max_data_length) = 32, (force) = true];
- // The name of the server (App.get_name())
- string name = 4;
+ // The name of the server (App.get_name() - device hostname)
+ // max_data_length matches ESPHOME_DEVICE_NAME_MAX_LEN (validated by validate_hostname)
+ string name = 4 [(max_data_length) = 31, (force) = true];
}
// DEPRECATED in ESPHome 2026.1.0 - Password authentication is no longer supported.
@@ -160,6 +161,16 @@ message AuthenticationResponse {
bool invalid_password = 1;
}
+// Reason a party is requesting the connection be closed.
+enum DisconnectReason {
+ // No specific reason / not provided (default for older peers).
+ DISCONNECT_REASON_UNSPECIFIED = 0;
+ // The device's provisioning window has expired. The device must be reset
+ // (power-cycled) to reopen the provisioning window before it will accept a
+ // connection again.
+ DISCONNECT_REASON_PROVISIONING_CLOSED = 1;
+}
+
// Request to close the connection.
// Can be sent by both the client and server
message DisconnectRequest {
@@ -168,6 +179,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 {
@@ -199,12 +214,14 @@ message DeviceInfoRequest {
message AreaInfo {
uint32 area_id = 1;
- string name = 2;
+ // max_data_length matches core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA
+ string name = 2 [(max_data_length) = 120, (force) = true];
}
message DeviceInfo {
uint32 device_id = 1;
- string name = 2;
+ // max_data_length matches core/config.FRIENDLY_NAME_MAX_LEN via DEVICE_SCHEMA
+ string name = 2 [(max_data_length) = 120, (force) = true];
uint32 area_id = 3;
}
@@ -219,6 +236,16 @@ message SerialProxyInfo {
SerialProxyPortType port_type = 2; // Port type (RS232, RS485)
}
+// DeviceInfoResponse max_data_length values:
+// name = 31 (ESPHOME_DEVICE_NAME_MAX_LEN, validated by validate_hostname)
+// friendly_name = 120 (core/config.FRIENDLY_NAME_MAX_LEN)
+// mac_address/bluetooth_mac_address = 17 (MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1, constexpr)
+// esphome_version = 32 (ESPHOME_VERSION string literal)
+// compilation_time = 25 (Application::BUILD_TIME_STR_SIZE - 1, constexpr)
+// manufacturer = 20 (longest hardcoded literal: "Nordic Semiconductor")
+// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas)
+// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH)
+// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA)
message DeviceInfoResponse {
option (id) = 10;
option (source) = SOURCE_SERVER;
@@ -227,28 +254,30 @@ message DeviceInfoResponse {
// with older ESPHome versions that still send this field.
bool uses_password = 1 [deprecated = true];
- // The name of the node, given by "App.set_name()"
- string name = 2;
+ // The name of the node, given by "App.set_name()" - device hostname
+ string name = 2 [(max_data_length) = 31, (force) = true];
// The mac address of the device. For example "AC:BC:32:89:0E:A9"
- string mac_address = 3;
+ string mac_address = 3 [(max_data_length) = 17, (force) = true];
// A string describing the ESPHome version. For example "1.10.0"
- string esphome_version = 4;
+ string esphome_version = 4 [(max_data_length) = 32, (force) = true];
// A string describing the date of compilation, this is generated by the compiler
// and therefore may not be in the same format all the time.
// If the user isn't using ESPHome, this will also not be set.
- string compilation_time = 5;
+ string compilation_time = 5 [(max_data_length) = 25, (force) = true];
// The model of the board. For example NodeMCU
- string model = 6;
+ // max_data_length matches core/config.BOARD_MAX_LENGTH (validated in platform schemas)
+ string model = 6 [(max_data_length) = 127, (force) = true];
bool has_deep_sleep = 7 [(field_ifdef) = "USE_DEEP_SLEEP"];
// The esphome project details if set
- string project_name = 8 [(field_ifdef) = "ESPHOME_PROJECT_NAME"];
- string project_version = 9 [(field_ifdef) = "ESPHOME_PROJECT_NAME"];
+ // max_data_length matches core/config.PROJECT_MAX_LENGTH
+ string project_name = 8 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"];
+ string project_version = 9 [(max_data_length) = 127, (force) = true, (field_ifdef) = "ESPHOME_PROJECT_NAME"];
uint32 webserver_port = 10 [(field_ifdef) = "USE_WEBSERVER"];
@@ -256,18 +285,18 @@ message DeviceInfoResponse {
uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
- string manufacturer = 12;
+ string manufacturer = 12 [(max_data_length) = 20, (force) = true];
- string friendly_name = 13;
+ string friendly_name = 13 [(max_data_length) = 120, (force) = true];
// Deprecated in API version 1.10
uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"];
uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
- string suggested_area = 16 [(field_ifdef) = "USE_AREAS"];
+ string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"];
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
- string bluetooth_mac_address = 18 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
+ string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
// Supports receiving and saving api encryption key
bool api_encryption_supported = 19 [(field_ifdef) = "USE_API_NOISE"];
@@ -285,9 +314,14 @@ 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"];
+
// Indicates if Zigbee proxy support is available and features supported
- uint32 zigbee_proxy_feature_flags = 26 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
- uint64 zigbee_ieee_address = 27 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
+ uint32 zigbee_proxy_feature_flags = 27 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
+ uint64 zigbee_ieee_address = 28 [(field_ifdef) = "USE_ZIGBEE_PROXY"];
}
message ListEntitiesRequest {
@@ -315,6 +349,12 @@ enum EntityCategory {
ENTITY_CATEGORY_DIAGNOSTIC = 2;
}
+// Entity field max_data_length values match Python validation constants:
+// name/object_id = 120 (config_validation.NAME_MAX_LENGTH)
+// icon = 63 (core/config.ICON_MAX_LENGTH)
+// device_class = 47 (core/config.DEVICE_CLASS_MAX_LENGTH)
+// unit_of_measurement = 63 (core/config.UNIT_OF_MEASUREMENT_MAX_LENGTH)
+
// ==================== BINARY SENSOR ====================
message ListEntitiesBinarySensorResponse {
option (id) = 12;
@@ -322,15 +362,15 @@ message ListEntitiesBinarySensorResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BINARY_SENSOR";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string device_class = 5;
+ string device_class = 5 [(max_data_length) = 47];
bool is_status_binary_sensor = 6;
bool disabled_by_default = 7;
- string icon = 8 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 8 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
EntityCategory entity_category = 9;
uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"];
}
@@ -341,7 +381,7 @@ message BinarySensorStateResponse {
option (ifdef) = "USE_BINARY_SENSOR";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool state = 2;
// If the binary sensor does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
@@ -356,17 +396,17 @@ message ListEntitiesCoverResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_COVER";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
bool assumed_state = 5;
bool supports_position = 6;
bool supports_tilt = 7;
- string device_class = 8;
+ string device_class = 8 [(max_data_length) = 47];
bool disabled_by_default = 9;
- string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
EntityCategory entity_category = 11;
bool supports_stop = 12;
uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"];
@@ -390,7 +430,7 @@ message CoverStateResponse {
option (ifdef) = "USE_COVER";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
// legacy: state has been removed in 1.13
// clients/servers must still send/accept it until the next protocol change
// Deprecated in API version 1.1
@@ -416,7 +456,7 @@ message CoverCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
// legacy: command has been removed in 1.13
// clients/servers must still send/accept it until the next protocol change
@@ -440,9 +480,9 @@ message ListEntitiesFanResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_FAN";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
bool supports_oscillation = 5;
@@ -450,7 +490,7 @@ message ListEntitiesFanResponse {
bool supports_direction = 7;
int32 supported_speed_count = 8;
bool disabled_by_default = 9;
- string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 10 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
EntityCategory entity_category = 11;
repeated string supported_preset_modes = 12 [(container_pointer_no_template) = "std::vector"];
uint32 device_id = 13 [(field_ifdef) = "USE_DEVICES"];
@@ -473,7 +513,7 @@ message FanStateResponse {
option (ifdef) = "USE_FAN";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool state = 2;
bool oscillating = 3;
// Deprecated in API version 1.6
@@ -490,7 +530,7 @@ message FanCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool has_state = 2;
bool state = 3;
// Deprecated in API version 1.6
@@ -528,9 +568,9 @@ message ListEntitiesLightResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_LIGHT";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
repeated ColorMode supported_color_modes = 12 [(container_pointer_no_template) = "light::ColorModeMask"];
@@ -547,7 +587,7 @@ message ListEntitiesLightResponse {
float max_mireds = 10;
repeated string effects = 11 [(container_pointer_no_template) = "FixedVector"];
bool disabled_by_default = 13;
- string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
EntityCategory entity_category = 15;
uint32 device_id = 16 [(field_ifdef) = "USE_DEVICES"];
}
@@ -558,7 +598,7 @@ message LightStateResponse {
option (ifdef) = "USE_LIGHT";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool state = 2;
float brightness = 3;
ColorMode color_mode = 11;
@@ -580,7 +620,7 @@ message LightCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool has_state = 2;
bool state = 3;
bool has_brightness = 4;
@@ -633,16 +673,16 @@ message ListEntitiesSensorResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SENSOR";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
- string unit_of_measurement = 6;
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
+ string unit_of_measurement = 6 [(max_data_length) = 63];
int32 accuracy_decimals = 7;
bool force_update = 8;
- string device_class = 9;
+ string device_class = 9 [(max_data_length) = 47];
SensorStateClass state_class = 10;
// Last reset type removed in 2021.9.0
// Deprecated in API version 1.5
@@ -657,8 +697,9 @@ message SensorStateResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SENSOR";
option (no_delay) = true;
+ option (speed_optimized) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
float state = 2;
// If the sensor does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
@@ -673,16 +714,16 @@ message ListEntitiesSwitchResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SWITCH";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool assumed_state = 6;
bool disabled_by_default = 7;
EntityCategory entity_category = 8;
- string device_class = 9;
+ string device_class = 9 [(max_data_length) = 47];
uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"];
}
message SwitchStateResponse {
@@ -692,7 +733,7 @@ message SwitchStateResponse {
option (ifdef) = "USE_SWITCH";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool state = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
}
@@ -703,7 +744,7 @@ message SwitchCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool state = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
}
@@ -715,15 +756,15 @@ message ListEntitiesTextSensorResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_TEXT_SENSOR";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
- string device_class = 8;
+ string device_class = 8 [(max_data_length) = 47];
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
}
message TextSensorStateResponse {
@@ -733,7 +774,7 @@ message TextSensorStateResponse {
option (ifdef) = "USE_TEXT_SENSOR";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
string state = 2;
// If the text sensor does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
@@ -763,9 +804,10 @@ message SubscribeLogsResponse {
option (source) = SOURCE_SERVER;
option (log) = false;
option (no_delay) = false;
+ option (speed_optimized) = true;
- LogLevel level = 1;
- bytes message = 3;
+ LogLevel level = 1 [(force) = true];
+ bytes message = 3 [(force) = true];
}
// ==================== NOISE ENCRYPTION ====================
@@ -929,7 +971,7 @@ message ListEntitiesServicesResponse {
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
string name = 1;
- fixed32 key = 2;
+ fixed32 key = 2 [(force) = true];
repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true];
SupportsResponseType supports_response = 4;
}
@@ -952,7 +994,7 @@ message ExecuteServiceRequest {
option (no_delay) = true;
option (ifdef) = "USE_API_USER_DEFINED_ACTIONS";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
repeated ExecuteServiceArgument args = 2 [(fixed_vector) = true];
uint32 call_id = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES"];
bool return_response = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES"];
@@ -978,12 +1020,12 @@ message ListEntitiesCameraResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_CAMERA";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
bool disabled_by_default = 5;
- string icon = 6 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 6 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
EntityCategory entity_category = 7;
uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"];
}
@@ -994,7 +1036,7 @@ message CameraImageResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_CAMERA";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bytes data = 2;
bool done = 3;
uint32 device_id = 4 [(field_ifdef) = "USE_DEVICES"];
@@ -1009,6 +1051,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;
@@ -1063,9 +1112,9 @@ message ListEntitiesClimateResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_CLIMATE";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
bool supports_current_temperature = 5; // Deprecated: use feature_flags
@@ -1085,7 +1134,7 @@ message ListEntitiesClimateResponse {
repeated ClimatePreset supported_presets = 16 [(container_pointer_no_template) = "climate::ClimatePresetMask"];
repeated string supported_custom_presets = 17 [(container_pointer_no_template) = "std::vector"];
bool disabled_by_default = 18;
- string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 19 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
EntityCategory entity_category = 20;
float visual_current_temperature_step = 21;
bool supports_current_humidity = 22; // Deprecated: use feature_flags
@@ -1094,6 +1143,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;
@@ -1102,7 +1152,7 @@ message ClimateStateResponse {
option (ifdef) = "USE_CLIMATE";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
ClimateMode mode = 2;
float current_temperature = 3;
float target_temperature = 4;
@@ -1128,7 +1178,7 @@ message ClimateCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool has_mode = 2;
ClimateMode mode = 3;
bool has_target_temperature = 4;
@@ -1174,10 +1224,10 @@ message ListEntitiesWaterHeaterResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_WATER_HEATER";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
- string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON"];
+ 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"];
@@ -1187,6 +1237,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 {
@@ -1196,7 +1247,7 @@ message WaterHeaterStateResponse {
option (ifdef) = "USE_WATER_HEATER";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
float current_temperature = 2;
float target_temperature = 3;
WaterHeaterMode mode = 4;
@@ -1226,7 +1277,7 @@ message WaterHeaterCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
// Bitmask of which fields are set (see WaterHeaterCommandHasField)
uint32 has_fields = 2;
WaterHeaterMode mode = 3;
@@ -1250,20 +1301,20 @@ message ListEntitiesNumberResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_NUMBER";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
float min_value = 6;
float max_value = 7;
float step = 8;
bool disabled_by_default = 9;
EntityCategory entity_category = 10;
- string unit_of_measurement = 11;
+ string unit_of_measurement = 11 [(max_data_length) = 63];
NumberMode mode = 12;
- string device_class = 13;
+ string device_class = 13 [(max_data_length) = 47];
uint32 device_id = 14 [(field_ifdef) = "USE_DEVICES"];
}
message NumberStateResponse {
@@ -1273,7 +1324,7 @@ message NumberStateResponse {
option (ifdef) = "USE_NUMBER";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
float state = 2;
// If the number does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
@@ -1287,7 +1338,7 @@ message NumberCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
float state = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
}
@@ -1299,12 +1350,12 @@ message ListEntitiesSelectResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SELECT";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
repeated string options = 6 [(container_pointer_no_template) = "FixedVector"];
bool disabled_by_default = 7;
EntityCategory entity_category = 8;
@@ -1317,7 +1368,7 @@ message SelectStateResponse {
option (ifdef) = "USE_SELECT";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
string state = 2;
// If the select does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
@@ -1331,7 +1382,7 @@ message SelectCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
string state = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
}
@@ -1343,12 +1394,12 @@ message ListEntitiesSirenResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_SIREN";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
repeated string tones = 7 [(container_pointer_no_template) = "FixedVector"];
bool supports_duration = 8;
@@ -1363,7 +1414,7 @@ message SirenStateResponse {
option (ifdef) = "USE_SIREN";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool state = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
}
@@ -1374,7 +1425,7 @@ message SirenCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool has_state = 2;
bool state = 3;
bool has_tone = 4;
@@ -1394,6 +1445,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;
@@ -1406,12 +1459,12 @@ message ListEntitiesLockResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_LOCK";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
bool assumed_state = 8;
@@ -1429,7 +1482,7 @@ message LockStateResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_LOCK";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
LockState state = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
}
@@ -1439,7 +1492,7 @@ message LockCommandRequest {
option (ifdef) = "USE_LOCK";
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
LockCommand command = 2;
// Not yet implemented:
@@ -1455,15 +1508,15 @@ message ListEntitiesButtonResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BUTTON";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
- string device_class = 8;
+ string device_class = 8 [(max_data_length) = 47];
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
}
message ButtonCommandRequest {
@@ -1473,7 +1526,7 @@ message ButtonCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
uint32 device_id = 2 [(field_ifdef) = "USE_DEVICES"];
}
@@ -1522,12 +1575,12 @@ message ListEntitiesMediaPlayerResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_MEDIA_PLAYER";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
@@ -1545,7 +1598,7 @@ message MediaPlayerStateResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_MEDIA_PLAYER";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
MediaPlayerState state = 2;
float volume = 3;
bool muted = 4;
@@ -1558,7 +1611,7 @@ message MediaPlayerCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool has_command = 2;
MediaPlayerCommand command = 3;
@@ -1611,9 +1664,10 @@ message BluetoothLEAdvertisementResponse {
}
message BluetoothLERawAdvertisement {
- uint64 address = 1 [(force) = true];
+ option (inline_encode) = true;
+ uint64 address = 1 [(force) = true, (mac_address) = true];
sint32 rssi = 2 [(force) = true];
- uint32 address_type = 3;
+ uint32 address_type = 3 [(max_value) = 4];
bytes data = 4 [(fixed_array_size) = 62, (force) = true];
}
@@ -1623,6 +1677,7 @@ message BluetoothLERawAdvertisementsResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_BLUETOOTH_PROXY";
option (no_delay) = true;
+ option (speed_optimized) = true;
repeated BluetoothLERawAdvertisement advertisements = 1 [(fixed_array_with_length_define) = "BLUETOOTH_PROXY_ADVERTISEMENT_BATCH_SIZE"];
}
@@ -1997,6 +2052,7 @@ message VoiceAssistantAudio {
bytes data = 1 [(pointer_to_buffer) = true];
bool end = 2;
+ bytes data2 = 3 [(pointer_to_buffer) = true];
}
enum VoiceAssistantTimerEvent {
@@ -2110,11 +2166,11 @@ message ListEntitiesAlarmControlPanelResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_ALARM_CONTROL_PANEL";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
uint32 supported_features = 8;
@@ -2129,7 +2185,7 @@ message AlarmControlPanelStateResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_ALARM_CONTROL_PANEL";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
AlarmControlPanelState state = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
}
@@ -2140,7 +2196,7 @@ message AlarmControlPanelCommandRequest {
option (ifdef) = "USE_ALARM_CONTROL_PANEL";
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
AlarmControlPanelStateCommand command = 2;
string code = 3;
uint32 device_id = 4 [(field_ifdef) = "USE_DEVICES"];
@@ -2157,11 +2213,11 @@ message ListEntitiesTextResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_TEXT";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
@@ -2178,7 +2234,7 @@ message TextStateResponse {
option (ifdef) = "USE_TEXT";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
string state = 2;
// If the Text does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
@@ -2192,7 +2248,7 @@ message TextCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
string state = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
}
@@ -2205,12 +2261,12 @@ message ListEntitiesDateResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_DATETIME_DATE";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"];
@@ -2222,7 +2278,7 @@ message DateStateResponse {
option (ifdef) = "USE_DATETIME_DATE";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
// If the date does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
bool missing_state = 2;
@@ -2238,7 +2294,7 @@ message DateCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
uint32 year = 2;
uint32 month = 3;
uint32 day = 4;
@@ -2252,12 +2308,12 @@ message ListEntitiesTimeResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_DATETIME_TIME";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"];
@@ -2269,7 +2325,7 @@ message TimeStateResponse {
option (ifdef) = "USE_DATETIME_TIME";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
// If the time does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
bool missing_state = 2;
@@ -2285,7 +2341,7 @@ message TimeCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
uint32 hour = 2;
uint32 minute = 3;
uint32 second = 4;
@@ -2299,15 +2355,15 @@ message ListEntitiesEventResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_EVENT";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
- string device_class = 8;
+ string device_class = 8 [(max_data_length) = 47];
repeated string event_types = 9 [(container_pointer_no_template) = "FixedVector"];
uint32 device_id = 10 [(field_ifdef) = "USE_DEVICES"];
@@ -2318,7 +2374,7 @@ message EventResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_EVENT";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
string event_type = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
}
@@ -2330,15 +2386,15 @@ message ListEntitiesValveResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_VALVE";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
- string device_class = 8;
+ string device_class = 8 [(max_data_length) = 47];
bool assumed_state = 9;
bool supports_position = 10;
@@ -2358,7 +2414,7 @@ message ValveStateResponse {
option (ifdef) = "USE_VALVE";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
float position = 2;
ValveOperation current_operation = 3;
uint32 device_id = 4 [(field_ifdef) = "USE_DEVICES"];
@@ -2371,7 +2427,7 @@ message ValveCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool has_position = 2;
float position = 3;
bool stop = 4;
@@ -2385,12 +2441,12 @@ message ListEntitiesDateTimeResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_DATETIME_DATETIME";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
uint32 device_id = 8 [(field_ifdef) = "USE_DEVICES"];
@@ -2402,7 +2458,7 @@ message DateTimeStateResponse {
option (ifdef) = "USE_DATETIME_DATETIME";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
// If the datetime does not have a valid state yet.
// Equivalent to `!obj->has_state()` - inverse logic to make state packets smaller
bool missing_state = 2;
@@ -2416,7 +2472,7 @@ message DateTimeCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
fixed32 epoch_seconds = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
}
@@ -2428,15 +2484,15 @@ message ListEntitiesUpdateResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_UPDATE";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
+ string object_id = 1 [(max_data_length) = 120, (force) = true];
+ fixed32 key = 2 [(force) = true];
+ string name = 3 [(max_data_length) = 120, (force) = true];
reserved 4; // Deprecated: was string unique_id
- string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON"];
+ string icon = 5 [(field_ifdef) = "USE_ENTITY_ICON", (max_data_length) = 63];
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
- string device_class = 8;
+ string device_class = 8 [(max_data_length) = 47];
uint32 device_id = 9 [(field_ifdef) = "USE_DEVICES"];
}
message UpdateStateResponse {
@@ -2446,7 +2502,7 @@ message UpdateStateResponse {
option (ifdef) = "USE_UPDATE";
option (no_delay) = true;
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
bool missing_state = 2;
bool in_progress = 3;
bool has_progress = 4;
@@ -2470,7 +2526,7 @@ message UpdateCommandRequest {
option (no_delay) = true;
option (base_class) = "CommandProtoMessage";
- fixed32 key = 1;
+ fixed32 key = 1 [(force) = true];
UpdateCommand command = 2;
uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"];
}
@@ -2482,6 +2538,7 @@ message ZWaveProxyFrame {
option (source) = SOURCE_BOTH;
option (ifdef) = "USE_ZWAVE_PROXY";
option (no_delay) = true;
+ option (speed_optimized) = true;
bytes data = 1;
}
@@ -2511,41 +2568,66 @@ message ListEntitiesInfraredResponse {
option (source) = SOURCE_SERVER;
option (ifdef) = "USE_INFRARED";
- string object_id = 1;
- fixed32 key = 2;
- string name = 3;
- string icon = 4 [(field_ifdef) = "USE_ENTITY_ICON"];
+ 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; // Bitfield of InfraredCapabilityFlags
+ uint32 receiver_frequency = 9; // Demodulation frequency of the IR receiver in Hz (0 = unspecified)
}
// Command to transmit infrared/RF data using raw timings
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; // 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; // 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 {
@@ -2574,6 +2656,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
@@ -2680,7 +2763,7 @@ message BluetoothSetConnectionParamsResponse {
// ==================== ZIGBEE ====================
message ZigbeeProxyFrame {
- option (id) = 148;
+ option (id) = 149;
option (source) = SOURCE_BOTH;
option (ifdef) = "USE_ZIGBEE_PROXY";
option (no_delay) = true;
@@ -2695,7 +2778,7 @@ enum ZigbeeProxyRequestType {
}
message ZigbeeProxyRequest {
- option (id) = 149;
+ option (id) = 150;
option (source) = SOURCE_BOTH;
option (ifdef) = "USE_ZIGBEE_PROXY";
diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h
index 00801e3ee5..1d0cccf61c 100644
--- a/esphome/components/api/api_buffer.h
+++ b/esphome/components/api/api_buffer.h
@@ -44,6 +44,12 @@ class APIBuffer {
this->reserve(n);
this->size_ = n; // no zero-fill
}
+ /// Reserve capacity for max(reserve_size, new_size) bytes, then set size to new_size.
+ /// Single grow_ check regardless of argument order.
+ inline void reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
+ this->reserve(std::max(reserve_size, new_size));
+ this->size_ = new_size;
+ }
uint8_t *data() { return this->data_.get(); }
const uint8_t *data() const { return this->data_.get(); }
size_t size() const { return this->size_; }
diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp
index 7d3e248481..d60d15dc31 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"
@@ -52,14 +56,17 @@
#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 {
-// Read a maximum of 5 messages per loop iteration to prevent starving other components.
+// Maximum messages to read per loop iteration to prevent starving other components.
// This is a balance between API responsiveness and allowing other components to run.
// Since each message could contain multiple protobuf messages when using packet batching,
// this limits the number of messages processed, not the number of TCP packets.
-static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 5;
+static constexpr uint8_t MAX_MESSAGES_PER_LOOP = 10;
static constexpr uint8_t MAX_PING_RETRIES = 60;
static constexpr uint16_t PING_RETRY_INTERVAL = 1000;
static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS * 5) / 2;
@@ -67,10 +74,22 @@ static constexpr uint32_t KEEPALIVE_DISCONNECT_TIMEOUT = (KEEPALIVE_TIMEOUT_MS *
// A stalled handshake from a buggy client or network glitch holds a connection
// slot, which can prevent legitimate clients from reconnecting. Also hardens
// against the less likely case of intentional connection slot exhaustion.
-static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 15000;
+//
+// 60s is intentionally high: on ESP8266 with power_save_mode: LIGHT and weak
+// WiFi (-70 dBm+), TCP retransmissions push real-world handshake times to
+// 28-30s. See https://github.com/esphome/esphome/issues/14999
+static constexpr uint32_t HANDSHAKE_TIMEOUT_MS = 60000;
static constexpr auto ESPHOME_VERSION_REF = StringRef::from_lit(ESPHOME_VERSION);
+// Cross-validate C++ constants against proto max_data_length annotations in api.proto
+static_assert(MAC_ADDRESS_PRETTY_BUFFER_SIZE - 1 == 17,
+ "Update max_data_length for mac_address/bluetooth_mac_address in api.proto");
+static_assert(Application::BUILD_TIME_STR_SIZE - 1 == 25, "Update max_data_length for compilation_time in api.proto");
+static_assert(sizeof(ESPHOME_VERSION) - 1 <= 32, "Update max_data_length for esphome_version in api.proto");
+static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for name in api.proto");
+static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto");
+
static const char *const TAG = "api.connection";
#ifdef USE_CAMERA
static const int CAMERA_STOP_STREAM = 5000;
@@ -91,6 +110,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
@@ -106,6 +131,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) {
@@ -131,8 +162,6 @@ APIConnection::APIConnection(std::unique_ptr sock, APIServer *pa
#endif
}
-uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
-
void APIConnection::start() {
this->last_traffic_ = App.get_loop_component_start_time();
@@ -172,6 +201,29 @@ APIConnection::~APIConnection() {
#endif
}
+#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
+void APIConnection::upgrade_helper_to_noise_() {
+ // The client opened with a Noise hello while this device has no encryption
+ // key set. Replace the plaintext helper with a Noise helper so the key can
+ // be provisioned over an encrypted channel: the noise context PSK is all
+ // zeros when unprovisioned, and NNpsk0 still runs a fresh ephemeral X25519
+ // exchange, so a passive listener cannot read the session. A publicly known
+ // PSK authenticates nobody; this protects against sniffing only.
+ auto *plaintext = static_cast(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:
@@ -213,16 +265,32 @@ void APIConnection::loop() {
}
const uint32_t now = App.get_loop_component_start_time();
- // Check if socket has data ready before attempting to read
- if (this->helper_->is_socket_ready()) {
+ // Check if socket has data ready before attempting to read.
+ // Also try reading if we hit the message limit last time — LWIP's rcvevent
+ // (used by is_socket_ready) tracks pbuf dequeues, not bytes. When multiple
+ // messages share a TCP segment, the last message's data stays in LWIP's
+ // lastdata cache after rcvevent hits 0, making is_socket_ready() return false
+ // even though data remains.
+ if (this->helper_->is_socket_ready() || this->flags_.may_have_remaining_data) {
+ this->flags_.may_have_remaining_data = false;
// Read up to MAX_MESSAGES_PER_LOOP messages per loop to improve throughput
- for (uint8_t message_count = 0; message_count < MAX_MESSAGES_PER_LOOP; message_count++) {
+ uint8_t message_count = 0;
+ for (; message_count < MAX_MESSAGES_PER_LOOP; message_count++) {
ReadPacketBuffer buffer;
err = this->helper_->read_packet(&buffer);
if (err == APIError::WOULD_BLOCK) {
// 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 {
@@ -233,11 +301,16 @@ void APIConnection::loop() {
this->last_traffic_ = now;
}
// read a packet
- this->read_message(buffer.data_len, buffer.type, buffer.data);
+ this->read_message_(buffer.data_len, buffer.type, buffer.data);
if (this->flags_.remove)
return;
}
}
+ // If we hit the limit, there may be more data remaining in LWIP's
+ // lastdata cache that rcvevent doesn't account for.
+ if (message_count == MAX_MESSAGES_PER_LOOP) {
+ this->flags_.may_have_remaining_data = true;
+ }
}
// Process deferred batch if scheduled and timer has expired
@@ -308,6 +381,8 @@ void APIConnection::process_active_iterator_() {
this->destroy_active_iterator_();
if (this->flags_.state_subscription) {
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
+ } else {
+ this->finalize_iterator_sync_();
}
} else {
this->process_iterator_batch_(this->iterator_storage_.list_entities);
@@ -315,24 +390,30 @@ void APIConnection::process_active_iterator_() {
} else { // INITIAL_STATE
if (this->iterator_storage_.initial_state.completed()) {
this->destroy_active_iterator_();
- // Process any remaining batched messages immediately
- if (!this->deferred_batch_.empty()) {
- this->process_batch_();
- }
- // Now that everything is sent, enable immediate sending for future state changes
- this->flags_.should_try_send_immediately = true;
- // Release excess memory from buffers that grew during initial sync
- this->deferred_batch_.release_buffer();
- this->helper_->release_buffers();
+ this->finalize_iterator_sync_();
} else {
this->process_iterator_batch_(this->iterator_storage_.initial_state);
}
}
}
+void APIConnection::finalize_iterator_sync_() {
+ // Flush any remaining batched messages immediately so clients
+ // receive completion responses (e.g. ListEntitiesDoneResponse)
+ // without waiting for the batch timer.
+ if (!this->deferred_batch_.empty()) {
+ this->process_batch_();
+ }
+ // Enable immediate sending for future state changes
+ this->flags_.should_try_send_immediately = true;
+ // Release excess memory from buffers that grew during initial sync
+ this->deferred_batch_.release_buffer();
+ this->helper_->release_buffers();
+}
+
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();
}
@@ -375,16 +456,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();
}
@@ -399,7 +470,7 @@ uint16_t APIConnection::fill_and_encode_entity_info(EntityBase *entity, InfoResp
#ifdef USE_DEVICES
msg.device_id = entity->get_device_id();
#endif
- return encode_to_buffer(size_fn(&msg), encode_fn, &msg, conn, remaining_size);
+ return encode_to_buffer_slow(size_fn(&msg), encode_fn, &msg, conn, remaining_size);
}
uint16_t APIConnection::fill_and_encode_entity_info_with_device_class(EntityBase *entity, InfoResponseProtoMessage &msg,
@@ -1126,7 +1197,7 @@ void APIConnection::on_camera_image_request(const CameraImageRequest &msg) {
void APIConnection::on_get_time_response(const GetTimeResponse &value) {
if (homeassistant::global_homeassistant_time != nullptr) {
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
-#ifdef USE_TIME_TIMEZONE
+#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE)
if (!value.timezone.empty()) {
// Check if the sender provided pre-parsed timezone data.
// If std_offset is non-zero or DST rules are present, the parsed data was populated.
@@ -1263,6 +1334,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);
}
@@ -1312,7 +1386,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) {
@@ -1454,19 +1528,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
@@ -1474,7 +1565,7 @@ void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent
void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
- ESP_LOGW(TAG, "Serial proxy instance %u out of range (max %u)", msg.instance,
+ ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range (max %" PRIu32 ")", msg.instance,
static_cast(proxies.size()));
return;
}
@@ -1485,7 +1576,7 @@ void APIConnection::on_serial_proxy_configure_request(const SerialProxyConfigure
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
- ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance);
+ ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
proxies[msg.instance]->write_from_client(msg.data, msg.data_len);
@@ -1494,7 +1585,7 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest
void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
- ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance);
+ ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
proxies[msg.instance]->set_modem_pins(msg.line_states);
@@ -1503,7 +1594,7 @@ void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetM
void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
- ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance);
+ ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
SerialProxyGetModemPinsResponse resp{};
@@ -1515,7 +1606,7 @@ void APIConnection::on_serial_proxy_get_modem_pins_request(const SerialProxyGetM
void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
- ESP_LOGW(TAG, "Serial proxy instance %u out of range", msg.instance);
+ ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
switch (msg.type) {
@@ -1528,16 +1619,16 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
resp.instance = msg.instance;
resp.type = enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH;
switch (proxies[msg.instance]->flush_port()) {
- case uart::FlushResult::SUCCESS:
+ case uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS:
resp.status = enums::SERIAL_PROXY_STATUS_OK;
break;
- case uart::FlushResult::ASSUMED_SUCCESS:
+ case uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS:
resp.status = enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
break;
- case uart::FlushResult::TIMEOUT:
+ case uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT:
resp.status = enums::SERIAL_PROXY_STATUS_TIMEOUT;
break;
- case uart::FlushResult::FAILED:
+ case uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED:
resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
break;
}
@@ -1545,7 +1636,7 @@ void APIConnection::on_serial_proxy_request(const SerialProxyRequest &msg) {
break;
}
default:
- ESP_LOGW(TAG, "Unknown serial proxy request type: %u", static_cast(msg.type));
+ ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast(msg.type));
break;
}
}
@@ -1558,10 +1649,24 @@ uint16_t APIConnection::try_send_infrared_info(EntityBase *entity, APIConnection
auto *infrared = static_cast(entity);
ListEntitiesInfraredResponse msg;
msg.capabilities = infrared->get_capability_flags();
+ msg.receiver_frequency = infrared->get_traits().get_receiver_frequency_hz();
return fill_and_encode_entity_info(infrared, msg, conn, remaining_size);
}
#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);
@@ -1659,12 +1764,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;
@@ -1672,6 +1771,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_();
@@ -1707,7 +1819,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"
@@ -1731,6 +1843,7 @@ bool APIConnection::send_device_info_response_() {
static constexpr auto MANUFACTURER = StringRef::from_lit(ESPHOME_MANUFACTURER);
resp.manufacturer = MANUFACTURER;
#endif
+ static_assert(sizeof(ESPHOME_MANUFACTURER) - 1 <= 20, "Update max_data_length for manufacturer in api.proto");
#undef ESPHOME_MANUFACTURER
#ifdef USE_ESP8266
@@ -1795,6 +1908,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;
@@ -1825,7 +1944,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();
}
@@ -1953,6 +2073,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)) {
@@ -1962,10 +2091,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);
@@ -2012,53 +2152,15 @@ bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, M
size_t write_start = shared_buf.size();
shared_buf.resize(write_start + payload_size);
ProtoWriteBuffer buffer{&shared_buf, write_start};
- encode_fn(msg, buffer);
+ encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type);
}
-// Encodes a message to the buffer and returns the total number of bytes used,
-// including header and footer overhead. Returns 0 if the message doesn't fit.
-uint16_t 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
- // Cache frame sizes to avoid repeated virtual calls
- const uint8_t header_padding = conn->helper_->frame_header_padding();
- const uint8_t footer_size = conn->helper_->frame_footer_size();
+// encode_to_buffer is defined inline in api_connection.h (ESPHOME_ALWAYS_INLINE)
- // Calculate total size with padding for buffer allocation
- size_t total_calculated_size = calculated_size + header_padding + footer_size;
-
- // Check if it fits
- if (total_calculated_size > remaining_size)
- return 0; // Doesn't fit
-
- auto &shared_buf = conn->parent_->get_shared_buffer_ref();
-
- if (conn->flags_.batch_first_message) {
- // First message - buffer already prepared by caller, just clear flag
- conn->flags_.batch_first_message = false;
- } else {
- // Batch message second or later
- // Add padding for previous message footer + this message header
- size_t current_size = shared_buf.size();
- shared_buf.reserve(current_size + total_calculated_size);
- shared_buf.resize(current_size + footer_size + header_padding);
- }
-
- // Pre-resize buffer to include payload, then encode through raw pointer
- size_t write_start = shared_buf.size();
- shared_buf.resize(write_start + calculated_size);
- ProtoWriteBuffer buffer{&shared_buf, write_start};
- encode_fn(msg, buffer);
-
- // Return total size (header + payload + footer)
- return static_cast(header_padding + calculated_size + footer_size);
+// Noinline version for cold paths — single shared copy
+uint16_t APIConnection::encode_to_buffer_slow(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg,
+ APIConnection *conn, uint32_t remaining_size) {
+ return encode_to_buffer(calculated_size, encode_fn, msg, conn, remaining_size);
}
bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) {
const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE);
@@ -2090,37 +2192,9 @@ void APIConnection::on_fatal_error() {
this->flags_.remove = true;
}
-void __attribute__((flatten)) APIConnection::DeferredBatch::push_item(const BatchItem &item) { items.push_back(item); }
-
-void APIConnection::DeferredBatch::add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
- uint8_t aux_data_index) {
- // Check if we already have a message of this type for this entity
- // This provides deduplication per entity/message_type combination
- // O(n) but optimized for RAM and not performance.
- // Skip deduplication for events - they are edge-triggered, every occurrence matters
-#ifdef USE_EVENT
- if (message_type != EventResponse::MESSAGE_TYPE)
-#endif
- {
- for (const auto &item : items) {
- if (item.entity == entity && item.message_type == message_type)
- return; // Already queued
- }
- }
- // No existing item found (or event), add new one
- this->push_item({entity, message_type, estimated_size, aux_data_index});
-}
-
-void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
- // Add high priority message and swap to front
- // This avoids expensive vector::insert which shifts all elements
- // Note: We only ever have one high-priority message at a time (ping OR disconnect)
- // If we're disconnecting, pings are blocked, so this simple swap is sufficient
- this->push_item({entity, message_type, estimated_size, AUX_DATA_UNUSED});
- if (items.size() > 1) {
- // Swap the new high-priority item to the front
- std::swap(items.front(), items.back());
- }
+bool APIConnection::schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
+ this->deferred_batch_.add_item_front(entity, message_type, estimated_size);
+ return this->schedule_batch_();
}
bool APIConnection::send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
@@ -2154,6 +2228,13 @@ void APIConnection::process_batch_() {
return;
}
+ // Ensure TCP_NODELAY is on before draining overflow and writing batch data.
+ // Log messages enable Nagle (NODELAY off) to coalesce small packets.
+ // If Nagle is still on when we try to drain, LWIP holds data in the
+ // Nagle buffer, the TCP send buffer stays full, and the overflow
+ // buffer can never drain — blocking the batch write indefinitely.
+ this->helper_->set_nodelay_for_message(false);
+
// Try to clear buffer first
if (!this->try_to_clear_buffer(true)) {
// Can't write now, we'll try again later
@@ -2213,17 +2294,15 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items
"MessageInfo must remain trivially destructible with this placement-new approach");
const size_t messages_to_process = std::min(num_items, MAX_MESSAGES_PER_BATCH);
- const uint8_t frame_overhead = header_padding + footer_size;
// Stack-allocated array for message info
alignas(MessageInfo) char message_info_storage[MAX_MESSAGES_PER_BATCH * sizeof(MessageInfo)];
MessageInfo *message_info = reinterpret_cast(message_info_storage);
size_t items_processed = 0;
uint16_t remaining_size = std::numeric_limits::max();
- // Track where each message's header padding begins in the buffer
- // For plaintext: this is where the 6-byte header padding starts
- // For noise: this is where the 7-byte header padding starts
- // The actual message data follows after the header padding
+ // Track where each message's header begins in the buffer
+ // First message: offset 0 (max padding, may have unused leading bytes)
+ // Subsequent messages: offset points to exact header start (no gaps)
uint32_t current_offset = 0;
// Process items and encode directly to buffer (up to our limit)
@@ -2239,13 +2318,14 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items
}
// Message was encoded successfully
- // payload_size is header_padding + actual payload size + footer_size
- uint16_t proto_payload_size = payload_size - frame_overhead;
+ // payload_size = header_size + proto_payload_size + footer_size
+ uint16_t proto_payload_size = payload_size - this->batch_header_size_ - footer_size;
// Use placement new to construct MessageInfo in pre-allocated stack array
// This avoids default-constructing all MAX_MESSAGES_PER_BATCH elements
// Explicit destruction is not needed because MessageInfo is trivially destructible,
// as ensured by the static_assert in its definition.
- new (&message_info[items_processed++]) MessageInfo(item.message_type, current_offset, proto_payload_size);
+ new (&message_info[items_processed++])
+ MessageInfo(item.message_type, current_offset, proto_payload_size, this->batch_header_size_);
// After first message, set remaining size to MAX_BATCH_PACKET_SIZE to avoid fragmentation
if (items_processed == 1) {
remaining_size = MAX_BATCH_PACKET_SIZE;
@@ -2295,6 +2375,7 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items
uint16_t APIConnection::dispatch_message_(const DeferredBatch::BatchItem &item, uint32_t remaining_size,
bool batch_first) {
this->flags_.batch_first_message = batch_first;
+ this->batch_message_type_ = item.message_type;
#ifdef USE_EVENT
// Events need aux_data_index to look up event type from entity
if (item.message_type == EventResponse::MESSAGE_TYPE) {
@@ -2390,6 +2471,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 b454b3595a..3db3e86adb 100644
--- a/esphome/components/api/api_connection.h
+++ b/esphome/components/api/api_connection.h
@@ -11,14 +11,18 @@
#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"
#endif
#include "esphome/core/entity_base.h"
#include "esphome/core/string_ref.h"
@@ -33,27 +37,57 @@ 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");
+#ifdef USE_BENCHMARK
+class APIConnection;
+void bench_enable_immediate_send(APIConnection *conn);
+void bench_clear_batch(APIConnection *conn);
+void bench_process_batch(APIConnection *conn);
+#endif
+
class APIConnection final : public APIServerConnectionBase {
public:
friend class APIServer;
friend class ListEntitiesIterator;
+#ifdef USE_BENCHMARK
+ friend void bench_enable_immediate_send(APIConnection *conn);
+ friend void bench_clear_batch(APIConnection *conn);
+ friend void bench_process_batch(APIConnection *conn);
+#endif
APIConnection(std::unique_ptr socket, APIServer *parent);
- virtual ~APIConnection();
+ ~APIConnection();
void start();
void loop();
+ protected:
+ // read_message_ is defined here (instead of in APIServerConnectionBase) so the
+ // compiler can devirtualize and inline on_* handler calls within this final class.
+ void read_message_(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data);
+
+ // Auth helpers defined here (not in ProtoService) so the compiler can
+ // devirtualize is_connection_setup()/on_no_setup_connection() calls
+ // within this final class.
+ inline bool check_connection_setup_() {
+ if (!this->is_connection_setup()) {
+ this->on_no_setup_connection();
+ return false;
+ }
+ return true;
+ }
+ inline bool check_authenticated_() { return this->check_connection_setup_(); }
+
+ public:
bool send_list_info_done() {
return this->schedule_message_(nullptr, ListEntitiesDoneResponse::MESSAGE_TYPE,
ListEntitiesDoneResponse::ESTIMATED_SIZE);
@@ -63,98 +97,102 @@ class APIConnection final : public APIServerConnectionBase {
#endif
#ifdef USE_COVER
bool send_cover_state(cover::Cover *cover);
- void on_cover_command_request(const CoverCommandRequest &msg) override;
+ void on_cover_command_request(const CoverCommandRequest &msg);
#endif
#ifdef USE_FAN
bool send_fan_state(fan::Fan *fan);
- void on_fan_command_request(const FanCommandRequest &msg) override;
+ void on_fan_command_request(const FanCommandRequest &msg);
#endif
#ifdef USE_LIGHT
bool send_light_state(light::LightState *light);
- void on_light_command_request(const LightCommandRequest &msg) override;
+ void on_light_command_request(const LightCommandRequest &msg);
#endif
#ifdef USE_SENSOR
bool send_sensor_state(sensor::Sensor *sensor);
#endif
#ifdef USE_SWITCH
bool send_switch_state(switch_::Switch *a_switch);
- void on_switch_command_request(const SwitchCommandRequest &msg) override;
+ void on_switch_command_request(const SwitchCommandRequest &msg);
#endif
#ifdef USE_TEXT_SENSOR
bool send_text_sensor_state(text_sensor::TextSensor *text_sensor);
#endif
#ifdef USE_CAMERA
void set_camera_state(std::shared_ptr image);
- void on_camera_image_request(const CameraImageRequest &msg) override;
+ void on_camera_image_request(const CameraImageRequest &msg);
#endif
#ifdef USE_CLIMATE
bool send_climate_state(climate::Climate *climate);
- void on_climate_command_request(const ClimateCommandRequest &msg) override;
+ void on_climate_command_request(const ClimateCommandRequest &msg);
#endif
#ifdef USE_NUMBER
bool send_number_state(number::Number *number);
- void on_number_command_request(const NumberCommandRequest &msg) override;
+ void on_number_command_request(const NumberCommandRequest &msg);
#endif
#ifdef USE_DATETIME_DATE
bool send_date_state(datetime::DateEntity *date);
- void on_date_command_request(const DateCommandRequest &msg) override;
+ void on_date_command_request(const DateCommandRequest &msg);
#endif
#ifdef USE_DATETIME_TIME
bool send_time_state(datetime::TimeEntity *time);
- void on_time_command_request(const TimeCommandRequest &msg) override;
+ void on_time_command_request(const TimeCommandRequest &msg);
#endif
#ifdef USE_DATETIME_DATETIME
bool send_datetime_state(datetime::DateTimeEntity *datetime);
- void on_date_time_command_request(const DateTimeCommandRequest &msg) override;
+ void on_date_time_command_request(const DateTimeCommandRequest &msg);
#endif
#ifdef USE_TEXT
bool send_text_state(text::Text *text);
- void on_text_command_request(const TextCommandRequest &msg) override;
+ void on_text_command_request(const TextCommandRequest &msg);
#endif
#ifdef USE_SELECT
bool send_select_state(select::Select *select);
- void on_select_command_request(const SelectCommandRequest &msg) override;
+ void on_select_command_request(const SelectCommandRequest &msg);
#endif
#ifdef USE_BUTTON
- void on_button_command_request(const ButtonCommandRequest &msg) override;
+ void on_button_command_request(const ButtonCommandRequest &msg);
#endif
#ifdef USE_LOCK
bool send_lock_state(lock::Lock *a_lock);
- void on_lock_command_request(const LockCommandRequest &msg) override;
+ void on_lock_command_request(const LockCommandRequest &msg);
#endif
#ifdef USE_VALVE
bool send_valve_state(valve::Valve *valve);
- void on_valve_command_request(const ValveCommandRequest &msg) override;
+ void on_valve_command_request(const ValveCommandRequest &msg);
#endif
#ifdef USE_MEDIA_PLAYER
bool send_media_player_state(media_player::MediaPlayer *media_player);
- void on_media_player_command_request(const MediaPlayerCommandRequest &msg) override;
+ void on_media_player_command_request(const MediaPlayerCommandRequest &msg);
#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) override;
+ void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
#endif // USE_API_HOMEASSISTANT_SERVICES
#ifdef USE_BLUETOOTH_PROXY
- void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg) override;
- void on_unsubscribe_bluetooth_le_advertisements_request() override;
+ void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg);
+ void on_unsubscribe_bluetooth_le_advertisements_request();
- void on_bluetooth_device_request(const BluetoothDeviceRequest &msg) override;
- void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg) override;
- void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg) override;
- void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg) override;
- void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg) override;
- void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg) override;
- void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg) override;
- void on_subscribe_bluetooth_connections_free_request() override;
- void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) override;
- void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) override;
+ void on_bluetooth_device_request(const BluetoothDeviceRequest &msg);
+ void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg);
+ void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg);
+ void on_bluetooth_gatt_read_descriptor_request(const BluetoothGATTReadDescriptorRequest &msg);
+ void on_bluetooth_gatt_write_descriptor_request(const BluetoothGATTWriteDescriptorRequest &msg);
+ void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg);
+ void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg);
+ void on_subscribe_bluetooth_connections_free_request();
+ void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
+ void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg);
#endif
#ifdef USE_HOMEASSISTANT_TIME
@@ -165,19 +203,19 @@ class APIConnection final : public APIServerConnectionBase {
#endif
#ifdef USE_VOICE_ASSISTANT
- void on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg) override;
- void on_voice_assistant_response(const VoiceAssistantResponse &msg) override;
- void on_voice_assistant_event_response(const VoiceAssistantEventResponse &msg) override;
- void on_voice_assistant_audio(const VoiceAssistantAudio &msg) override;
- void on_voice_assistant_timer_event_response(const VoiceAssistantTimerEventResponse &msg) override;
- void on_voice_assistant_announce_request(const VoiceAssistantAnnounceRequest &msg) override;
- void on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg) override;
- void on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg) override;
+ void on_subscribe_voice_assistant_request(const SubscribeVoiceAssistantRequest &msg);
+ void on_voice_assistant_response(const VoiceAssistantResponse &msg);
+ void on_voice_assistant_event_response(const VoiceAssistantEventResponse &msg);
+ void on_voice_assistant_audio(const VoiceAssistantAudio &msg);
+ void on_voice_assistant_timer_event_response(const VoiceAssistantTimerEventResponse &msg);
+ void on_voice_assistant_announce_request(const VoiceAssistantAnnounceRequest &msg);
+ void on_voice_assistant_configuration_request(const VoiceAssistantConfigurationRequest &msg);
+ void on_voice_assistant_set_configuration(const VoiceAssistantSetConfiguration &msg);
#endif
#ifdef USE_ZWAVE_PROXY
- void on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) override;
- void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override;
+ void on_z_wave_proxy_frame(const ZWaveProxyFrame &msg);
+ void on_z_wave_proxy_request(const ZWaveProxyRequest &msg);
#endif
#ifdef USE_ZIGBEE_PROXY
@@ -188,25 +226,25 @@ class APIConnection final : public APIServerConnectionBase {
#ifdef USE_ALARM_CONTROL_PANEL
bool send_alarm_control_panel_state(alarm_control_panel::AlarmControlPanel *a_alarm_control_panel);
- void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg) override;
+ void on_alarm_control_panel_command_request(const AlarmControlPanelCommandRequest &msg);
#endif
#ifdef USE_WATER_HEATER
bool send_water_heater_state(water_heater::WaterHeater *water_heater);
- void on_water_heater_command_request(const WaterHeaterCommandRequest &msg) override;
+ void on_water_heater_command_request(const WaterHeaterCommandRequest &msg);
#endif
-#ifdef USE_IR_RF
- void on_infrared_rf_transmit_raw_timings_request(const InfraredRFTransmitRawTimingsRequest &msg) override;
+#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
#ifdef USE_SERIAL_PROXY
- void on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg) override;
- void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) override;
- void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) override;
- void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg) override;
- void on_serial_proxy_request(const SerialProxyRequest &msg) override;
+ void on_serial_proxy_configure_request(const SerialProxyConfigureRequest &msg);
+ void on_serial_proxy_write_request(const SerialProxyWriteRequest &msg);
+ void on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg);
+ void on_serial_proxy_get_modem_pins_request(const SerialProxyGetModemPinsRequest &msg);
+ void on_serial_proxy_request(const SerialProxyRequest &msg);
void send_serial_proxy_data(const SerialProxyDataReceived &msg);
#endif
@@ -216,26 +254,26 @@ class APIConnection final : public APIServerConnectionBase {
#ifdef USE_UPDATE
bool send_update_state(update::UpdateEntity *update);
- void on_update_command_request(const UpdateCommandRequest &msg) override;
+ void on_update_command_request(const UpdateCommandRequest &msg);
#endif
- void on_disconnect_response() override;
- void on_ping_response() override {
+ void on_disconnect_response();
+ void on_ping_response() {
// we initiated ping
this->flags_.sent_ping = false;
}
#ifdef USE_API_HOMEASSISTANT_STATES
- void on_home_assistant_state_response(const HomeAssistantStateResponse &msg) override;
+ void on_home_assistant_state_response(const HomeAssistantStateResponse &msg);
#endif
#ifdef USE_HOMEASSISTANT_TIME
- void on_get_time_response(const GetTimeResponse &value) override;
+ void on_get_time_response(const GetTimeResponse &value);
#endif
- void on_hello_request(const HelloRequest &msg) override;
- void on_disconnect_request() override;
- void on_ping_request() override;
- void on_device_info_request() override;
- void on_list_entities_request() override { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
- void on_subscribe_states_request() override {
+ void on_hello_request(const HelloRequest &msg);
+ 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); }
+ void on_subscribe_states_request() {
this->flags_.state_subscription = true;
// Start initial state iterator only if no iterator is active
// If list_entities is running, we'll start initial_state when it completes
@@ -243,25 +281,29 @@ class APIConnection final : public APIServerConnectionBase {
this->begin_iterator_(ActiveIterator::INITIAL_STATE);
}
}
- void on_subscribe_logs_request(const SubscribeLogsRequest &msg) override {
+ void on_subscribe_logs_request(const SubscribeLogsRequest &msg) {
this->flags_.log_subscription = msg.level;
if (msg.dump_config)
App.schedule_dump_config();
#ifdef USE_ESP32_CRASH_HANDLER
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();
#endif
}
#ifdef USE_API_HOMEASSISTANT_SERVICES
- void on_subscribe_homeassistant_services_request() override { this->flags_.service_call_subscription = true; }
+ void on_subscribe_homeassistant_services_request() { this->flags_.service_call_subscription = true; }
#endif
#ifdef USE_API_HOMEASSISTANT_STATES
- void on_subscribe_home_assistant_states_request() override;
+ void on_subscribe_home_assistant_states_request();
#endif
#ifdef USE_API_USER_DEFINED_ACTIONS
- void on_execute_service_request(const ExecuteServiceRequest &msg) override;
+ void on_execute_service_request(const ExecuteServiceRequest &msg);
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
void send_execute_service_response(uint32_t call_id, bool success, StringRef error_message);
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
@@ -271,13 +313,13 @@ class APIConnection final : public APIServerConnectionBase {
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
#endif
#ifdef USE_API_NOISE
- void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg) override;
+ void on_noise_encryption_set_key_request(const NoiseEncryptionSetKeyRequest &msg);
#endif
- bool is_authenticated() override {
+ bool is_authenticated() {
return static_cast(this->flags_.connection_state) == ConnectionState::AUTHENTICATED;
}
- bool is_connection_setup() override {
+ bool is_connection_setup() {
return static_cast(this->flags_.connection_state) == ConnectionState::CONNECTED ||
this->is_authenticated();
}
@@ -290,11 +332,11 @@ class APIConnection final : public APIServerConnectionBase {
(this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor);
}
- void on_fatal_error() override;
- void on_no_setup_connection() override;
+ void on_fatal_error();
+ void on_no_setup_connection();
// Function pointer type for type-erased message encoding
- using MessageEncodeFn = void (*)(const void *, ProtoWriteBuffer &);
+ using MessageEncodeFn = uint8_t *(*) (const void *, ProtoWriteBuffer &PROTO_ENCODE_DEBUG_PARAM);
// Function pointer type for type-erased size calculation
using CalculateSizeFn = uint32_t (*)(const void *);
@@ -311,9 +353,9 @@ class APIConnection final : public APIServerConnectionBase {
// Reserve space for header padding + message + footer
// - Header padding: space for protocol headers (7 bytes for Noise, 6 for Plaintext)
// - Footer: space for MAC (16 bytes for Noise, 0 for Plaintext)
- shared_buf.reserve(total_size);
- // Resize to add header padding so message encoding starts at the correct position
- shared_buf.resize(header_padding);
+ // Reserve full size but only set initial size to header padding
+ // so message encoding starts at the correct position
+ shared_buf.reserve_and_resize(total_size, header_padding);
}
// Convenience overload - computes frame overhead internally
@@ -330,7 +372,7 @@ class APIConnection final : public APIServerConnectionBase {
return true;
return this->try_to_clear_buffer_slow_(log_out_of_space);
}
- bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) override;
+ bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type);
const char *get_name() const { return this->helper_->get_client_name(); }
/// Get peer name (IP address) into caller-provided buffer, returns buf for convenience
@@ -373,21 +415,32 @@ class APIConnection final : public APIServerConnectionBase {
}
// Shared no-op encode thunk for empty messages (ESTIMATED_SIZE == 0)
- static void encode_msg_noop(const void *, ProtoWriteBuffer &) {}
+ static uint8_t *encode_msg_noop(const void *, ProtoWriteBuffer &buf PROTO_ENCODE_DEBUG_PARAM) {
+ return buf.get_pos();
+ }
// Non-template buffer management for send_message
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
- // Non-template buffer management for batch encoding
- static uint16_t encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg,
- APIConnection *conn, uint32_t remaining_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);
- // Thin template wrapper — computes size, delegates buffer work to non-template helper
+ // 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.
+ static uint16_t encode_to_buffer_slow(uint32_t calculated_size, MessageEncodeFn encode_fn, const void *msg,
+ APIConnection *conn, uint32_t remaining_size);
+
+ // Thin template wrapper — uses noinline encode_to_buffer_slow since
+ // encode_message_to_buffer callers are cold paths (zero-payload control messages).
+ // Hot paths (state/info) go through fill_and_encode_entity_state/info instead.
+ // batch_message_type_ is already set by dispatch_message_ before reaching here.
template