diff --git a/.clang-tidy b/.clang-tidy
index ea7370a3b2..6dab84fbd9 100644
--- a/.clang-tidy
+++ b/.clang-tidy
@@ -116,7 +116,6 @@ Checks: >-
-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,
diff --git a/.clang-tidy.hash b/.clang-tidy.hash
deleted file mode 100644
index 77b4f5323f..0000000000
--- a/.clang-tidy.hash
+++ /dev/null
@@ -1 +0,0 @@
-593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000000..92706fed20
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,16 @@
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "d=\"${CLAUDE_PROJECT_DIR:-.}\"; [ -x \"$d/venv/bin/python\" ] || { mkdir -p \"$d/.temp\"; env -u VIRTUAL_ENV \"$d/script/setup\" >\"$d/.temp/setup.log\" 2>&1 || echo '{\"systemMessage\":\"script/setup failed; see .temp/setup.log\"}'; }",
+ "statusMessage": "Setting up dev environment (script/setup)...",
+ "timeout": 900
+ }
+ ]
+ }
+ ]
+ }
+}
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 29f63b54b5..9181275269 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -15,7 +15,6 @@
// 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/.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..977fe9428d 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -2,13 +2,13 @@
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
about: Report an issue with the ESPHome web server.
- name: Report an issue with the ESPHome Builder / Dashboard
- url: https://github.com/esphome/dashboard/issues/new/choose
+ url: https://github.com/esphome/device-builder/issues/new/choose
about: Report an issue with the ESPHome Builder / Dashboard.
- name: Report an issue with the ESPHome API client
url: https://github.com/esphome/aioesphomeapi/issues/new/choose
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 52d72544d3..133d7ca8d8 100644
--- a/.github/actions/build-image/action.yaml
+++ b/.github/actions/build-image/action.yaml
@@ -15,11 +15,6 @@ inputs:
description: "Version to build"
required: true
example: "2023.12.0"
- base_os:
- description: "Base OS to use"
- required: false
- default: "debian"
- example: "debian"
runs:
using: "composite"
steps:
@@ -47,7 +42,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -60,7 +55,6 @@ runs:
build-args: |
BUILD_TYPE=${{ inputs.build_type }}
BUILD_VERSION=${{ inputs.version }}
- BUILD_OS=${{ inputs.base_os }}
outputs: |
type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
@@ -73,7 +67,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
- uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -86,7 +80,6 @@ runs:
build-args: |
BUILD_TYPE=${{ inputs.build_type }}
BUILD_VERSION=${{ inputs.version }}
- BUILD_OS=${{ inputs.base_os }}
outputs: |
type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
diff --git a/.github/actions/cache-esp-idf/action.yml b/.github/actions/cache-esp-idf/action.yml
new file mode 100644
index 0000000000..b884e1e4c6
--- /dev/null
+++ b/.github/actions/cache-esp-idf/action.yml
@@ -0,0 +1,54 @@
+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", and _get_configured_targets() in espidf/toolchain.py
+ skips per-variant narrowing whenever CI is set, so all toolchains are
+ present regardless of the chip a job builds.
+ Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the
+ Python venv already restored.
+inputs:
+ framework:
+ description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".'
+ default: espidf
+ restore-only:
+ description: >
+ When "true", only restore -- never save the cache, even on dev. Use from
+ jobs that may not produce an ESP-IDF install (e.g. a component batch with
+ no esp32 target), so a partial/empty install is never written to the key.
+ default: "false"
+runs:
+ using: composite
+ steps:
+ - name: Resolve ESP-IDF version for cache key
+ # The native-IDF version is pinned in code, not in any file that feeds the
+ # other cache keys, so resolve it explicitly. Keying on it means the cache
+ # invalidates on a version bump (actions/cache never overwrites a key).
+ id: version
+ shell: bash
+ run: |
+ . venv/bin/activate
+ if [ "${{ inputs.framework }}" = "arduino" ]; then
+ version=$(python -c 'from esphome.components.esp32 import ARDUINO_FRAMEWORK_VERSION_LOOKUP as A, ARDUINO_IDF_VERSION_LOOKUP as L; print(L[A["recommended"]])')
+ else
+ version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])')
+ fi
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ # Mirror the adjacent PlatformIO cache: only dev-branch runs write the
+ # shared cache (so it lives in the default-branch scope readable by all
+ # PRs), and PRs are restore-only -- they never push multi-GB artifacts into
+ # their own scope / the repo quota (e.g. on a version-bump PR).
+ - name: Cache ESP-IDF install (write on dev)
+ if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
+ uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ~/.esphome-idf
+ key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
+ - name: Cache ESP-IDF install (restore-only off dev)
+ if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
+ uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ with:
+ path: ~/.esphome-idf
+ key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
diff --git a/.github/actions/cache-sdk-nrf/action.yml b/.github/actions/cache-sdk-nrf/action.yml
new file mode 100644
index 0000000000..6cbb87cc66
--- /dev/null
+++ b/.github/actions/cache-sdk-nrf/action.yml
@@ -0,0 +1,49 @@
+name: Cache nRF Connect SDK
+description: >
+ Resolve the pinned sdk-nrf version and cache the native sdk-nrf install
+ (west workspace, Zephyr SDK toolchain, python env) at ~/.esphome-sdk-nrf.
+ Every job that installs sdk-nrf natively (the nrf52 clang-tidy job and,
+ once the component tests build natively, their batches) shares one cache.
+ Callers must set env ESPHOME_SDK_NRF_PREFIX: ~/.esphome-sdk-nrf and have
+ the Python venv already restored.
+inputs:
+ restore-only:
+ description: >
+ When "true", only restore -- never save the cache, even on dev. Use from
+ jobs that may not produce a complete install (e.g. a component batch
+ that fails mid-install), so a partial install is never written.
+ default: "false"
+runs:
+ using: composite
+ steps:
+ - name: Resolve sdk-nrf and toolchain versions for cache key
+ # Both versions are pinned in code, not in any file that feeds the
+ # other cache keys, so resolve them explicitly. Keying on them means
+ # the cache invalidates when either is bumped (actions/cache never
+ # overwrites a key).
+ id: version
+ shell: bash
+ run: |
+ . venv/bin/activate
+ version=$(python -c '
+ from esphome.components.nrf52 import RECOMMENDED_SDK_NRF_VERSION
+ from esphome.components.nrf52.framework import TOOLCHAIN_VERSION
+ print(f"{RECOMMENDED_SDK_NRF_VERSION}-{TOOLCHAIN_VERSION}")')
+ echo "version=$version" >> "$GITHUB_OUTPUT"
+ # Mirror cache-esp-idf: only dev-branch runs write the shared cache (so it
+ # lives in the default-branch scope readable by all PRs); PRs are
+ # restore-only and never push multi-GB artifacts into their own scope.
+ - name: Cache nRF Connect SDK install (write on dev)
+ if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.esphome-sdk-nrf
+ # yamllint disable-line rule:line-length
+ key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }}
+ - name: Cache nRF Connect SDK install (restore-only off dev)
+ if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: ~/.esphome-sdk-nrf
+ # yamllint disable-line rule:line-length
+ key: ${{ runner.os }}-esphome-sdk-nrf-${{ steps.version.outputs.version }}-${{ hashFiles('esphome/components/nrf52/requirements.txt') }}
diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml
index 21393f2aba..ce14b0152a 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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment
id: cache-venv
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: venv
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }}
+ - name: Set up uv
+ # Only needed on cache miss to populate the venv. ``uv pip install``
+ # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
+ # downstream jobs rely on is preserved.
+ if: steps.cache-venv.outputs.cache-hit != 'true'
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ 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_dev.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_dev.txt -r requirements_test.txt
+ uv pip install -e .
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 528e69c478..e87939f824 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -5,6 +5,7 @@ updates:
directory: "/"
schedule:
interval: daily
+ open-pull-requests-limit: 10
ignore:
# Hypotehsis is only used for testing and is updated quite often
- dependency-name: hypothesis
diff --git a/.github/scripts/auto-label-pr/constants.js b/.github/scripts/auto-label-pr/constants.js
index e02b450bf0..5e8acb09c9 100644
--- a/.github/scripts/auto-label-pr/constants.js
+++ b/.github/scripts/auto-label-pr/constants.js
@@ -13,6 +13,7 @@ module.exports = {
'merging-to-release',
'merging-to-beta',
'chained-pr',
+ 'stacked-pr',
'core',
'small-pr',
'medium-pr',
@@ -22,11 +23,13 @@ module.exports = {
'has-tests',
'needs-tests',
'needs-docs',
+ 'needs-developer-docs',
'needs-codeowners',
'too-big',
'labeller-recheck',
'bugfix',
'new-feature',
+ 'new-feature-developer',
'breaking-change',
'developer-breaking-change',
'undocumented-api-change',
@@ -35,7 +38,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 410c1a53c0..1d76c18be8 100644
--- a/.github/scripts/auto-label-pr/detectors.js
+++ b/.github/scripts/auto-label-pr/detectors.js
@@ -1,4 +1,4 @@
-const { DOCS_PR_PATTERNS } = require('./constants');
+const { DOCS_PR_PATTERNS, DEVELOPER_DOCS_PR_PATTERNS, DEV_DOCS_EXEMPT_FILES } = require('./constants');
const {
COMPONENT_REGEX,
detectComponents,
@@ -33,16 +33,54 @@ async function fetchPrFileContent(github, context, path) {
}
}
+// Check whether a pull request is part of a GitHub stack.
+//
+// GitHub's stacked pull request feature adds a `stack` object to the pull
+// request resource. It is present on every pull request in the stack -
+// including the bottom one, whose base is already `dev` - and is absent
+// entirely on standalone pull requests.
+//
+// The `pull_request_target` webhook payload is not guaranteed to carry this
+// field, so fall back to asking the API when it is missing. Guessing wrong
+// here is costly: a stacked pull request mistaken for a manually chained one
+// gets a label that blocks merging.
+async function isStackedPr(github, context) {
+ const pr = context.payload.pull_request;
+ if (pr.stack != null) {
+ return true;
+ }
+
+ try {
+ const { owner, repo } = context.repo;
+ const { data } = await github.rest.pulls.get({
+ owner,
+ repo,
+ pull_number: pr.number,
+ });
+ return data.stack != null;
+ } catch (error) {
+ // Treat an API failure as "not stacked" so a chained pull request still
+ // gets its blocking label rather than silently slipping through.
+ console.log('Failed to check stack membership:', error.message);
+ return false;
+ }
+}
+
// Strategy: Merge branch detection
-async function detectMergeBranch(context) {
+async function detectMergeBranch(github, context) {
const labels = new Set();
const baseRef = context.payload.pull_request.base.ref;
+ const defaultBranch = context.payload.repository.default_branch;
if (baseRef === 'release') {
labels.add('merging-to-release');
} else if (baseRef === 'beta') {
labels.add('merging-to-beta');
- } else if (baseRef !== 'dev') {
+ } else if (await isStackedPr(github, context)) {
+ // GitHub manages the merge order for a stack, so these are not blocked.
+ labels.add('stacked-pr');
+ } else if (baseRef !== defaultBranch) {
+ // A chain built by hand: it must not merge until its base branch does.
labels.add('chained-pr');
}
@@ -107,6 +145,8 @@ async function detectNewPlatforms(github, context, prFiles, apiData) {
/^esphome\/components\/([^\/]+)\/([^\/]+)\/__init__\.py$/,
];
+ 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);
@@ -114,6 +154,12 @@ async function detectNewPlatforms(github, context, prFiles, apiData) {
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) {
@@ -139,19 +185,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);
@@ -159,7 +195,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) {
@@ -230,6 +283,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' },
@@ -340,12 +394,14 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
const labels = new Set();
// Check for missing tests
- if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature')) && !allLabels.has('has-tests')) {
+ if ((allLabels.has('new-component') || allLabels.has('new-platform') || allLabels.has('new-feature') || allLabels.has('new-feature-developer')) && !allLabels.has('has-tests')) {
labels.add('needs-tests');
}
// Check for missing docs.
- // `new-feature` (PR-body checkbox) always counts. `new-component` / `new-platform`
+ // `new-feature` (PR-body checkbox) always counts. `new-feature-developer` is
+ // deliberately excluded here: its docs live on developers.esphome.io and are
+ // checked separately below. `new-component` / `new-platform`
// only count when at least one newly added file defines a top-level CONFIG_SCHEMA,
// i.e. the new component/platform is actually loadable from YAML.
const docsEligible =
@@ -361,6 +417,22 @@ async function detectRequirements(allLabels, prFiles, context, hasYamlLoadable)
}
}
+ // Check for missing developer docs. `new-feature-developer` requires a
+ // developers.esphome.io PR link, unless every changed file outside tests/ is
+ // in DEV_DOCS_EXEMPT_FILES (core validators documented via docstrings only).
+ if (allLabels.has('new-feature-developer')) {
+ const prBody = context.payload.pull_request.body || '';
+ const nonTestFiles = prFiles
+ .map(file => file.filename)
+ .filter(file => !file.startsWith('tests/'));
+ const onlyExemptFiles = nonTestFiles.every(file => DEV_DOCS_EXEMPT_FILES.includes(file));
+ const hasDevDocsLink = DEVELOPER_DOCS_PR_PATTERNS.some(pattern => pattern.test(prBody));
+
+ if (!onlyExemptFiles && !hasDevDocsLink) {
+ labels.add('needs-developer-docs');
+ }
+ }
+
// Check for missing CODEOWNERS
if (allLabels.has('new-component')) {
const codeownersModified = prFiles.some(file =>
diff --git a/.github/scripts/auto-label-pr/index.js b/.github/scripts/auto-label-pr/index.js
index 9769cd8060..8b0c821503 100644
--- a/.github/scripts/auto-label-pr/index.js
+++ b/.github/scripts/auto-label-pr/index.js
@@ -88,7 +88,7 @@ module.exports = async ({ github, context }) => {
// Early exit for release and beta branches only
if (baseRef === 'release' || baseRef === 'beta') {
- const branchLabels = await detectMergeBranch(context);
+ const branchLabels = await detectMergeBranch(github, context);
const finalLabels = Array.from(branchLabels);
console.log('Computed labels (merge branch only):', finalLabels.join(', '));
@@ -118,12 +118,12 @@ module.exports = async ({ github, context }) => {
deprecatedResult,
maintainerAccess
] = await Promise.all([
- detectMergeBranch(context),
+ detectMergeBranch(github, context),
detectComponentPlatforms(changedFiles, apiData),
detectNewComponents(github, context, prFiles),
detectNewPlatforms(github, context, prFiles, apiData),
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),
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/tests/detectors.test.js b/.github/scripts/auto-label-pr/tests/detectors.test.js
new file mode 100644
index 0000000000..be239e2f1b
--- /dev/null
+++ b/.github/scripts/auto-label-pr/tests/detectors.test.js
@@ -0,0 +1,466 @@
+const { describe, it } = require('node:test');
+const assert = require('node:assert/strict');
+const {
+ detectMergeBranch,
+ detectNewPlatforms,
+ detectNewComponents,
+ detectPRSize,
+ detectPRTemplateCheckboxes,
+ detectRequirements,
+} = require('../detectors');
+const { MANAGED_LABELS } = require('../constants');
+
+// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
+// to check for CONFIG_SCHEMA in newly added files.
+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"]';
+
+// ---------------------------------------------------------------------------
+// detectMergeBranch
+// ---------------------------------------------------------------------------
+
+// Builds a fresh context for detectMergeBranch tests instead of mutating the
+// shared CONTEXT fixture above (which other describe blocks rely on).
+function makeMergeContext(baseRef, { stack, defaultBranch = 'dev' } = {}) {
+ const pull_request = { number: 1, base: { ref: baseRef } };
+ if (stack !== undefined) {
+ pull_request.stack = stack;
+ }
+ return {
+ repo: { owner: 'esphome', repo: 'esphome' },
+ payload: { pull_request, repository: { default_branch: defaultBranch } }
+ };
+}
+
+// A GitHub API mock exposing only rest.pulls.get, with a call counter so
+// tests can assert whether the API fallback was actually invoked.
+function makeStackGithub({ stack = null, error = null } = {}) {
+ const state = { calls: 0 };
+ const github = {
+ rest: {
+ pulls: {
+ get: async () => {
+ state.calls++;
+ if (error) throw error;
+ return { data: { stack } };
+ }
+ }
+ }
+ };
+ return { github, state };
+}
+
+const STACK_INFO = { base: { ref: 'dev' }, id: 71540, number: 17978, position: 3, size: 3 };
+
+describe('detectMergeBranch', () => {
+ it('base ref release adds merging-to-release only and never checks the stack', async () => {
+ const { github, state } = makeStackGithub({ stack: STACK_INFO });
+ const context = makeMergeContext('release', { stack: STACK_INFO });
+ const labels = await detectMergeBranch(github, context);
+ assert.deepEqual(Array.from(labels).sort(), ['merging-to-release']);
+ assert.equal(state.calls, 0);
+ });
+
+ it('base ref beta adds merging-to-beta only and never checks the stack', async () => {
+ const { github, state } = makeStackGithub({ stack: STACK_INFO });
+ const context = makeMergeContext('beta', { stack: STACK_INFO });
+ const labels = await detectMergeBranch(github, context);
+ assert.deepEqual(Array.from(labels).sort(), ['merging-to-beta']);
+ assert.equal(state.calls, 0);
+ });
+
+ it('stack present on the webhook payload adds stacked-pr without calling the API', async () => {
+ const { github, state } = makeStackGithub();
+ const context = makeMergeContext('feature-branch', { stack: STACK_INFO });
+ const labels = await detectMergeBranch(github, context);
+ assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
+ assert.equal(state.calls, 0);
+ });
+
+ it('stack absent from payload falls back to the API and adds stacked-pr', async () => {
+ const { github, state } = makeStackGithub({ stack: STACK_INFO });
+ const context = makeMergeContext('feature-branch');
+ const labels = await detectMergeBranch(github, context);
+ assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
+ assert.equal(state.calls, 1);
+ });
+
+ it('bottom of a stack (base ref dev, stack present) still adds stacked-pr', async () => {
+ const { github, state } = makeStackGithub();
+ const context = makeMergeContext('dev', { stack: STACK_INFO });
+ const labels = await detectMergeBranch(github, context);
+ assert.deepEqual(Array.from(labels).sort(), ['stacked-pr']);
+ assert.equal(state.calls, 0);
+ });
+
+ it('not stacked, base ref not dev adds chained-pr', async () => {
+ const { github } = makeStackGithub({ stack: null });
+ const context = makeMergeContext('feature-branch');
+ const labels = await detectMergeBranch(github, context);
+ assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
+ });
+
+ it('not stacked, base ref dev adds no labels', async () => {
+ const { github } = makeStackGithub({ stack: null });
+ const context = makeMergeContext('dev');
+ const labels = await detectMergeBranch(github, context);
+ assert.deepEqual(Array.from(labels).sort(), []);
+ });
+
+ it('a failed stack lookup falls back to not-stacked, so a feature-branch base adds chained-pr', async () => {
+ const { github, state } = makeStackGithub({ error: new Error('API unavailable') });
+ const context = makeMergeContext('feature-branch');
+ const labels = await detectMergeBranch(github, context);
+ assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
+ assert.equal(state.calls, 1);
+ });
+
+ it('base ref matches default branch adds no labels', async () => {
+ const { github } = makeStackGithub({ stack: null });
+ const context = makeMergeContext('other', { defaultBranch: 'other' });
+ const labels = await detectMergeBranch(github, context);
+ assert.deepEqual(Array.from(labels).sort(), []);
+ });
+
+ it('base ref dev when the default branch is main adds chained-pr', async () => {
+ const { github } = makeStackGithub({ stack: null });
+ const context = makeMergeContext('dev', { defaultBranch: 'main' });
+ const labels = await detectMergeBranch(github, context);
+ assert.deepEqual(Array.from(labels).sort(), ['chained-pr']);
+ });
+
+});
+
+// ---------------------------------------------------------------------------
+// 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 2d000658a2..b49c66b976 100644
--- a/.github/workflows/auto-label-pr.yml
+++ b/.github/workflows/auto-label-pr.yml
@@ -24,18 +24,18 @@ jobs:
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
steps:
- name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Generate a token
id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
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
+ permission-pull-requests: write # pulls.get, pulls.listFiles, list/create/update/dismissReview
- name: Auto Label PR
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml
index 2f7fd271ba..63219a1dbc 100644
--- a/.github/workflows/ci-api-proto.yml
+++ b/.github/workflows/ci-api-proto.yml
@@ -21,20 +21,55 @@ 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ 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
+ # PR-only workflow, so nothing on dev could seed a shared apt cache
+ # entry; the cached apt action would save one copy per PR. Plain apt
+ # with every call bounded: the apt.conf.d timeouts make a dead
+ # mirror fail over in seconds, and timeout runs under sudo so it can
+ # kill apt-get itself. Install without update first: image lists are
+ # fresh, and the index refresh is what a congested mirror makes slow.
+ timeout-minutes: 15
run: |
- sudo apt update
- sudo apt-cache show protobuf-compiler
- sudo apt install -y protobuf-compiler
+ sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
+ Acquire::Retries "1";
+ Acquire::http::Timeout "15";
+ Acquire::https::Timeout "15";
+ EOF
+ # Common path: the image's package lists are fresh enough.
+ if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
+ apt-get install -y protobuf-compiler; then
+ protoc --version
+ exit 0
+ fi
+ # Rescue path: refresh the lists once with a generous bound; the
+ # apt config already fails a stalled mirror over quickly.
+ sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
+ dpkg --configure -a || true
+ sudo timeout -k 15 300 apt-get update
+ sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
+ apt-get install -y protobuf-compiler
protoc --version
- name: Install python dependencies
- run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt
+ run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
- name: Generate files
run: script/api_protobuf/api_protobuf.py
- name: Check for changes
diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml
deleted file mode 100644
index d9148fb06d..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 # actions/checkout for the PR head
- pull-requests: write # pulls.createReview / listReviews / dismissReview when the clang-tidy hash is out of date
-
-jobs:
- verify-hash:
- name: Verify clang-tidy hash
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
- - name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- with:
- python-version: "3.11"
-
- - name: Verify hash
- run: |
- python script/clang_tidy_hash.py --verify
-
- - if: failure()
- name: Show hash details
- run: |
- python script/clang_tidy_hash.py
- echo "## Job Failed" | tee -a $GITHUB_STEP_SUMMARY
- echo "You have modified clang-tidy configuration but have not updated the hash." | tee -a $GITHUB_STEP_SUMMARY
- echo "Please run 'script/clang_tidy_hash.py --update' and commit the changes." | tee -a $GITHUB_STEP_SUMMARY
-
- - if: failure() && github.event.pull_request.head.repo.full_name == github.repository
- name: Request changes
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- await github.rest.pulls.createReview({
- pull_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- event: 'REQUEST_CHANGES',
- body: 'You have modified clang-tidy configuration but have not updated the hash.\nPlease run `script/clang_tidy_hash.py --update` and commit the changes.'
- })
-
- - if: success() && github.event.pull_request.head.repo.full_name == github.repository
- name: Dismiss review
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- script: |
- let reviews = await github.rest.pulls.listReviews({
- pull_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo
- });
- for (let review of reviews.data) {
- if (review.user.login === 'github-actions[bot]' && review.state === 'CHANGES_REQUESTED') {
- await github.rest.pulls.dismissReview({
- pull_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- review_id: review.id,
- message: 'Clang-tidy hash now matches configuration.'
- });
- }
- }
diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml
index 3fd17888c7..829bdd5f98 100644
--- a/.github/workflows/ci-docker.yml
+++ b/.github/workflows/ci-docker.yml
@@ -1,28 +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 # actions/checkout only; the build does not push images
+ contents: read # actions/checkout only
concurrency:
# yamllint disable-line rule:line-length
@@ -33,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:
@@ -41,23 +57,167 @@ 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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- - name: Set TAG
+ - name: Determine tag and whether to push
+ id: tag
+ env:
+ HEAD_REF: ${{ github.head_ref || github.ref_name }}
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="$HEAD_REF"
+ 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@dbcb813823bdd20940b903addbd779551569679f # v4.6.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'
+ # zstd over gzip: docker save is on the critical path for every
+ # compile-test job, and zstd -T0 is multithreaded (export 50s -> 9s).
+ # docker load auto-detects the format; its time is layer extraction,
+ # not decompression, so it is unchanged. shell: bash adds pipefail so
+ # a failed docker save cannot upload a truncated artifact.
+ shell: bash
+ run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | zstd -T0 -3 > compile-test-image.tar.zst
+
+ - 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 compressed, 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.zst
+ 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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
+
+ - name: Log in to the GitHub container registry
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.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
+ 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.zst
+ - name: Load image
+ run: docker load --input compile-test-image.tar.zst
+ - 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 025b960985..0dc653bd39 100644
--- a/.github/workflows/ci-memory-impact-comment.yml
+++ b/.github/workflows/ci-memory-impact-comment.yml
@@ -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 ad39b3f346..cbf6e070b4 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 # 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,185 @@ 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_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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: venv
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }}
+ - name: Set up uv
+ # Only needed on cache miss to populate the venv. ``uv pip install``
+ # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
+ # that ``. venv/bin/activate`` see an identical layout.
+ if: steps.cache-venv.outputs.cache-hit != 'true'
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ 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_dev.txt -r requirements_test.txt pre-commit
- pip install -e .
+ uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt
+ uv pip install -e .
+
+ seed-apt-cache:
+ name: Seed apt package cache
+ runs-on: ubuntu-24.04
+ # PR-branch cache saves are invisible to other PRs, so dev/beta/release
+ # pushes seed the one shared entry PR jobs restore. The key is derived
+ # only from the package list and version; keep both identical in every
+ # step that restores it. In ci-status needs so a broken seed fails dev.
+ if: github.event_name == 'push'
+ timeout-minutes: 10
+ steps:
+ - name: Install apt packages (cached)
+ uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
+ with:
+ packages: libsdl2-dev ccache
+ version: 1.1
+
+ determine-jobs:
+ name: Determine which jobs to run
+ runs-on: ubuntu-24.04
+ 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 }}
+ component-test-count: ${{ steps.determine.outputs.component-test-count }}
+ changed-cpp-file-count: ${{ steps.determine.outputs.changed-cpp-file-count }}
+ memory_impact: ${{ steps.determine.outputs.memory-impact }}
+ 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 }}
+ # "true" when this run is a pull request into one of the release
+ # branches. Those pull requests are batches of changes already tested on
+ # their original dev pull requests, so several jobs below trade coverage
+ # for turnaround time on them. Matched exactly, not by prefix, so an
+ # ordinary branch named e.g. "release-notes" is not caught by it.
+ release-pr: ${{ github.base_ref == 'beta' || github.base_ref == 'release' }}
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ # Fetch enough history to find the merge base
+ 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: Restore components graph cache
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: .temp/components_graph.json
+ key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
+ - name: Determine which tests to run
+ id: determine
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ . venv/bin/activate
+ 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
+ echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT
+ echo "changed-cpp-file-count=$(echo "$output" | jq -r '.changed_cpp_file_count')" >> $GITHUB_OUTPUT
+ echo "memory-impact=$(echo "$output" | jq -c '.memory_impact')" >> $GITHUB_OUTPUT
+ 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: .temp/components_graph.json
+ key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
+
+ ci-custom:
+ name: Run script/ci-custom
+ runs-on: ubuntu-24.04
+ needs:
+ - common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.core-ci == 'true'
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Restore Python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ env.DEFAULT_PYTHON }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+ - name: Register matcher
+ run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json"
+ - name: Run script/ci-custom
+ run: |
+ . venv/bin/activate
+ script/ci-custom.py
+ script/build_codeowners.py --check
+ script/build_alias_registry.py --check
+ script/build_language_schema.py --check
+ script/generate-esp32-boards.py --check
+ script/generate-rp2-boards.py --check
+ script/ci_check_duplicate_test_ids.py
+ script/ci_check_test_fixture_list_form.py
pylint:
name: Check pylint
@@ -70,7 +217,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:
@@ -84,29 +231,191 @@ jobs:
run: script/ci-suggest-changes
if: always()
- ci-custom:
- name: Run script/ci-custom
+ lint-format:
+ name: Check lint and formatting
+ runs-on: ubuntu-latest
+ needs:
+ - determine-jobs
+ if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.release-pr == 'false' && needs.determine-jobs.outputs.core-ci == 'true'
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Run prek
+ uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece # v3.0.0
+ with:
+ # Keep in sync with requirements_test.txt.
+ prek-version: "0.4.11"
+ # This job only runs on pull requests, so nothing ever populates
+ # the cache on dev. Every run would miss and then write a per-pull
+ # request copy, which is what the old seed-cache job existed to
+ # avoid. Building the hooks from scratch takes seconds, so skip it.
+ cache: false
+ env:
+ PREK_SKIP: pylint,ci-custom
+ # Pushes any fixes the hooks made back to the pull request. This step
+ # must keep its default name: the GitHub App that performs the push
+ # locates the workflow run by that name.
+ - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
+ if: always()
+ with:
+ msg: apply automatic formatting fixes
+
+ pytest:
+ name: Run pytest
+ strategy:
+ fail-fast: false
+ matrix:
+ python-version:
+ - "3.12"
+ - "3.13"
+ - "3.14"
+ os:
+ - ubuntu-latest
+ - macOS-latest
+ - windows-latest
+ exclude:
+ # Minimize CI resource usage
+ # by only running the Python version
+ # version used for docker images on Windows and macOS
+ - python-version: "3.13"
+ os: windows-latest
+ - python-version: "3.13"
+ os: macOS-latest
+ runs-on: ${{ matrix.os }}
+ needs:
+ - common
+ - determine-jobs
+ if: needs.determine-jobs.outputs.core-ci == 'true'
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Restore Python
+ id: restore-python
+ uses: ./.github/actions/restore-python
+ with:
+ python-version: ${{ matrix.python-version }}
+ cache-key: ${{ needs.common.outputs.cache-key }}
+ - name: Register matcher
+ run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
+ - name: Run pytest
+ if: matrix.os == 'windows-latest'
+ run: |
+ . ./venv/Scripts/activate.ps1
+ pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
+ - name: Run pytest
+ if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
+ run: |
+ . venv/bin/activate
+ pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+ - name: Save Python virtual environment cache
+ if: github.ref == 'refs/heads/dev'
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
+ with:
+ path: venv
+ key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
+
+ codecov-empty-upload:
+ name: Report no coverage to Codecov
+ runs-on: ubuntu-24.04
+ needs:
+ - determine-jobs
+ # ``pytest`` is the only job that uploads coverage, and it is skipped when
+ # every changed file is CI-irrelevant (see ``should_run_core_ci`` in
+ # ``script/determine-jobs.py``). With no upload Codecov never reports a
+ # result, so the required ``codecov/patch`` status stays pending forever and
+ # the pull request can never be merged. Tell Codecov up front that this
+ # commit has nothing to cover so it publishes a passing status instead.
+ #
+ # ``force`` skips Codecov's own check that every changed file is ignorable;
+ # ``determine-jobs`` has already decided none of these files can affect
+ # coverage, and Codecov would otherwise fail the status for paths it does
+ # not recognise as non-testable (``docker/**``, ``.yamllint``).
+ if: needs.determine-jobs.outputs.core-ci == 'false'
+ steps:
+ - name: Check out code from GitHub
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Report empty upload to Codecov
+ uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+ with:
+ run_command: empty-upload
+ force: true
+ fail_ci_if_error: true
+
+ integration-tests:
+ name: Run integration tests (${{ matrix.bucket.name }})
+ # Must match seed-apt-cache's image: the apt cache key has no OS in it.
runs-on: ubuntu-24.04
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
- - name: Restore Python
- uses: ./.github/actions/restore-python
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ - name: Install apt packages (cached)
+ # ccache speeds up the host compiles. A cache hit never touches apt
+ # (mirror outages cannot hang the job); the timeout bounds the cold
+ # path. Packages and version must match seed-apt-cache exactly;
+ # libsdl2-dev is unused here and carried only for cache-key parity.
+ timeout-minutes: 10
+ uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- cache-key: ${{ needs.common.outputs.cache-key }}
+ packages: libsdl2-dev ccache
+ version: 1.1
+ - name: Set up Python 3.13
+ id: python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.13"
+ - name: Restore Python virtual environment
+ id: cache-venv
+ 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ 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
+ uv pip install -r requirements.txt -r requirements_test.txt
+ uv pip install -e .
- name: Register matcher
- run: echo "::add-matcher::.github/workflows/matchers/ci-custom.json"
- - name: Run script/ci-custom
+ 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
- script/ci-custom.py
- script/build_codeowners.py --check
- script/build_language_schema.py --check
- script/generate-esp32-boards.py --check
- script/generate-rp2040-boards.py --check
+ mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
+ echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
+ pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
+ - name: Print ccache statistics
+ # esphome stores the PlatformIO ccache under the machine-global cache
+ # dir (see _ccache_env() in esphome/platformio/toolchain.py).
+ run: CCACHE_DIR="$HOME/.cache/esphome/platformio-ccache" ccache -s
import-time:
name: Check import esphome.__main__ time
@@ -117,7 +426,7 @@ jobs:
if: needs.determine-jobs.outputs.import-time == '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:
@@ -136,230 +445,104 @@ jobs:
if-no-files-found: ignore
retention-days: 14
- device-builder:
- name: Test downstream esphome/device-builder
+ benchmarks:
+ name: Run CodSpeed benchmarks
runs-on: ubuntu-24.04
+ timeout-minutes: 30
needs:
- common
- determine-jobs
- if: needs.determine-jobs.outputs.device-builder == 'true'
- steps:
- - name: Check out esphome (this PR)
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- path: esphome
- - name: Check out esphome/device-builder
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- with:
- repository: esphome/device-builder
- ref: main
- path: device-builder
- - name: Set up Python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- with:
- python-version: "3.13"
- - name: Set up uv
- # Mirrors the install shape device-builder's own CI uses
- # (esphome/device-builder#192): uv replaces pip for the
- # install step (order-of-magnitude faster on cold boots,
- # with its own wheel cache). actions/setup-python still
- # provides the interpreter.
- uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- with:
- enable-cache: true
- - name: Install device-builder + esphome from PR
- # Install device-builder with its esphome + test extras
- # first so its pinned versions of pytest/etc. land, then
- # overlay the PR's esphome so the downstream tests run
- # against this PR's Python code. ``--system`` installs into
- # the runner's Python instead of a venv.
- run: |
- uv pip install --system -e './device-builder[esphome,test]'
- uv pip install --system -e ./esphome
- - name: Run device-builder pytest
- # ``-n auto`` runs under pytest-xdist (matches device-builder's
- # own CI). No ``--cov`` here -- this is purely a downstream
- # smoke check against this PR's esphome code.
- working-directory: device-builder
- run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks
-
- pytest:
- name: Run pytest
- strategy:
- fail-fast: false
- matrix:
- python-version:
- - "3.11"
- - "3.13"
- - "3.14"
- os:
- - ubuntu-latest
- - macOS-latest
- - windows-latest
- exclude:
- # Minimize CI resource usage
- # by only running the Python version
- # version used for docker images on Windows and macOS
- - python-version: "3.13"
- os: windows-latest
- - python-version: "3.13"
- os: macOS-latest
- runs-on: ${{ matrix.os }}
- needs:
- - common
+ if: >-
+ github.repository == 'esphome/esphome' && (
+ (github.event_name == 'push' && github.ref_name == 'dev') ||
+ (
+ github.event_name == 'pull_request' &&
+ needs.determine-jobs.outputs.release-pr == 'false' &&
+ needs.determine-jobs.outputs.benchmarks == 'true'
+ )
+ )
+ # CodSpeed benchmarks require a CodSpeed account linked to the repository to run
+ # (https://codspeed.io) -- disabled on forks that aren't esphome/esphome itself.
+ #
+ # Pull requests into beta and release are skipped as well. CodSpeed compares a
+ # pull request against the newest commit of its base branch that has a benchmark
+ # run of its own, and only dev is benchmarked. A release pull request therefore
+ # falls back to dev's latest run, so every speed-up merged into dev since the
+ # release branched is reported as a regression in the release. The changes there
+ # have already been benchmarked on their original dev pull requests.
steps:
- name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Restore Python
- id: restore-python
- uses: ./.github/actions/restore-python
- with:
- python-version: ${{ matrix.python-version }}
- cache-key: ${{ needs.common.outputs.cache-key }}
- - name: Register matcher
- run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- - name: Run pytest
- if: matrix.os == 'windows-latest'
- run: |
- . ./venv/Scripts/activate.ps1
- pytest -vv --cov-report=xml --tb=native -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/
- - name: Upload coverage to Codecov
- uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
- with:
- token: ${{ secrets.CODECOV_TOKEN }}
- - name: Save Python virtual environment cache
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: venv
- key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- determine-jobs:
- name: Determine which jobs to run
- runs-on: ubuntu-24.04
- needs:
- - common
- outputs:
- 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 }}
- python-linters: ${{ steps.determine.outputs.python-linters }}
- import-time: ${{ steps.determine.outputs.import-time }}
- device-builder: ${{ steps.determine.outputs.device-builder }}
- 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 }}
- component-test-count: ${{ steps.determine.outputs.component-test-count }}
- changed-cpp-file-count: ${{ steps.determine.outputs.changed-cpp-file-count }}
- memory_impact: ${{ steps.determine.outputs.memory-impact }}
- 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
- with:
- # Fetch enough history to find the merge base
- 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: Restore components graph cache
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: .temp/components_graph.json
- key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
- - name: Determine which tests to run
- id: determine
- env:
- GH_TOKEN: ${{ github.token }}
- run: |
- . venv/bin/activate
- output=$(python script/determine-jobs.py)
- echo "Test determination output:"
- echo "$output" | jq
- # Extract individual fields
- 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 "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 "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
- echo "component-test-count=$(echo "$output" | jq -r '.component_test_count')" >> $GITHUB_OUTPUT
- echo "changed-cpp-file-count=$(echo "$output" | jq -r '.changed_cpp_file_count')" >> $GITHUB_OUTPUT
- echo "memory-impact=$(echo "$output" | jq -c '.memory_impact')" >> $GITHUB_OUTPUT
- echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT
- echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT
- echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT
- echo "validate-only-components=$(echo "$output" | jq -c '.validate_only_components')" >> $GITHUB_OUTPUT
- echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
- - name: Save components graph cache
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: .temp/components_graph.json
- key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
+ - name: Build benchmarks
+ id: build
+ run: |
+ # pipefail: without it a failed build is masked by the grep/cut
+ # pipeline below, leaving BINARY empty and silently dropping every
+ # C++ benchmark from the run while the job still reports success.
+ set -o pipefail
+ . venv/bin/activate
+ BENCHMARK_LIB_CONFIG=$(python script/setup_codspeed_lib.py)
+ export BENCHMARK_LIB_CONFIG
+ # --build-only prints BUILD_BINARY= to stdout; the grep is
+ # non-fatal so a missing marker reaches the check below instead of
+ # tripping errexit at this assignment
+ BINARY=$(script/cpp_benchmark.py --all --build-only | { grep '^BUILD_BINARY=' || true; } | tail -1 | cut -d= -f2-)
+ if [ -z "$BINARY" ]; then
+ echo "::error::Benchmark build did not report a binary path"
+ exit 1
+ fi
+ echo "binary=$BINARY" >> $GITHUB_OUTPUT
- 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
- - name: Set up Python 3.13
- id: python
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- with:
- python-version: "3.13"
- - name: Restore Python virtual environment
- id: cache-venv
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: venv
- key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
- - name: Create Python virtual environment
- if: steps.cache-venv.outputs.cache-hit != 'true'
+ - name: Bound apt fetches and pre-install libc6-dbg
+ # The CodSpeed runner installs valgrind + libc6-dbg via its own
+ # unbounded apt-get update; per-invocation apt options cannot reach
+ # it. The apt.conf.d timeouts below bound every later apt call in
+ # this job, the runner's included. Pre-installing libc6-dbg lets the
+ # runner skip apt once its valgrind cache is restored (it checks
+ # ``dpkg -s libc6-dbg``, so the cache action's unregistered restores
+ # would not count). Install without update first: image lists are
+ # fresh, and the index refresh is what a congested mirror makes
+ # slow. Best effort; the job timeout is the last backstop.
+ timeout-minutes: 15
+ continue-on-error: true
run: |
- python -m venv venv
- . venv/bin/activate
- python --version
- pip install -r requirements.txt -r requirements_test.txt
- 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
- 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 -n auto "${test_files[@]}"
+ sudo tee /etc/apt/apt.conf.d/99ci-acquire-timeouts >/dev/null <<'EOF'
+ Acquire::Retries "1";
+ Acquire::http::Timeout "15";
+ Acquire::https::Timeout "15";
+ EOF
+ if dpkg -s libc6-dbg >/dev/null 2>&1; then
+ echo "libc6-dbg already installed"
+ exit 0
+ fi
+ # Common path: the image's package lists are fresh enough.
+ if sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 90 \
+ apt-get install -y libc6-dbg; then
+ exit 0
+ fi
+ # Rescue path: refresh the lists once with a generous bound; the
+ # apt config already fails a stalled mirror over quickly.
+ sudo DEBIAN_FRONTEND=noninteractive timeout -k 10 30 \
+ dpkg --configure -a || true
+ sudo timeout -k 15 300 apt-get update
+ sudo DEBIAN_FRONTEND=noninteractive timeout -k 15 300 \
+ apt-get install -y libc6-dbg
+
+ - name: Run CodSpeed benchmarks
+ uses: CodSpeedHQ/action@373d6868929f444bc08d901fd0eb0ad52a8875ea # v5.2.1
+ with:
+ run: |
+ . venv/bin/activate
+ ${{ steps.build.outputs.binary }}
+ pytest tests/benchmarks/python/ --codspeed --no-cov
+ mode: simulation
cpp-unit-tests:
name: Run C++ unit tests
@@ -370,7 +553,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
@@ -388,40 +571,6 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-
- - name: Restore Python
- uses: ./.github/actions/restore-python
- with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- cache-key: ${{ needs.common.outputs.cache-key }}
-
- - name: 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@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1
- with:
- run: ${{ steps.build.outputs.binary }}
- mode: simulation
-
clang-tidy-single:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
@@ -431,9 +580,12 @@ 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
matrix:
include:
- id: clang-tidy
@@ -441,61 +593,81 @@ 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
- name: Restore Python
+ id: restore-python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
+ # Key on the exact Python version as well: LibreTiny creates a venv under
+ # ~/.platformio/penv whose interpreter is a symlink into the runner's
+ # hosted toolcache, so a cache saved on an older runner image breaks once
+ # a new image ships a newer patch release and drops the old interpreter.
- name: Cache platformio
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key
+ uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
- key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
+ key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio
- if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
- key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
+ key: platformio-${{ matrix.pio_cache_key }}-${{ steps.restore-python.outputs.python-version }}-${{ 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
@@ -505,11 +677,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
@@ -521,7 +704,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
@@ -529,9 +712,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
@@ -542,19 +727,8 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- - name: Cache platformio
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
-
- - name: Cache platformio
- if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
+ - name: Cache ESP-IDF install
+ uses: ./.github/actions/cache-esp-idf
- name: Register problem matchers
run: |
@@ -565,9 +739,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
@@ -577,11 +754,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
@@ -600,27 +777,25 @@ 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
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
@@ -631,19 +806,8 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- - name: Cache platformio
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
-
- - name: Cache platformio
- if: github.ref != 'refs/heads/dev'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.platformio
- key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }}
+ - name: Cache ESP-IDF install
+ uses: ./.github/actions/cache-esp-idf
- name: Register problem matchers
run: |
@@ -654,9 +818,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
@@ -666,7 +833,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"
@@ -680,18 +847,107 @@ 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
+ 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: |
@@ -699,21 +955,43 @@ 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
+ - name: Install apt packages (cached)
+ # A cache hit (seeded on dev by seed-apt-cache) never touches apt,
+ # so mirror outages cannot hang this PR-only job; the timeout bounds
+ # the cold path. Packages and version must match seed-apt-cache
+ # exactly. The action has no --no-install-recommends; same package
+ # set this job used before #17463.
+ timeout-minutes: 10
+ uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
with:
- packages: libsdl2-dev
- version: 1.0
+ packages: libsdl2-dev ccache
+ version: 1.1
- 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
@@ -744,7 +1022,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
@@ -754,7 +1032,7 @@ jobs:
# - This catches pin conflicts and other issues in directly changed code
# - Grouped tests use --testing-mode to allow config merging (disables some checks)
# - Dependencies are safe to group since they weren't modified in this PR
- if [[ "${{ github.base_ref }}" == beta* ]] || [[ "${{ github.base_ref }}" == release* ]]; then
+ if [[ "${{ needs.determine-jobs.outputs.release-pr }}" == "true" ]]; then
directly_changed_csv=""
echo "Testing components: $components_csv"
echo "Target branch: ${{ github.base_ref }} - grouping all components"
@@ -765,7 +1043,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 ""
@@ -817,19 +1095,27 @@ jobs:
echo "All components in this batch are validate-only -- skipping compile stage."
fi
- test-native-idf:
- name: Test components with native ESP-IDF
+ - 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'
+ if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp32-platformio == 'true'
env:
- ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
- TEST_COMPONENTS: esp32,api,heatpumpir,bme280_i2c,bh1750,aht10,esp32_ble,esp32_ble_beacon,esp32_ble_client,esp32_ble_server,esp32_ble_tracker,ble_client,ble_presence,ble_rssi,ble_scanner
+ # 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -837,90 +1123,80 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- - name: Cache ESPHome
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.esphome-idf
- key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
-
- - name: Run native ESP-IDF compile test
+ - name: Run PlatformIO compile test
run: |
. venv/bin/activate
- # Check if /mnt has more free space than / before bind mounting
- # Extract available space in KB for comparison
- root_avail=$(df -k / | awk 'NR==2 {print $4}')
- mnt_avail=$(df -k /mnt 2>/dev/null | awk 'NR==2 {print $4}')
-
- echo "Available space: / has ${root_avail}KB, /mnt has ${mnt_avail}KB"
-
- # Only use /mnt if it has more space than /
- if [ -n "$mnt_avail" ] && [ "$mnt_avail" -gt "$root_avail" ]; then
- echo "Using /mnt for build files (more space available)"
- # Bind mount PlatformIO directory to /mnt (tools, packages, build cache all go there)
- sudo mkdir -p /mnt/esphome-idf
- sudo chown $USER:$USER /mnt/esphome-idf
- mkdir -p ~/.esphome-idf
- sudo mount --bind /mnt/esphome-idf ~/.esphome-idf
-
- # Bind mount test build directory to /mnt
- sudo mkdir -p /mnt/test_build_components_build
- sudo chown $USER:$USER /mnt/test_build_components_build
- mkdir -p tests/test_build_components/build
- sudo mount --bind /mnt/test_build_components_build tests/test_build_components/build
- else
- echo "Using / for build files (more space available than /mnt or /mnt unavailable)"
- fi
-
echo "Testing components: $TEST_COMPONENTS"
echo ""
- # Show disk space before validation (after bind mounts setup)
- echo "Disk space before config validation:"
- df -h
- echo ""
-
- # Run config validation (auto-grouped by test_build_components.py)
- python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf
+ # 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 "Config validation passed! Starting compilation..."
+ echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
echo ""
- # Show disk space before compilation
- echo "Disk space before compilation:"
- df -h
- 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
- # Run compilation (auto-grouped by test_build_components.py)
- python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf
-
- - name: Save ESPHome cache
- if: github.ref == 'refs/heads/dev'
- uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
- with:
- path: ~/.esphome-idf
- key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
-
- pre-commit-ci-lite:
- name: pre-commit.ci lite
- runs-on: ubuntu-latest
+ device-builder:
+ name: Test downstream esphome/device-builder
+ runs-on: ubuntu-24.04
needs:
- common
- if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
+ - determine-jobs
+ if: needs.determine-jobs.outputs.device-builder == 'true'
steps:
- - name: Check out code from GitHub
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- - name: Restore Python
- uses: ./.github/actions/restore-python
+ - name: Check out esphome (this PR)
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- python-version: ${{ env.DEFAULT_PYTHON }}
- cache-key: ${{ needs.common.outputs.cache-key }}
- - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache
- env:
- SKIP: pylint,clang-tidy-hash,ci-custom
- - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
- if: always()
+ 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@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ 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
memory-impact-target-branch:
name: Build target branch for memory impact
@@ -936,7 +1212,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 }}
@@ -1012,7 +1288,7 @@ jobs:
- name: Restore cached memory analysis
id: cache-memory-analysis
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -1036,7 +1312,7 @@ jobs:
- name: Cache platformio
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true'
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -1078,7 +1354,7 @@ jobs:
- name: Save memory analysis to cache
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success'
- uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }}
@@ -1118,14 +1394,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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -1187,7 +1463,7 @@ jobs:
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:
@@ -1221,20 +1497,28 @@ jobs:
ci-status:
name: CI Status
runs-on: ubuntu-24.04
+ # Listed in the same order the jobs are defined above. One job is
+ # deliberately left out: "benchmarks" reports through CodSpeed rather than
+ # this check.
needs:
- common
+ - seed-apt-cache
+ - determine-jobs
- ci-custom
- pylint
+ - lint-format
- pytest
+ - codecov-empty-upload
- integration-tests
+ - import-time
+ - cpp-unit-tests
- clang-tidy-single
- clang-tidy-nosplit
- clang-tidy-split
- - determine-jobs
- - device-builder
+ - clang-tidy-esp32-variants
- test-build-components-split
- - test-native-idf
- - pre-commit-ci-lite
+ - test-esp32-platformio
+ - device-builder
- memory-impact-target-branch
- memory-impact-pr-branch
- memory-impact-comment
@@ -1249,4 +1533,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/codeowner-approved-label-update.yml b/.github/workflows/codeowner-approved-label-update.yml
index 013517bde6..bb1d1e2d7a 100644
--- a/.github/workflows/codeowner-approved-label-update.yml
+++ b/.github/workflows/codeowner-approved-label-update.yml
@@ -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: |
diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml
index cd6c1d34c6..38a4b8ff0e 100644
--- a/.github/workflows/codeowner-review-request.yml
+++ b/.github/workflows/codeowner-review-request.yml
@@ -29,13 +29,13 @@ 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: Generate a token
id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 0a4dd9a92d..b46f9adab6 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -52,11 +52,11 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
- uses: actions/checkout@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@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
+ uses: github/codeql-action/init@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
+ uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4.37.8
with:
category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/dashboard-deprecation-comment.yml b/.github/workflows/dashboard-deprecation-comment.yml
deleted file mode 100644
index e15c61df5e..0000000000
--- a/.github/workflows/dashboard-deprecation-comment.yml
+++ /dev/null
@@ -1,113 +0,0 @@
-name: Add Dashboard Deprecation Comment
-
-on:
- pull_request_target:
- types: [opened, synchronize]
-
-# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with
-# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes.
-permissions: {}
-
-jobs:
- dashboard-deprecation-comment:
- name: Dashboard deprecation comment
- runs-on: ubuntu-latest
- steps:
- - name: Generate a token
- id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
- with:
- client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
- private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
- # pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources
- # the issues.*Comment APIs require the pull-requests scope, not issues.
- permission-pull-requests: write
-
- - name: Add dashboard deprecation comment
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- github-token: ${{ steps.generate-token.outputs.token }}
- script: |
- const commentMarker = "";
-
- const commentBody = `Thanks for opening this PR!
-
- Heads up: the legacy ESPHome dashboard (\`esphome/dashboard/\` and \`tests/dashboard/\`) is **deprecated** and is being replaced by [ESPHome Device Builder](https://github.com/esphome/device-builder). We are not adding new features to the legacy dashboard and it will eventually be removed from this repository.
-
- What this means for your PR:
-
- - **New features / enhancements**: please port the change to [esphome/device-builder](https://github.com/esphome/device-builder) instead. We are unlikely to review or merge new dashboard features here.
- - **Bug fixes**: small fixes may still be considered, but please check first whether the same issue exists in Device Builder, where the fix will have a longer life.
- - **Security issues**: please do not file a public PR. Report privately via [GitHub security advisories](https://github.com/esphome/esphome/security/advisories/new) so we can coordinate a fix.
-
- We appreciate the contribution and apologize for the friction; flagging this early so your time isn't spent on a change that may not land.
-
- ---
- (Added by the PR bot)
-
- ${commentMarker}`;
-
- async function getDashboardChanges(github, owner, repo, prNumber) {
- const changedFiles = await github.paginate(
- github.rest.pulls.listFiles,
- {
- owner: owner,
- repo: repo,
- pull_number: prNumber,
- per_page: 100,
- }
- );
-
- return changedFiles.filter(file =>
- file.filename.startsWith('esphome/dashboard/') ||
- file.filename.startsWith('tests/dashboard/')
- );
- }
-
- async function findBotComment(github, owner, repo, prNumber) {
- const comments = await github.paginate(
- github.rest.issues.listComments,
- {
- owner: owner,
- repo: repo,
- issue_number: prNumber,
- per_page: 100,
- }
- );
-
- return comments.find(comment =>
- comment.body.includes(commentMarker) && comment.user.type === "Bot"
- );
- }
-
- const prNumber = context.payload.pull_request.number;
- const { owner, repo } = context.repo;
-
- const dashboardChanges = await getDashboardChanges(github, owner, repo, prNumber);
- const existingComment = await findBotComment(github, owner, repo, prNumber);
-
- if (dashboardChanges.length === 0) {
- // PR doesn't (or no longer) touches the legacy dashboard. If we previously
- // commented (e.g. files were removed in a later push), leave the comment in
- // place for history rather than thrash on edit/delete.
- return;
- }
-
- if (existingComment) {
- if (existingComment.body === commentBody) {
- return;
- }
- await github.rest.issues.updateComment({
- owner: owner,
- repo: repo,
- comment_id: existingComment.id,
- body: commentBody,
- });
- } else {
- await github.rest.issues.createComment({
- owner: owner,
- repo: repo,
- issue_number: prNumber,
- body: commentBody,
- });
- }
diff --git a/.github/workflows/external-component-bot.yml b/.github/workflows/external-component-bot.yml
index 2e96bec1de..104988d7a5 100644
--- a/.github/workflows/external-component-bot.yml
+++ b/.github/workflows/external-component-bot.yml
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml
index 5e70117652..e09e9bf2d1 100644
--- a/.github/workflows/lock.yml
+++ b/.github/workflows/lock.yml
@@ -14,4 +14,4 @@ jobs:
permissions:
issues: write # issues.lock on closed issues
pull-requests: write # issues.lock on closed pull requests
- uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1
+ uses: esphome/workflows/.github/workflows/lock.yml@0fdd5e311b7e744069166696072a1a9cbc5fbeb6 # 2026.8.1
diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml
index ed0bff9664..3a89c26cd3 100644
--- a/.github/workflows/pr-title-check.yml
+++ b/.github/workflows/pr-title-check.yml
@@ -16,7 +16,7 @@ jobs:
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@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
@@ -29,10 +29,11 @@ jobs:
} = require('./.github/scripts/detect-tags.js');
const title = context.payload.pull_request.title;
- const author = context.payload.pull_request.user.login;
+ const user = context.payload.pull_request.user;
- // Skip bot PRs (e.g. dependabot) - they have their own title format
- if (author === 'dependabot[bot]') {
+ // Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) -
+ // they have their own title formats.
+ if (user.type === 'Bot') {
return;
}
@@ -68,14 +69,15 @@ jobs:
return;
}
- // Check for angle brackets not wrapped in backticks.
- // Astro docs MDX treats bare < as JSX component opening tags.
+ // Check for MDX syntax characters not wrapped in backticks.
+ // Astro docs MDX treats bare `<` as JSX component opening tags and
+ // bare `{` as JS expressions, so both must be escaped in changelog entries.
const stripped = title.replace(/`[^`]*`/g, '');
- if (/[<>]/.test(stripped)) {
+ if (/[<>{}]/.test(stripped)) {
core.setFailed(
- 'PR title contains `<` or `>` not wrapped in backticks.\n' +
- 'Astro docs MDX interprets bare `<` as JSX components.\n' +
- 'Please wrap angle brackets with backticks, e.g.: [component] Add `` support'
+ 'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' +
+ 'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' +
+ 'Please wrap these characters with backticks, e.g.: [component] Add `` support'
);
return;
}
diff --git a/.github/workflows/release-nightly.yml b/.github/workflows/release-nightly.yml
new file mode 100644
index 0000000000..cd3b7207b7
--- /dev/null
+++ b/.github/workflows/release-nightly.yml
@@ -0,0 +1,38 @@
+---
+name: Nightly Dev Release
+
+# Works out the dated dev tag and starts the release workflow with it, so that
+# the release run is named after the tag it builds. A workflow run name is
+# fixed when the run starts and cannot read a file or the current date.
+
+on:
+ schedule:
+ - cron: "0 2 * * *"
+
+permissions:
+ contents: read # actions/checkout to read the version from esphome/const.py
+
+jobs:
+ trigger:
+ name: Start release build
+ if: github.repository == 'esphome/esphome'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read # actions/checkout to read the version from esphome/const.py
+ actions: write # gh workflow run starts release.yml
+ steps:
+ - name: Check out the repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Start the release workflow
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ VERSION=$(sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p" esphome/const.py)
+ if [[ -z "$VERSION" ]]; then
+ echo "::error::Could not read __version__ from esphome/const.py"
+ exit 1
+ fi
+ TAG="${VERSION}$(date --utc '+%Y%m%d')"
+ echo "Starting release build for ${TAG}"
+ gh workflow run release.yml --ref "${GITHUB_REF_NAME}" --field tag="${TAG}"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index d07c8fe633..d0dee8165c 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,12 +1,23 @@
---
name: Publish Release
+# Releases (production and beta) are named after the version they publish.
+# Dev builds are named after the dated dev tag, which is passed in by the
+# nightly workflow because a run name cannot compute it itself.
+run-name: ${{ github.event.inputs.tag || github.event.release.tag_name || format('Manual build ({0})', github.ref_name) }}
+
on:
workflow_dispatch:
+ inputs:
+ tag:
+ description: >-
+ Tag to build. Only supported on dev, where the nightly workflow
+ uses it. Leave empty to build the version from esphome/const.py
+ with today's date appended.
+ required: false
+ default: ""
release:
types: [published]
- schedule:
- - cron: "0 2 * * *"
permissions:
contents: read # actions/checkout for all jobs; deploy jobs add their own scopes when they need to write
@@ -20,9 +31,11 @@ jobs:
branch_build: ${{ steps.tag.outputs.branch_build }}
deploy_env: ${{ steps.tag.outputs.deploy_env }}
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Get tag
id: tag
+ env:
+ INPUT_TAG: ${{ github.event.inputs.tag }}
# yamllint disable rule:line-length
run: |
if [[ "${{ github.event_name }}" = "release" ]]; then
@@ -34,12 +47,23 @@ jobs:
ENVIRONMENT="production"
fi
else
- TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
- today="$(date --utc '+%Y%m%d')"
- TAG="${TAG}${today}"
BRANCH=${GITHUB_REF#refs/heads/}
+ # The nightly workflow passes the finished tag so that the run name
+ # matches what is built. Without it, work it out here.
+ TAG="${INPUT_TAG}"
+ if [[ -n "$TAG" && "$BRANCH" != "dev" ]]; then
+ echo "::error::The tag input is only supported on dev. A build from ${BRANCH} has to use the tag worked out here, which carries the branch name, so that it cannot publish over the dev, beta, latest or stable images."
+ exit 1
+ fi
+ if [[ -z "$TAG" ]]; then
+ TAG=$(cat esphome/const.py | sed -n -E "s/^__version__\s+=\s+\"(.+)\"$/\1/p")
+ today="$(date --utc '+%Y%m%d')"
+ TAG="${TAG}${today}"
+ if [[ "$BRANCH" != "dev" ]]; then
+ TAG="${TAG}-${BRANCH}"
+ fi
+ fi
if [[ "$BRANCH" != "dev" ]]; then
- TAG="${TAG}-${BRANCH}"
BRANCH_BUILD="true"
ENVIRONMENT=""
else
@@ -60,9 +84,9 @@ jobs:
contents: read # actions/checkout to build the sdist/wheel
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
steps:
- - uses: actions/checkout@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 +94,7 @@ jobs:
pip3 install build
python3 -m build
- name: Publish
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
with:
skip-existing: true
@@ -92,22 +116,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@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to docker hub
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -168,7 +192,7 @@ jobs:
- ghcr
- dockerhub
steps:
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -178,17 +202,17 @@ jobs:
merge-multiple: true
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
+ uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr'
- uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
+ uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -212,74 +236,6 @@ jobs:
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
$(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *)
- deploy-ha-addon-repo:
- if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
- runs-on: ubuntu-latest
- needs:
- - init
- - deploy-manifest
- steps:
- - name: Generate a token
- id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
- with:
- client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
- private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
- owner: esphome
- repositories: home-assistant-addon
- permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
-
- - name: Trigger Workflow
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- github-token: ${{ steps.generate-token.outputs.token }}
- script: |
- let description = "ESPHome";
- if (context.eventName == "release") {
- description = ${{ toJSON(github.event.release.body) }};
- }
- github.rest.actions.createWorkflowDispatch({
- owner: "esphome",
- repo: "home-assistant-addon",
- workflow_id: "bump-version.yml",
- ref: "main",
- inputs: {
- version: "${{ needs.init.outputs.tag }}",
- content: description
- }
- })
-
- deploy-esphome-schema:
- if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
- runs-on: ubuntu-latest
- needs: [init]
- environment: ${{ needs.init.outputs.deploy_env }}
- steps:
- - name: Generate a token
- id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
- with:
- client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
- private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
- owner: esphome
- repositories: esphome-schema
- permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
-
- - name: Trigger Workflow
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
- with:
- github-token: ${{ steps.generate-token.outputs.token }}
- script: |
- github.rest.actions.createWorkflowDispatch({
- 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
@@ -289,7 +245,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -302,7 +258,7 @@ jobs:
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 2e57093bbb..aa31094f81 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -6,61 +6,46 @@ on:
- cron: "30 0 * * *"
workflow_dispatch:
-permissions:
- issues: write # actions/stale labels, comments on, and closes stale issues
- pull-requests: write # actions/stale labels, comments on, and closes stale pull requests
-
-concurrency:
- group: lock
+# The reusable workflow authenticates as the ESPHome GitHub App, so GITHUB_TOKEN
+# needs no permissions at all.
+permissions: {}
jobs:
stale:
if: github.repository_owner == 'esphome'
- runs-on: ubuntu-latest
- steps:
- - name: Stale
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- with:
- debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
- remove-stale-when-updated: true
- operations-per-run: 400
+ # No GITHUB_TOKEN permissions: the reusable workflow mints an ESPHome
+ # GitHub App token so the labels, comments and closures come from
+ # esphome[bot] instead of github-actions[bot].
+ uses: esphome/workflows/.github/workflows/stale.yml@a1c1485ab46ef41a84a6a9d8abd7fa4b7628fd70 # main
+ secrets:
+ ESPHOME_GITHUB_APP_PRIVATE_KEY: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+ with:
+ # Live only on dev: a workflow_dispatch from any other branch is a dry run
+ dry-run: ${{ github.ref != 'refs/heads/dev' }}
+ days-before-stale: 90
+ days-before-close: 7
+ stale-label: stale
+ exempt-label: not-stale
+ ignored-users: esphbot,codecov-commenter
+ stale-pr-message: >
+ There hasn't been any activity on this pull request recently. This
+ pull request has been automatically marked as stale because of that
+ and will be closed if no further activity occurs within 7 days.
- # The 90 day stale policy for PRs
- # - PRs
- # - No PRs marked as "not-stale"
- # - No Issues (see below)
- days-before-pr-stale: 90
- days-before-pr-close: 7
- stale-pr-label: "stale"
- exempt-pr-labels: "not-stale"
- stale-pr-message: >
- There hasn't been any activity on this pull request recently. This
- pull request has been automatically marked as stale because of that
- and will be closed if no further activity occurs within 7 days.
+ If you are the author of this PR, please leave a comment if you want
+ to keep it open. Also, please rebase your PR onto the latest dev
+ branch to ensure that it's up to date with the latest changes.
- If you are the author of this PR, please leave a comment if you want
- to keep it open. Also, please rebase your PR onto the latest dev
- branch to ensure that it's up to date with the latest changes.
+ Thank you for your contribution!
+ stale-issue-message: >
+ There hasn't been any activity on this issue recently. Due to the
+ high number of incoming GitHub notifications, we have to clean some
+ of the old issues, as many of them have already been resolved with
+ the latest updates.
- Thank you for your contribution!
+ Please make sure to update to the latest ESPHome version and
+ check if that solves the issue. Let us know if that works for you by
+ adding a comment 👍
- # The 90 day stale policy for Issues
- # - Issues
- # - No Issues marked as "not-stale"
- # - No PRs (see above)
- days-before-issue-stale: 90
- days-before-issue-close: 7
- stale-issue-label: "stale"
- exempt-issue-labels: "not-stale"
- stale-issue-message: >
- There hasn't been any activity on this issue recently. Due to the
- high number of incoming GitHub notifications, we have to clean some
- of the old issues, as many of them have already been resolved with
- the latest updates.
-
- Please make sure to update to the latest ESPHome version and
- check if that solves the issue. Let us know if that works for you by
- adding a comment 👍
-
- This issue has now been marked as stale and will be closed if no
- further activity occurs. Thank you for your contributions.
+ This issue has now been marked as stale and will be closed if no
+ further activity occurs. Thank you for your contributions.
diff --git a/.github/workflows/status-check-labels.yml b/.github/workflows/status-check-labels.yml
index d27cc0cbec..72987c25b1 100644
--- a/.github/workflows/status-check-labels.yml
+++ b/.github/workflows/status-check-labels.yml
@@ -5,7 +5,7 @@ on:
types: [opened, reopened, labeled, unlabeled, synchronize]
permissions:
- pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, merge-after-release, chained-pr)
+ pull-requests: read # issues.listLabelsOnIssue to detect blocking labels (needs-docs, needs-developer-docs, merge-after-release, chained-pr)
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
@@ -20,7 +20,7 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
- const blockingLabels = ['needs-docs', 'merge-after-release', 'chained-pr'];
+ const blockingLabels = ['needs-docs', 'needs-developer-docs', 'merge-after-release', 'chained-pr'];
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner: context.repo.owner,
repo: context.repo.repo,
diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml
index c6c829fbb4..9100064176 100644
--- a/.github/workflows/sync-device-classes.yml
+++ b/.github/workflows/sync-device-classes.yml
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -28,39 +28,76 @@ jobs:
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.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 ``prek`` /
+ # ``script/run-in-env.py`` steps find the deps without a
+ # ``uv run`` prefix.
+ uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d # v10.0.1
+ 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
- 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 prek auto-fixes
+ # First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
+ # files. prek exits non-zero whenever a hook touches anything,
+ # which would otherwise abort the workflow before the auto-fixes
+ # can flow into the sync PR.
+ #
+ # PREK_SKIP:
+ # - no-commit-to-branch is a local guard against committing on
+ # dev/release/beta; CI runs on dev by definition, and
+ # peter-evans/create-pull-request creates the branch itself.
+ # - 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:
+ PREK_SKIP: pylint,no-commit-to-branch
+ run: python script/run-in-env.py prek run --all-files || true
+
+ - name: Verify prek clean
+ # Second pass: re-run all hooks against the now-fixed tree.
+ # Auto-fixers exit 0 (nothing to change); any remaining failure
+ # from a check-only hook (flake8 / yamllint / ci-custom) is a
+ # real issue and fails the workflow loudly. Same PREK_SKIP list as
+ # above for the same reasons.
+ env:
+ PREK_SKIP: pylint,no-commit-to-branch
+ run: python script/run-in-env.py prek run --all-files
- name: Commit changes
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
with:
commit-message: "Synchronise Device Classes from Home Assistant"
- committer: esphomebot
- author: esphomebot
+ committer: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
+ author: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com>
branch: sync/device-classes
delete-branch: true
title: "Synchronise Device Classes from Home Assistant"
diff --git a/.gitignore b/.gitignore
index 4a4a88fd48..fdb75824fb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -133,6 +133,8 @@ CTestTestfile.cmake
.gcc-flags.json
config/
+# Test fixture config/ directories are tracked (the rule above is the dashboard dir)
+!tests/component_tests/**/config/
tests/build/
tests/.esphome/
/.temp-clang-tidy.cpp
@@ -141,6 +143,7 @@ tests/.esphome/
sdkconfig.*
!sdkconfig.defaults
+!sdkconfig.defaults.*
.tests/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index da5fb94d5e..0ea799aa4d 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.12
+ rev: v0.16.3
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:
@@ -59,13 +59,6 @@ repos:
language: system
types: [python]
files: ^esphome/.+\.py$
- - 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: []
- id: ci-custom
name: ci-custom
entry: python script/run-in-env.py script/ci-custom.py
diff --git a/AGENTS.md b/AGENTS.md
index 2139a2b796..f006ee6087 100644
--- a/AGENTS.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).
@@ -58,6 +57,25 @@ This document provides essential context for AI models interacting with this pro
- Function-local constants: `lower_snake_case`
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations
+ - Enumerator names: prefix every value of an `enum class` with the enum name converted to
+ `UPPER_SNAKE_CASE` (e.g. `UARTFlushResult::UART_FLUSH_RESULT_SUCCESS`). Never use bare
+ names like `SUCCESS`, `FAILURE`, `OK`, or `FAIL`: platform SDK headers define macros with
+ these common names (for example the Realtek SDKs used by LibreTiny define
+ `#define SUCCESS 0` in `basic_types.h`), and the preprocessor replaces the enumerator
+ before the compiler sees it, breaking the build and clang-tidy on those platforms.
+
+* **Python Idioms:**
+ * **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
+ ```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_`.
@@ -179,11 +197,14 @@ This document provides essential context for AI models interacting with this pro
my_component_ns = cg.esphome_ns.namespace("my_component")
MyComponent = my_component_ns.class_("MyComponent", cg.Component)
- CONFIG_SCHEMA = cv.Schema({
- cv.GenerateID(): cv.declare_id(MyComponent),
- cv.Required(CONF_KEY): cv.string,
- cv.Optional(CONF_PARAM, default=42): cv.int_,
- }).extend(cv.COMPONENT_SCHEMA)
+ CONFIG_SCHEMA = cv.Schema(
+ {
+ cv.GenerateID(): cv.declare_id(MyComponent),
+ cv.Required(CONF_KEY): cv.string,
+ cv.Optional(CONF_PARAM, default=42): cv.int_,
+ }
+ ).extend(cv.COMPONENT_SCHEMA)
+
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
@@ -217,7 +238,12 @@ This document provides essential context for AI models interacting with this pro
- **Sensor:**
```python
from esphome.components import sensor
- CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(cv.polling_component_schema("60s"))
+
+ CONFIG_SCHEMA = sensor.sensor_schema(MySensor).extend(
+ cv.polling_component_schema("60s")
+ )
+
+
async def to_code(config):
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
@@ -226,7 +252,10 @@ This document provides essential context for AI models interacting with this pro
- **Binary Sensor:**
```python
from esphome.components import binary_sensor
- CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({ ... })
+
+ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend({...})
+
+
async def to_code(config):
var = await binary_sensor.new_binary_sensor(config)
```
@@ -234,7 +263,10 @@ This document provides essential context for AI models interacting with this pro
- **Switch:**
```python
from esphome.components import switch
- CONFIG_SCHEMA = switch.switch_schema().extend({ ... })
+
+ CONFIG_SCHEMA = switch.switch_schema().extend({...})
+
+
async def to_code(config):
var = await switch.new_switch(config)
```
@@ -251,10 +283,13 @@ This document provides essential context for AI models interacting with this pro
```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)
+ 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])
@@ -304,11 +339,14 @@ This document provides essential context for AI models interacting with this pro
```python
TurnOnTrigger = my_ns.class_("TurnOnTrigger", automation.Trigger.template())
- CONFIG_SCHEMA = cv.Schema({
- cv.Optional(CONF_ON_TURN_ON): automation.validate_automation(
- {cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(TurnOnTrigger)}
- ),
- })
+ 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, []):
@@ -356,7 +394,10 @@ This document provides essential context for AI models interacting with this pro
```
Register with `@automation.register_condition("my_component.is_active", MyCondition, schema)`.
+* **Type Hints:** Type-hint all function signatures, including test functions and config validators (e.g. `def validate_x(config: ConfigType) -> ConfigType:`, `def test_x() -> None:`). Import `ConfigType` from `esphome.types`.
+
* **Configuration Validation:**
+ * **Reuse existing validators:** Before writing a custom validator, check for an existing one in `config_validation.py` and compose it in `cv.All(...)` rather than duplicating logic across components. For example, rename a config key with `cv.rename_key(CONF_OLD, CONF_NEW, removed_in="2026.6.0")`, and reject mutually-exclusive keys with `cv.has_at_most_one_key(...)` / `cv.has_exactly_one_key(...)`. See how `api` composes `cv.has_exactly_one_key` + `cv.rename_key`.
* **Common Validators:** `cv.int_`, `cv.float_`, `cv.string`, `cv.boolean`, `cv.int_range(min=0, max=100)`, `cv.positive_int`, `cv.percentage`.
* **Complex Validation:** `cv.All(cv.string, cv.Length(min=1, max=50))`, `cv.Any(cv.int_, cv.string)`.
* **Platform-Specific:** `cv.only_on(["esp32", "esp8266"])`, `esp32.only_on_variant(...)`, `cv.only_on_esp32`, `cv.only_on_esp8266`, `cv.only_on_rp2040`.
@@ -369,6 +410,7 @@ This document provides essential context for AI models interacting with this pro
.extend(i2c.i2c_device_schema(0x48))
.extend(spi.spi_device_schema(cs_pin_required=True))
```
+ * **Constants:** `esphome/const.py` is frozen — do not add new `CONF_` constants there. Define a component-local constant in the component's own `.py` (as with `CONF_PARAM` above); for a constant shared by multiple components, add it to `esphome/components/const/__init__.py`. CI (`lint_constants_usage`) fails if the same constant is defined in three or more component files. Constants used in core files (i.e. those not under `esphome/components`) may be added to `esphome/const.py` but will require adjustment to the CI validation check.
## 5. Key Files & Entrypoints
@@ -376,7 +418,7 @@ This document provides essential context for AI models interacting with this pro
* **Configuration:**
* `pyproject.toml`: Defines the Python project metadata and dependencies.
* `platformio.ini`: Configures the PlatformIO build environments for different microcontrollers.
- * `.pre-commit-config.yaml`: Configures the pre-commit hooks for linting and formatting.
+ * `.pre-commit-config.yaml`: Configures the lint and format hooks, run by `prek`.
* **CI/CD Pipeline:** Defined in `.github/workflows`.
* **Static Analysis & Development:**
* `esphome/core/defines.h`: A comprehensive header file containing all `#define` directives that can be added by components using `cg.add_define()` in Python. This file is used exclusively for development, static analysis tools, and CI testing - it is not used during runtime compilation. When developing components that add new defines, they must be added to this file to ensure proper IDE support and static analysis coverage. The file includes feature flags, build configurations, and platform-specific defines that help static analyzers understand the complete codebase without needing to compile for specific platforms.
@@ -384,7 +426,7 @@ This document provides essential context for AI models interacting with this pro
## 6. Development & Testing Workflow
* **Local Development Environment:** Use the provided Docker container or create a Python virtual environment and install dependencies from `requirements_dev.txt`.
-* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py pre-commit run`.
+* **Running Commands:** Use the `script/run-in-env.py` script to execute commands within the project's virtual environment. For example, to run the linter: `python3 script/run-in-env.py prek run`.
* **Testing:**
* **Python:** Run unit tests with `pytest`.
* **C++:** Use `clang-tidy` for static analysis.
@@ -415,13 +457,14 @@ This document provides essential context for AI models interacting with this pro
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
- * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
+ * **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`.
+
+ All includes in test files must go through dict-style `packages:` so that batch grouping works correctly — the grouping scripts only understand dict-style packages. Never use list-style packages (`packages: [- !include ...]`) or top-level merge keys (`<<: !include common.yaml`). Bus packages are keyed by the bus name; the component's `common.yaml` is keyed by the component name (e.g. `cst328: !include common.yaml`):
```yaml
- # test.esp32-idf.yaml — use packages for buses
+ # test.esp32-idf.yaml — everything included via named packages
packages:
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
-
- <<: !include common.yaml
+ my_component: !include common.yaml
```
```yaml
# common.yaml — component config only, NO bus definitions
@@ -443,7 +486,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`.
@@ -457,12 +499,12 @@ This document provides essential context for AI models interacting with this pro
1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b dev` to ensure you're branching from `dev`, not the currently checked out branch).
2. **Make Changes:** Adhere to all coding conventions and patterns.
3. **Test:** Create component tests for all supported platforms and run the full test suite locally.
- 4. **Lint:** Run `pre-commit` to ensure code is compliant.
+ 4. **Lint:** Run `prek` to ensure code is compliant.
5. **Commit:** Commit your changes. There is no strict format for commit messages.
- 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
+ 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title must start with a `[tag]` prefix. For component work, use the component name (e.g., `[display] Fix bug`, `[abc123] Add new component`); for changes to shared/core code that isn't tied to a single component, use `[core]` (e.g., `[core] Add validator`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template.
* **Documentation Contributions:**
- * Documentation is hosted in the separate `esphome/esphome-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.
@@ -605,6 +647,7 @@ This document provides essential context for AI models interacting with this pro
_component_state = []
_use_feature = None
+
def enable_feature():
global _use_feature
_use_feature = True
@@ -624,20 +667,24 @@ This document provides essential context for AI models interacting with this pro
DOMAIN = "my_component"
+
@dataclass
class MyComponentData:
feature_enabled: bool = False
item_count: int = 0
items: list[str] = field(default_factory=list)
+
def _get_data() -> MyComponentData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = MyComponentData()
return CORE.data[DOMAIN]
+
def request_feature() -> None:
_get_data().feature_enabled = True
+
def add_item(item: str) -> None:
_get_data().items.append(item)
```
@@ -645,7 +692,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
@@ -681,7 +728,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++):**
@@ -692,9 +739,37 @@ This document provides essential context for AI models interacting with this pro
```
* **Deprecation Pattern (Python):**
+ For a renamed config key, use the shared `cv.rename_key` validator with `removed_in` (and `component` for context) — it warns and auto-migrates:
+ ```python
+ CONFIG_SCHEMA = cv.All(
+ cv.rename_key(
+ CONF_OLD_KEY, CONF_NEW_KEY, removed_in="2026.6.0", component="my_component"
+ ),
+ cv.Schema({ ... }),
+ )
+ ```
+ For other deprecations, warn manually during validation:
```python
# Remove before 2026.6.0
if CONF_OLD_KEY in config:
- _LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0")
+ _LOGGER.warning(
+ f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0"
+ )
config[CONF_NEW_KEY] = config.pop(CONF_OLD_KEY) # Auto-migrate
```
+## 9. English Language
+
+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.
+
+## 10. Code Comments
+
+Code comments on individual lines should be used only where necessary to flag issues that may not be obvious
+on a simple reading of the code. Keep them short (e.g. 1 or 2 lines).
+
+Function and method comment blocks may include more detail as required to make
+calling contracts clear and document parameter usage, but should still be kept concise.
+
+Avoid redundancy and repetition; comments should never simply restate what the code already says.
diff --git a/CODEOWNERS b/CODEOWNERS
index f8cdfdc6c6..e1287ca275 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
@@ -70,12 +69,16 @@ esphome/components/bh1750/* @OttoWinter
esphome/components/bh1900nux/* @B48D81EFCC
esphome/components/binary_sensor/* @esphome/core
esphome/components/bk72xx/* @kuba2k2
+esphome/components/bk72xx_ble/* @Bl00d-B0b
+esphome/components/bk72xx_ble_tracker/* @Bl00d-B0b
esphome/components/bl0906/* @athom-tech @jesserockz @tarontop
esphome/components/bl0939/* @ziceva
esphome/components/bl0940/* @dan-s-github @tobias-
esphome/components/bl0942/* @dbuezas @dwmw2
esphome/components/ble_client/* @buxtronix @clydebarrow
+esphome/components/ble_device_base/* @Bl00d-B0b
esphome/components/ble_nus/* @tomaszduda23
+esphome/components/bluetooth_connection/* @bdraco @jesserockz
esphome/components/bluetooth_proxy/* @bdraco @jesserockz
esphome/components/bm8563/* @abmantis
esphome/components/bme280_base/* @esphome/core
@@ -84,6 +87,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
@@ -122,7 +126,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
@@ -139,10 +145,11 @@ esphome/components/dfplayer/* @glmnet
esphome/components/dfrobot_sen0395/* @niklasweber
esphome/components/dht/* @OttoWinter
esphome/components/display_menu_base/* @numo68
-esphome/components/dlms_meter/* @SimonFischer04
+esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz
esphome/components/dps310/* @kbx81
esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds2484/* @mrk-its
+esphome/components/ds248x/* @tomwellnitz
esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duty_time/* @dudanov
esphome/components/ee895/* @Stock-M
@@ -185,6 +192,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
@@ -206,6 +214,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
@@ -229,6 +238,7 @@ esphome/components/hlw8032/* @rici4kubicek
esphome/components/hm3301/* @freekode
esphome/components/hmac_md5/* @dwmw2
esphome/components/hmac_sha256/* @dwmw2
+esphome/components/hoermann_hcp/* @zweckj
esphome/components/homeassistant/* @esphome/core @OttoWinter
esphome/components/homeassistant/number/* @landonr
esphome/components/homeassistant/switch/* @Links2004
@@ -266,6 +276,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
@@ -279,6 +290,7 @@ esphome/components/ld2412/* @Rihan9
esphome/components/ld2420/* @descipher
esphome/components/ld2450/* @hareeshmu
esphome/components/ld24xx/* @kbx81
+esphome/components/ld6002b/* @hepter
esphome/components/ledc/* @OttoWinter
esphome/components/libretiny/* @kuba2k2
esphome/components/libretiny_pwm/* @kuba2k2
@@ -286,11 +298,14 @@ esphome/components/light/* @esphome/core
esphome/components/lightwaverf/* @max246
esphome/components/lilygo_t5_47/touchscreen/* @jesserockz
esphome/components/lm75b/* @beormund
+esphome/components/ln882h_ble/* @Bl00d-B0b
+esphome/components/ln882h_ble_tracker/* @Bl00d-B0b
esphome/components/ln882x/* @lamauny
esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core
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
@@ -339,6 +354,7 @@ esphome/components/mlx90393/* @functionpointer
esphome/components/mlx90614/* @jesserockz
esphome/components/mmc5603/* @benhoff
esphome/components/mmc5983/* @agoode
+esphome/components/modbus_client/* @exciton
esphome/components/modbus_controller/* @martgras
esphome/components/modbus_controller/binary_sensor/* @martgras
esphome/components/modbus_controller/number/* @martgras
@@ -351,6 +367,7 @@ 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
@@ -364,6 +381,7 @@ esphome/components/nextion/switch/* @senexcrenshaw
esphome/components/nextion/text_sensor/* @senexcrenshaw
esphome/components/nfc/* @jesserockz @kbx81
esphome/components/noblex/* @AGalfra
+esphome/components/noise/* @esphome/core
esphome/components/npi19/* @bakerkj
esphome/components/nrf52/* @tomaszduda23
esphome/components/number/* @esphome/core
@@ -379,9 +397,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
@@ -397,10 +417,12 @@ 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
@@ -417,10 +439,12 @@ esphome/components/restart/* @esphome/core
esphome/components/rf_bridge/* @jesserockz
esphome/components/rgbct/* @jesserockz
esphome/components/ring_buffer/* @kahrendt
-esphome/components/rp2040/* @jesserockz
+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
+esphome/components/rp2_ble_tracker/* @bdraco
esphome/components/rpi_dpi_rgb/* @clydebarrow
esphome/components/rtl87xx/* @kuba2k2
esphome/components/rtttl/* @glmnet @ximex
@@ -441,8 +465,9 @@ 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/image/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sendspin/sensor/* @kahrendt
@@ -451,6 +476,7 @@ esphome/components/sensirion_common/* @martgras
esphome/components/sensor/* @esphome/core
esphome/components/serial_proxy/* @kbx81
esphome/components/sfa30/* @ghsensdev
+esphome/components/sfa40/* @NoQuarrel
esphome/components/sgp40/* @SenexCrenshaw
esphome/components/sgp4x/* @martgras @SenexCrenshaw
esphome/components/sha256/* @esphome/core
@@ -493,6 +519,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
@@ -550,6 +577,7 @@ esphome/components/tuya/select/* @bearpawmaxim
esphome/components/tuya/sensor/* @jesserockz
esphome/components/tuya/switch/* @jesserockz
esphome/components/tuya/text_sensor/* @dentra
+esphome/components/tuya/water_heater/* @iago-veiga
esphome/components/uart/* @esphome/core
esphome/components/uart/button/* @ssieb
esphome/components/uart/event/* @eoasmxd
@@ -557,6 +585,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
@@ -573,6 +602,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
@@ -594,6 +624,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
@@ -607,6 +638,7 @@ esphome/components/xpt2046/touchscreen/* @nielsnl68 @numo68
esphome/components/xxtea/* @clydebarrow
esphome/components/zephyr/* @tomaszduda23
esphome/components/zephyr_mcumgr/ota/* @tomaszduda23
+esphome/components/zephyr_pwm/* @wiomoc
esphome/components/zhlt01/* @cfeenstra1024
esphome/components/zigbee/* @luar123 @tomaszduda23
esphome/components/zio_ultrasonic/* @kahrendt
diff --git a/Doxyfile b/Doxyfile
index 7fce941c9b..8f6048b4d8 100644
--- a/Doxyfile
+++ b/Doxyfile
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
-PROJECT_NUMBER = 2026.5.0-dev
+PROJECT_NUMBER = 2026.9.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/MANIFEST.in b/MANIFEST.in
index e426627e8d..1626261fb6 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -6,3 +6,4 @@ recursive-include esphome *.cpp *.h *.tcc *.c
recursive-include esphome *.py.script
recursive-include esphome *.jinja
recursive-include esphome LICENSE.txt
+recursive-include esphome requirements.txt
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
new file mode 100644
index 0000000000..5816f38176
--- /dev/null
+++ b/THREAT_MODEL.md
@@ -0,0 +1,148 @@
+# ESPHome Threat Model
+
+This document defines the trust boundary for the **ESPHome** repository — the
+Python compiler/CLI and the device firmware it generates — so that real security
+bugs can be told apart from defense-in-depth improvements. It gives contributors,
+reviewers, and security researchers a clear answer to one question:
+**does this issue let an _unauthenticated_ attacker do something they shouldn't?**
+
+Related documents:
+
+- Deployment guidance for operators:
+ https://esphome.io/guides/security_best_practices/
+- The **Device Builder dashboard** (the web UI, its authentication, ingress,
+ Origin/Host gates, and peer-link pairing) lives in a separate repository and
+ has its own threat model. If your report concerns any of that, please read and
+ report there instead:
+ https://github.com/esphome/device-builder/blob/main/docs/THREAT_MODEL.md
+
+## The trust boundary
+
+For this repository there are two trusted inputs by design:
+
+1. **The configuration.** Anyone who can supply or edit a YAML config is trusted
+ (see below).
+2. **Authenticated peers of a running device** — clients holding the device's
+ API encryption key / password, OTA password, or web server credentials.
+
+The security boundary is therefore **unauthenticated network traffic vs. those
+trusted inputs.** A bug that lets an unauthenticated attacker cross it is a
+security bug.
+
+## Config authors are host-equivalent by design
+
+Anyone who can supply or edit a configuration is **trusted with full code
+execution on the host that runs `esphome`**, on purpose. This is what the product
+does, not a flaw. A config author can already, through fully supported features:
+
+- Run arbitrary **Python** at validation/compile time via `external_components:`
+ (and other component-import mechanisms) — ESPHome imports those packages as
+ ordinary Python.
+- Run arbitrary **shell** commands through the compile/validate/flash toolchain
+ that ESPHome invokes as subprocesses.
+- Read and write arbitrary files reachable by the process (e.g. via `!include`,
+ `packages:`, `dashboard_import:`, and generated build output).
+
+Because of this, a malicious config author is equivalent to shell access on the
+host running the build.
+
+## What is *not* a security vulnerability
+
+If exploiting an issue requires the ability to supply or edit configuration, it
+is **not** a vulnerability in ESPHome, because that ability already grants host
+code execution. This explicitly includes, among others:
+
+- Template / expression injection in substitutions or any YAML string value
+ (e.g. Jinja `${...}` evaluation reaching Python internals). This grants no
+ capability a config author lacks.
+- `!include` / `packages:` / `dashboard_import:` reading or fetching content
+ from surprising or remote locations.
+- The validator or compiler crashing or behaving unexpectedly on adversarial
+ YAML.
+- ESPHome running as root in the official container — that is the documented
+ deployment posture, reachable by the same caller through the features above.
+
+These do not warrant a CVE or coordinated disclosure. Hardening in these areas
+(for example, sandboxing template evaluation as least-surprise defense-in-depth)
+is welcome as a normal enhancement PR, framed as cleanliness rather than a
+security fix — not as a vulnerability remediation.
+
+## What we do defend
+
+These *are* security bugs in this repo, and we want to hear about them privately:
+
+- Memory-safety or protocol bugs in the generated **device firmware** that are
+ remotely triggerable over the network (native API, web server, OTA, BLE,
+ captive portal, etc.) **without** valid credentials.
+- Authentication or encryption bypass on the device — reaching API calls, OTA
+ updates, or the web server without the configured key/password.
+- Flaws that weaken the device's API encryption (Noise), OTA, or web server auth
+ below their documented guarantees.
+
+## The web server is an open HTTP API by design
+
+The `web_server` component exposes a plain HTTP interface for viewing and
+controlling entities, and, when the `web_server` OTA platform is enabled, for
+uploading firmware at `/update`. Its only access controls are the optional
+`web_server` `auth:` credentials and the network the device sits on.
+
+When `auth:` is not configured, every endpoint is reachable by any client that
+can reach the device. This is intentional; enabling `web_server` without `auth:`
+is choosing an open control surface, in the same way that running native OTA
+without a password leaves OTA open. The API is documented and is meant to be
+called by other devices, scripts, and pages.
+
+As defense-in-depth, the web server checks the `Origin` header on browser requests
+to its entity control and state endpoints: a request whose `Origin` does not match
+the address the device is served on is rejected, and the `allowed_origins` option
+widens that list. This blocks the common "confused deputy" (CSRF) case where a page
+the operator visits drives the device through their browser. It is **not** an
+authentication boundary: it only constrains browsers. Any client that omits the
+`Origin` header — `curl`, scripts, or other non-browser callers on the same
+network — reaches every endpoint exactly as before. The check also does not cover
+the web OTA `/update` endpoint. The device performs no CSRF-token or `Referer`
+validation. The following are therefore **not** vulnerabilities in this repository:
+
+- Requests without an `Origin` header (for example `curl`) reaching the control
+ endpoints, whether or not `web_server` `auth:` is set.
+- Requests from an origin the operator added to `allowed_origins`.
+- Cross-origin or CSRF firmware upload through the web OTA endpoint (`/update`) when
+ web OTA is enabled without `web_server` `auth:`. The `/update` endpoint is not
+ covered by the `Origin` check; this is the same exposure as running OTA without a
+ password.
+
+The supported defenses are `web_server` `auth:`, protecting OTA (a web password or
+a native OTA password), and keeping devices on a trusted, segmented network. See
+the security best practices guide linked above.
+
+What remains in scope is bypassing `web_server` `auth:` when it *is* configured,
+and any memory-safety or protocol bug in the server reachable without credentials.
+
+This section documents the current design and scope; it is not a judgment that the
+design is optimal or that it will not change.
+
+## Explicitly out of scope
+
+- Local attackers who already have shell access on the host that runs `esphome`.
+- Supply-chain attacks against ESPHome or its dependencies.
+- Operator-supplied hostile YAML (covered above — config authoring is trusted).
+- Attacks that require an already-authenticated device peer (someone who already
+ holds the API key / OTA / web credentials).
+- Access to the device web server or its web OTA endpoint by non-browser clients
+ (those that send no `Origin` header). The web server is an open HTTP API by
+ design (see above); browser cross-origin requests are blocked by default, but the
+ real controls are `web_server` `auth:` and network isolation.
+- Anything in the dashboard / device-builder — report that in its own repository
+ (linked at the top).
+- Deployments where the operator removed protections or exposed credentials. See
+ the security best practices guide:
+ https://esphome.io/guides/security_best_practices/
+
+## Reporting a vulnerability
+
+If you believe you've found an issue that crosses the unauthenticated boundary
+above, please report it privately via GitHub Security Advisories rather than a
+public issue. For issues that require config-write access, please review this
+document first — they are very likely out of scope by design. For dashboard /
+device-builder issues, report against that repository and consult its threat
+model (linked at the top).
diff --git a/codecov.yml b/codecov.yml
new file mode 100644
index 0000000000..f8afbbde04
--- /dev/null
+++ b/codecov.yml
@@ -0,0 +1,18 @@
+coverage:
+ status:
+ patch:
+ default:
+ target: 100%
+ threshold: 0%
+ project:
+ default:
+ informational: true
+
+ignore:
+ - "esphome/components/**/*"
+ - "esphome/analyze_memory/**/*"
+ - "tests/integration/**/*"
+
+comment:
+ layout: "reach, diff, flags, files"
+ require_changes: true
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 540d28be7f..0da8048c57 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.13.1
+
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/cont-init.d/40-device-builder.sh b/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh
deleted file mode 100755
index b990469762..0000000000
--- a/docker/ha-addon-rootfs/etc/cont-init.d/40-device-builder.sh
+++ /dev/null
@@ -1,22 +0,0 @@
-#!/usr/bin/with-contenv bashio
-# ==============================================================================
-# Installs the latest prerelease of esphome-device-builder when the
-# `use_new_device_builder` config option is enabled.
-# This is a temporary install-on-boot step until esphome-device-builder
-# becomes a direct dependency of esphome.
-# ==============================================================================
-
-if ! bashio::config.true 'use_new_device_builder'; then
- exit 0
-fi
-
-bashio::log.info "Installing latest prerelease of esphome-device-builder..."
-if command -v uv > /dev/null; then
- uv pip install --system --no-cache-dir --prerelease=allow --upgrade \
- esphome-device-builder ||
- bashio::exit.nok "Failed installing esphome-device-builder."
-else
- pip install --no-cache-dir --pre --upgrade esphome-device-builder ||
- bashio::exit.nok "Failed installing esphome-device-builder."
-fi
-bashio::log.info "Installed esphome-device-builder."
diff --git a/docker/ha-addon-rootfs/etc/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 bb5f52e10c..0000000000
--- a/docker/ha-addon-rootfs/etc/s6-overlay/s6-rc.d/nginx/run
+++ /dev/null
@@ -1,23 +0,0 @@
-#!/command/with-contenv bashio
-# shellcheck shell=bash
-# ==============================================================================
-# Community Hass.io Add-ons: ESPHome
-# Runs the NGINX proxy
-# ==============================================================================
-
-# The new device builder handles HA ingress itself, so nginx is bypassed.
-# Block the longrun forever so s6 keeps the dependency satisfied and does
-# not respawn it.
-if bashio::config.true 'use_new_device_builder'; then
- bashio::log.info "NGINX bypassed: new device builder serves ingress directly."
- exec sleep infinity
-fi
-
-bashio::log.info "Waiting for ESPHome dashboard to come up..."
-
-while [[ ! -S /var/run/esphome.sock ]]; do
- 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 bca8672917..632d2ba3d0 100644
--- a/esphome/__main__.py
+++ b/esphome/__main__.py
@@ -2,39 +2,33 @@
import argparse
from collections.abc import Callable
from contextlib import suppress
-from datetime import datetime
import functools
-import getpass
import importlib
import logging
import os
from pathlib import Path
import re
-import shutil
-import subprocess
import sys
import time
-from typing import Protocol
-
-import argcomplete
+from typing import TYPE_CHECKING, Protocol
# Note: Do not import modules from esphome.components here, as this would
# cause them to be loaded before external components are processed, resulting
# in the built-in version being used instead of the external component one.
-from esphome import const
-import esphome.codegen as cg
-from esphome.config import iter_component_configs, read_config, strip_default_ids
+from esphome import const, platform_hooks
from esphome.const import (
ALLOWED_NAME_CHARS,
ARGUMENT_HELP_DEVICE,
+ BUNDLE_EXTENSION,
CONF_API,
- CONF_AUTH,
CONF_BAUD_RATE,
CONF_BROKER,
CONF_DEASSERT_RTS_DTR,
CONF_DISABLED,
+ CONF_DISCOVER_IP,
CONF_ESPHOME,
CONF_LEVEL,
+ CONF_LOG,
CONF_LOG_TOPIC,
CONF_LOGGER,
CONF_MDNS,
@@ -48,14 +42,12 @@ from esphome.const import (
CONF_PORT,
CONF_SUBSTITUTIONS,
CONF_TOPIC,
- CONF_USERNAME,
+ CONF_VERSION,
CONF_WEB_SERVER,
+ CONF_WIFI,
ENV_NOGITIGNORE,
- KEY_CORE,
- KEY_TARGET_PLATFORM,
- PLATFORM_ESP32,
- PLATFORM_ESP8266,
- PLATFORM_RP2040,
+ KEY_ESP32,
+ KEY_VARIANT,
SECRETS_FILES,
Toolchain,
)
@@ -63,6 +55,7 @@ from esphome.core import CORE, EsphomeError, coroutine
from esphome.enum import StrEnum
from esphome.helpers import get_bool_env, indent, is_ip_address
from esphome.log import AnsiFore, color, setup_log
+from esphome.stacktrace import LogLineProcessor
from esphome.types import ConfigType
from esphome.upload_targets import PortType, get_port_type
from esphome.util import (
@@ -78,6 +71,9 @@ from esphome.util import (
safe_print,
)
+if TYPE_CHECKING:
+ import threading
+
# 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
@@ -229,8 +225,9 @@ def _discover_mac_suffix_devices() -> list[str] | None:
Returns:
- ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
- mDNS disabled, or ``CORE.address`` is already an IP). Callers should
- then fall back to whatever default OTA address they normally use.
+ 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.
@@ -240,7 +237,7 @@ def _discover_mac_suffix_devices() -> list[str] | None:
``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_non_ip_address()):
+ if not (has_name_add_mac_suffix() and has_mdns() and has_mdns_address()):
return None
from esphome.zeroconf import discover_mdns_devices
@@ -267,6 +264,36 @@ def _ota_hostnames_for_default(purpose: Purpose) -> list[str]:
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. Add an 'api:' component, enable MQTT logging, add a "
+ "'web_server:' component, or view logs over USB."
+ )
+ if purpose == Purpose.UPLOADING and not has_ota():
+ return (
+ "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,
@@ -290,9 +317,12 @@ def choose_upload_log_host(
]
resolved.append(choose_prompt(options, purpose=purpose))
elif device == "OTA":
+ # Logs can stream over a network transport via the native API
+ # or the web_server HTTP SSE feed.
+ network_logging = has_api() or has_web_server_logging()
# ensure IP adresses are used first
if is_ip_address(CORE.address) and (
- (purpose == Purpose.LOGGING and has_api())
+ (purpose == Purpose.LOGGING and network_logging)
or (purpose == Purpose.UPLOADING and has_ota())
):
resolved.extend(_resolve_with_cache(CORE.address, purpose))
@@ -304,7 +334,11 @@ def choose_upload_log_host(
if has_mqtt_logging():
resolved.append("MQTT")
- if has_api() and has_non_ip_address() and has_resolvable_address():
+ if (
+ network_logging
+ and has_non_ip_address()
+ and has_resolvable_address()
+ ):
resolved.extend(_ota_hostnames_for_default(purpose))
elif purpose == Purpose.UPLOADING:
@@ -316,14 +350,7 @@ def choose_upload_log_host(
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
@@ -335,7 +362,7 @@ def choose_upload_log_host(
bootsel_permission_error = False
if (
purpose == Purpose.UPLOADING
- and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
+ and CORE.is_rp2
and (picotool := _find_picotool()) is not None
):
bootsel = detect_rp2040_bootsel(picotool)
@@ -373,7 +400,7 @@ def choose_upload_log_host(
mqtt_config = CORE.config[CONF_MQTT]
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
- if has_api():
+ if has_api() or has_web_server_logging():
add_ota_options()
elif purpose == Purpose.UPLOADING and has_ota():
@@ -382,7 +409,7 @@ def choose_upload_log_host(
# 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:
@@ -466,10 +493,23 @@ def has_web_server_ota() -> bool:
)
+def has_web_server_logging() -> bool:
+ """Check if logs can be streamed over the web_server HTTP SSE endpoint.
+
+ The ``web_server`` component exposes a ``/events`` Server-Sent Events
+ stream that carries ``event: log`` frames. This requires version 2+ (the
+ v1 UI has no ``/events`` endpoint) and the ``log`` option enabled (default).
+ """
+ web_conf = CORE.config.get(CONF_WEB_SERVER)
+ if web_conf is None:
+ return False
+ if web_conf.get(CONF_VERSION, 2) == 1:
+ return False
+ return web_conf.get(CONF_LOG, True)
+
+
def has_mqtt_ip_lookup() -> bool:
"""Check if MQTT is available and IP lookup is supported."""
- from esphome.components.mqtt import CONF_DISCOVER_IP
-
if CONF_MQTT not in CORE.config:
return False
# Default Enabled
@@ -484,17 +524,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:
@@ -503,11 +548,17 @@ 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 has_name_add_mac_suffix() -> bool:
@@ -519,11 +570,48 @@ def has_name_add_mac_suffix() -> bool:
def mqtt_get_ip(
- config: ConfigType, username: str, password: str, client_id: str
+ config: ConfigType,
+ username: str,
+ password: str,
+ client_id: str,
+ stop_event: "threading.Event | None" = None,
) -> list[str]:
from esphome import mqtt
- return mqtt.get_esphome_device_ip(config, username, password, client_id)
+ return mqtt.get_esphome_device_ip(
+ config, username, password, client_id, stop_event=stop_event
+ )
+
+
+def _add_network_device(device: str, network_devices: list[str]) -> None:
+ """Append a device to the list, expanding it through ``CORE.address_cache``.
+
+ 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. Duplicates are dropped.
+ """
+ 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:
+ network_devices.append(device)
+
+
+def _split_network_devices(devices: list[str]) -> tuple[list[str], bool]:
+ """Split the device list into direct addresses and an MQTT-lookup flag.
+
+ Direct addresses are expanded through ``CORE.address_cache`` and deduped
+ the same way ``_resolve_network_devices`` does; MQTT/MQTTIP magic strings
+ are not resolved, only reported via the returned bool so the caller can
+ defer the broker lookup.
+ """
+ network_devices: list[str] = []
+ has_mqtt_lookup = False
+ for device in devices:
+ if get_port_type(device) in _MQTT_PORT_TYPES:
+ has_mqtt_lookup = True
+ else:
+ _add_network_device(device, network_devices)
+ return network_devices, has_mqtt_lookup
def _resolve_network_devices(
@@ -556,41 +644,47 @@ def _resolve_network_devices(
if port_type in _MQTT_PORT_TYPES:
# Only resolve MQTT once, even if multiple MQTT entries
if not mqtt_resolved:
- try:
- mqtt_ips = mqtt_get_ip(
- config, args.username, args.password, args.client_id
- )
- # 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_ips = _mqtt_get_ip_or_warn(
+ config, args.username, args.password, args.client_id
+ )
+ network_devices.extend(
+ addr for addr in mqtt_ips if addr not in network_devices
+ )
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)
+ _add_network_device(device, network_devices)
return network_devices
+def _mqtt_get_ip_or_warn(
+ config: ConfigType,
+ username: str,
+ password: str,
+ client_id: str,
+ stop_event: "threading.Event | None" = None,
+) -> list[str]:
+ """Look up the device IP via MQTT, returning [] with a warning on failure.
+
+ This owns the failure policy for MQTT IP discovery on paths that have
+ other addresses to fall back on: a broker problem must not abort the
+ operation. Also used as the deferred resolver handed to ``run_logs``,
+ where it runs in a worker thread.
+ """
+ try:
+ return mqtt_get_ip(config, username, password, client_id, stop_event=stop_event)
+ except EsphomeError as err:
+ _LOGGER.warning(
+ "MQTT IP discovery failed (%s), will try other devices if available",
+ err,
+ )
+ return []
+
+
def run_miniterm(config: ConfigType, port: str, args) -> int:
+ from datetime import datetime
+
from aioesphomeapi import LogParser
import serial
@@ -603,18 +697,9 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
return 1
_LOGGER.info("Starting log output from %s with baud rate %s", port, baud_rate)
- process_stacktrace = None
-
- try:
- module = importlib.import_module("esphome.components." + CORE.target_platform)
- process_stacktrace = getattr(module, "process_stacktrace")
- except (AttributeError, ImportError):
- _LOGGER.info(
- 'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
- CORE.target_platform,
- )
-
- backtrace_state = False
+ # Decoder resolution, crash isolation, and disable-after-failure
+ # all live in LogLineProcessor, shared with the API log path.
+ processor = LogLineProcessor(config, CORE.target_platform)
ser = serial.Serial()
ser.baudrate = baud_rate
ser.port = port
@@ -638,7 +723,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}]"
@@ -654,11 +739,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
"utf8", "backslashreplace"
)
safe_print(parser.parse_line(line, time_str))
-
- if process_stacktrace is not None:
- backtrace_state = process_stacktrace(
- config, line, backtrace_state
- )
+ processor.process_line(line)
except serial.SerialException:
_LOGGER.error("Serial port closed!")
return 0
@@ -673,15 +754,19 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
def _wrap_to_code(name, comp, yaml_util):
+ import esphome.codegen as cg
+
coro = coroutine(comp.to_code)
@functools.wraps(comp.to_code)
async def wrapped(conf):
cg.add(cg.LineComment(f"{name}:"))
if comp.config_schema is not None:
- conf_str = yaml_util.dump(conf)
+ # sort_keys: voluptuous fills defaults in set order, so an
+ # unsorted dump would churn main.cpp and relink every run
+ conf_str = yaml_util.dump(conf, sort_keys=True)
conf_str = conf_str.replace("//", "")
- # remove tailing \ to avoid multi-line comment warning
+ # remove trailing \ to avoid multi-line comment warning
conf_str = conf_str.replace("\\\n", "\n")
cg.add(cg.LineComment(indent(conf_str)))
await coro(conf)
@@ -694,6 +779,11 @@ def _wrap_to_code(name, comp, yaml_util):
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()
@@ -703,6 +793,7 @@ def write_cpp(config: ConfigType) -> int:
def generate_cpp_contents(config: ConfigType) -> None:
from esphome import yaml_util
+ from esphome.config import iter_component_configs
_LOGGER.info("Generating C++ source...")
@@ -733,6 +824,20 @@ def write_cpp_file() -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
+ # 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)
@@ -752,6 +857,20 @@ def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
toolchain.create_factory_bin()
toolchain.create_ota_bin()
toolchain.create_elf_copy()
+ from esphome.build_helpers.idedata import IDEDATA_BEST_EFFORT_ERRORS
+
+ try:
+ if toolchain.get_idedata() is None:
+ _LOGGER.warning("No idedata was generated for this build")
+ except IDEDATA_BEST_EFFORT_ERRORS as err:
+ # The firmware already built; an idedata failure must not fail
+ # a successful build.
+ _LOGGER.warning(
+ "Could not generate idedata: %s (IDE, clang-tidy, and "
+ "memory-analysis data will be unavailable for this build)",
+ err,
+ )
+ _LOGGER.debug("Idedata failure detail", exc_info=True)
else:
from esphome.platformio import toolchain
@@ -786,7 +905,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)
@@ -880,9 +999,10 @@ def upload_using_esptool(
mcu = "esp8266"
if CORE.is_esp32:
- from esphome.components.esp32 import get_esp32_variant
-
- mcu = get_esp32_variant().lower()
+ # Same lookup as esp32.get_esp32_variant(), read directly so the
+ # serial upload path does not import the esp32 package; both the
+ # validator and the warm-cache apply_to_core populate this key.
+ mcu = CORE.data[KEY_ESP32][KEY_VARIANT].lower()
line_callbacks: list[Callable[[str], str | None]] = []
if (
@@ -936,12 +1056,14 @@ def upload_using_esptool(
def upload_using_platformio(config: ConfigType, port: str) -> int:
+ import shutil
+
from esphome.platformio import toolchain
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
# 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:
+ if CORE.is_rp2:
idedata = toolchain.get_idedata(config)
build_dir = Path(idedata.firmware_elf_path).parent
firmware_bin = build_dir / "firmware.bin"
@@ -973,6 +1095,8 @@ def upload_using_picotool(config: ConfigType) -> int:
the mass storage copy approach that causes "disk not ejected properly"
warnings on macOS.
"""
+ import subprocess
+
from esphome.platformio import toolchain
idedata = toolchain.get_idedata(config)
@@ -1048,7 +1172,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:
@@ -1079,6 +1203,8 @@ def check_permissions(port: str):
"the USB cable can be used for data and is not a power-only cable."
)
if not (os.access(port, os.R_OK | os.W_OK)):
+ import getpass
+
raise EsphomeError(
"You do not have read or write permission on the selected serial port. "
"To resolve this issue, you can add your user to the dialout group "
@@ -1091,12 +1217,11 @@ def upload_program(
config: ConfigType, args: ArgsProtocol, devices: list[str]
) -> tuple[int, str | None]:
host = devices[0]
- try:
- module = importlib.import_module("esphome.components." + CORE.target_platform)
- if getattr(module, "upload_program")(config, args, host):
- return 0, host
- except AttributeError:
- pass
+ platform_upload = platform_hooks.get_platform_hook(
+ CORE.target_platform, "upload_program"
+ )
+ if platform_upload is not None and platform_upload(config, args, host):
+ return 0, host
port_type = get_port_type(host)
@@ -1126,10 +1251,10 @@ 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
@@ -1247,25 +1372,23 @@ def _upload_via_native_api(
def _upload_via_web_server(
config: ConfigType, network_devices: list[str], binary: Path
) -> tuple[int, str | None]:
- web_conf = config.get(CONF_WEB_SERVER)
- if not web_conf:
- raise EsphomeError(
- f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
- f"is not configured."
- )
-
- remote_port = int(web_conf[CONF_PORT])
- auth = web_conf.get(CONF_AUTH) or {}
- username = auth.get(CONF_USERNAME)
- password = auth.get(CONF_PASSWORD)
-
from esphome import web_server_ota
+ from esphome.web_server_helpers import get_web_server_connection
+ remote_port, username, password = get_web_server_connection(config)
return web_server_ota.run_ota(
network_devices, remote_port, username, password, binary
)
+def _show_logs_via_web_server(config: ConfigType, network_devices: list[str]) -> int:
+ from esphome import web_server_logs
+ from esphome.web_server_helpers import get_web_server_connection
+
+ port, username, password = get_web_server_connection(config)
+ return web_server_logs.run_logs(network_devices, port, username, password)
+
+
# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
@@ -1342,13 +1465,25 @@ def _validate_bootloader_binary(binary: Path) -> None:
)
+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):
- return 0
- except AttributeError:
- pass
+ platform_show_logs = platform_hooks.get_platform_hook(
+ CORE.target_platform, "show_logs"
+ )
+ if platform_show_logs is not None and platform_show_logs(config, args, devices):
+ return 0
if "logger" not in config:
raise EsphomeError("Logger is not configured!")
@@ -1362,17 +1497,37 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
return run_miniterm(config, port, args)
# Check if we should use API for logging
- # Resolve MQTT magic strings to actual IP addresses
- if has_api() and (
- network_devices := _resolve_network_devices(devices, config, args)
- ):
- from esphome.components.api.client import run_logs
+ if has_api():
+ network_devices, has_mqtt_lookup = _split_network_devices(devices)
+ mqtt_resolver = None
+ if has_mqtt_lookup:
+ if network_devices:
+ # Addresses are already known, so don't block startup on the
+ # MQTT broker lookup; hand it to run_logs as a deferred
+ # resolver that runs in the background and feeds discovered
+ # addresses into the running log client, keeping MQTT as a
+ # fallback for when the known addresses are stale (e.g. DHCP
+ # reassigned the IP).
+ mqtt_resolver = functools.partial(
+ _mqtt_get_ip_or_warn,
+ config,
+ args.username,
+ args.password,
+ args.client_id,
+ )
+ else:
+ # The MQTT lookup is the only way to find the device; resolve
+ # it up front since the client needs an address to start with.
+ network_devices = _resolve_network_devices(devices, config, args)
+ if network_devices:
+ from esphome.api_client import run_logs
- return run_logs(
- config,
- network_devices,
- subscribe_states=not getattr(args, "no_states", False),
- )
+ return run_logs(
+ config,
+ network_devices,
+ subscribe_states=_should_subscribe_states(args),
+ mqtt_resolver=mqtt_resolver,
+ )
if port_type in (PortType.NETWORK, PortType.MQTT) and has_mqtt_logging():
from esphome import mqtt
@@ -1381,6 +1536,13 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
config, args.topic, args.username, args.password, args.client_id
)
+ # Fall back to the web_server HTTP SSE log stream for devices that have
+ # web_server: but no api: (the logging counterpart to web_server OTA).
+ if has_web_server_logging() and (
+ network_devices := _resolve_network_devices(devices, config, args)
+ ):
+ return _show_logs_via_web_server(config, network_devices)
+
raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
@@ -1400,21 +1562,86 @@ def command_wizard(args: ArgsProtocol) -> int | None:
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import yaml_util
+ from esphome.config import strip_default_ids
- if not CORE.verbose:
+ if getattr(args, "no_defaults", False):
+ user_config = getattr(config, "user_config", None)
+ if user_config is None:
+ _LOGGER.warning(
+ "--no-defaults requested but the user-only config snapshot is "
+ "unavailable; falling back to the validated configuration."
+ )
+ else:
+ config = user_config
+ elif not CORE.verbose:
config = strip_default_ids(config)
output = yaml_util.dump(config, args.show_secrets)
- # add the console decoration so the front-end can hide the secrets
if not args.show_secrets:
- output = re.sub(
- r"(password|key|psk|ssid)\: (.+)", r"\1: \\033[8m\2\\033[28m", output
- )
+ output = _redact_with_legacy_fallback(output)
if not CORE.quiet:
safe_print(output)
_LOGGER.info("Configuration is valid!")
return 0
+# Legacy substring redaction fallback for unmigrated schemas; removed in
+# 2026.12.0 once canonical sensitive fields are tagged. The lookahead skips
+# values that already render themselves: ``\033[8m`` (SensitiveStr wrap),
+# ``!secret`` (preserves the user-friendly tag), ``!lambda`` (multi-line
+# block; first line is structural). The fragment must either start the
+# field name or follow ``_`` so the warning names a real field; this avoids
+# false positives like ``monkey:`` matching the ``key`` fragment.
+_LEGACY_REDACTION_RE = re.compile(
+ r"(?P\b(?:\w+_)?(?:password|key|psk|ssid))\: "
+ r"(?!\\033\[8m|!secret\b|!lambda\b)(?P.+)"
+)
+_LEGACY_REDACTION_REMOVAL = "2026.12.0"
+
+
+def _redact_with_legacy_fallback(output: str) -> str:
+ unmarked: set[str] = set()
+ # Track the top-level ``substitutions:`` block. Its keys are arbitrary
+ # user-chosen names with no schema validator, so the ``cv.sensitive(...)``
+ # migration named in the warning can't be applied to them. Their values are
+ # still redacted, but emitting the (unactionable) deprecation warning would
+ # only confuse users.
+ in_substitutions = False
+
+ lines = output.split("\n")
+ for i, line in enumerate(lines):
+ # A non-indented, non-blank line is a top-level key that opens or
+ # closes the substitutions block.
+ if line and not line[0].isspace():
+ in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:")
+ m = _LEGACY_REDACTION_RE.search(line)
+ if m is None:
+ continue
+ 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
@@ -1534,10 +1761,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()
@@ -1579,7 +1803,7 @@ 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
@@ -1588,7 +1812,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
- from esphome.bundle import BUNDLE_EXTENSION, ConfigBundleCreator
+ from esphome.bundle import ConfigBundleCreator
creator = ConfigBundleCreator(config)
@@ -1620,9 +1844,13 @@ def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_dashboard(args: ArgsProtocol) -> int | None:
- from esphome.dashboard import dashboard
-
- return dashboard.start_dashboard(args)
+ raise EsphomeError(
+ "The built-in dashboard has been removed from ESPHome. "
+ "Install and run ESPHome Device Builder instead:\n"
+ " pip install esphome-device-builder\n"
+ " esphome-device-builder\n"
+ "See https://github.com/esphome/device-builder for more information."
+ )
def run_multiple_configs(
@@ -1699,6 +1927,21 @@ def command_update_all(args: ArgsProtocol) -> int | None:
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json
+ 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",
@@ -1792,7 +2035,7 @@ 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
@@ -1804,7 +2047,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_name = args.name
for c in new_name:
if c not in ALLOWED_NAME_CHARS:
- print(
+ safe_print(
color(
AnsiFore.BOLD_RED,
f"'{c}' is an invalid character for names. Valid characters are: "
@@ -1817,7 +2060,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
yaml = yaml_util.load_yaml(CORE.config_path)
if CONF_ESPHOME not in yaml or CONF_NAME not in yaml[CONF_ESPHOME]:
- print(
+ safe_print(
color(
AnsiFore.BOLD_RED, "Complex YAML files cannot be automatically renamed."
)
@@ -1864,7 +2107,9 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
)
> 1
):
- print(color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename"))
+ safe_print(
+ color(AnsiFore.BOLD_RED, "Too many matches in YAML to safely rename")
+ )
return 1
new_raw = re.sub(
@@ -1882,7 +2127,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
# ``kitchen``; running ``esphome rename weird-file.yaml kitchen``
# would otherwise just re-flash the same hostname).
if new_name == old_name:
- print(
+ safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
@@ -1892,7 +2137,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
new_path: Path = CORE.config_dir / (new_name + ".yaml")
if new_path.resolve() == CORE.config_path.resolve():
- print(
+ safe_print(
color(
AnsiFore.BOLD_RED,
f"'{new_name}' is already the device's name.",
@@ -1900,7 +2145,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
)
return 1
if new_path.exists():
- print(
+ safe_print(
color(
AnsiFore.BOLD_RED,
f"Cannot rename: {new_path} already exists. "
@@ -1908,7 +2153,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
)
)
return 1
- print(
+ safe_print(
f"Updating {color(AnsiFore.CYAN, str(CORE.config_path))} to {color(AnsiFore.CYAN, str(new_path))}"
)
print()
@@ -1917,7 +2162,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
rc = run_external_process(*ESPHOME_COMMAND, "config", str(new_path))
if rc != 0:
- print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
+ safe_print(color(AnsiFore.BOLD_RED, "Rename failed. Reverting changes."))
new_path.unlink()
return 1
@@ -1943,7 +2188,7 @@ def command_rename(args: ArgsProtocol, config: ConfigType) -> int | None:
if CORE.config_path != new_path:
CORE.config_path.unlink()
- print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
+ safe_print(color(AnsiFore.BOLD_GREEN, "SUCCESS"))
print()
return 0
@@ -1980,6 +2225,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(
@@ -2072,6 +2340,12 @@ 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."
@@ -2156,11 +2430,7 @@ def parse_args(argv):
help="Reset the device before starting serial logs.",
default=os.getenv("ESPHOME_SERIAL_LOGGING_RESET"),
)
- parser_logs.add_argument(
- "--no-states",
- action="store_true",
- help="Do not show entity state changes in log output.",
- )
+ _add_states_args(parser_logs)
parser_discover = subparsers.add_parser(
"discover",
@@ -2192,11 +2462,7 @@ def parse_args(argv):
"--no-logs", help="Disable starting logs.", action="store_true"
)
- parser_run.add_argument(
- "--no-states",
- action="store_true",
- help="Do not show entity state changes in log output.",
- )
+ _add_states_args(parser_run)
parser_run.add_argument(
"--reset",
@@ -2242,50 +2508,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")
@@ -2352,7 +2599,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)
@@ -2363,6 +2615,49 @@ def parse_args(argv):
return parser.parse_args(arguments)
+def _warn_if_source_tree_mismatch() -> None:
+ """Warn when the checkout the user is standing in is not the one being run.
+
+ An editable install records one absolute path, so a venv shared between git
+ worktrees (or reused after a checkout is copied or renamed) keeps importing
+ the tree it was installed from. Every command then silently runs, and
+ compiles, sources the user is not looking at. Only fires inside a checkout,
+ so ordinary installs never see it.
+ """
+ try:
+ cwd = Path.cwd()
+ except OSError:
+ return # working directory is gone; a diagnostic must not break startup
+ for candidate in (cwd, *cwd.parents):
+ if (candidate / "esphome" / "__main__.py").is_file():
+ standing_in = candidate.resolve()
+ break
+ else:
+ return # not inside a checkout; nothing to compare against
+
+ running = Path(__file__).resolve().parent.parent
+ # Both sides are resolved, so on a case-sensitive filesystem this matches
+ # plain equality. samefile() compares device and inode, which additionally
+ # covers a case-insensitive filesystem (macOS) reaching one directory by
+ # differently cased paths. Falls back to equality if either path is gone.
+ try:
+ same = standing_in.samefile(running)
+ except OSError:
+ same = standing_in == running
+ if same:
+ return
+
+ _LOGGER.warning(
+ "Running ESPHome from a different checkout than the one you are in:\n"
+ " running from: %s\n"
+ " you are in: %s\n"
+ "The installed esphome resolves to the first, so its sources are used.\n"
+ "Run 'python -m esphome' from the second to use that one instead.",
+ running,
+ standing_in,
+ )
+
+
def run_esphome(argv):
from esphome.address_cache import AddressCache
@@ -2380,11 +2675,8 @@ 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)
+ _warn_if_source_tree_mismatch()
if args.command in PRE_CONFIG_ACTIONS:
try:
@@ -2416,10 +2708,11 @@ def run_esphome(argv):
return 0
# Bundle support: if the configuration is a .esphomebundle, extract it
- # and rewrite conf_path to the extracted YAML config.
- from esphome.bundle import is_bundle_path, prepare_bundle_for_compile
+ # and rewrite conf_path to the extracted YAML config. The suffix check
+ # stays inline so the ordinary run never imports esphome.bundle.
+ if conf_path.name.lower().endswith(BUNDLE_EXTENSION):
+ from esphome.bundle import prepare_bundle_for_compile
- if is_bundle_path(conf_path):
_LOGGER.info("Extracting config bundle %s...", conf_path)
conf_path = prepare_bundle_for_compile(conf_path)
# Update the argument so downstream code sees the extracted path
@@ -2434,20 +2727,56 @@ def run_esphome(argv):
# 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")
- config = read_config(
- dict(args.substitution) if args.substitution else {},
- skip_external_update=skip_external,
+ 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_write_eligible = (
+ args.command in ("upload", "logs") and not command_line_substitutions
)
- if config is None:
- return 2
+ # An explicit --toolchain must re-run the per-platform validators, so
+ # gate only the cache read; the refresh below saves the result unless
+ # the sidecar records a different toolchain.
+ cache_read_eligible = cache_write_eligible and args.toolchain is None
+ if cache_read_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,
+ )
+
+ cache_missed = config is None
+ if cache_missed:
+ from esphome.config import read_config
+
+ config = read_config(
+ command_line_substitutions,
+ skip_external_update=skip_external,
+ # Snapshot only needed by `esphome config --no-defaults`.
+ snapshot_user_config=getattr(args, "no_defaults", False),
+ )
+ 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.
+ # The cache fast path skips validation, and legacy sidecars lack the
+ # toolchain field. Must run before the cache refresh below.
if CORE.toolchain is None:
CORE.toolchain = Toolchain.PLATFORMIO
+ # Refresh the cache so the next upload/logs hits the fast path
+ # instead of re-running read_config.
+ if cache_write_eligible and cache_missed:
+ from esphome.compiled_config import save_compiled_config_and_sidecar
+
+ save_compiled_config_and_sidecar(config)
+
if args.command not in POST_CONFIG_ACTIONS:
safe_print(f"Unknown command {args.command}")
return 1
diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py
index 8f1f39e1d6..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
@@ -19,6 +20,7 @@ from . import (
RAM_SECTIONS,
MemoryAnalyzer,
)
+from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
if TYPE_CHECKING:
from . import ComponentMemory
@@ -509,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(
@@ -601,7 +603,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(
f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):"
)
- for i, (symbol, demangled, size, section) in enumerate(large_symbols):
+ for i, (_symbol, demangled, size, section) in enumerate(large_symbols):
lines.append(
f"{i + 1}. {self._format_symbol_with_section(demangled, size, section)}"
)
@@ -640,7 +642,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
lines.append(
f" Symbols > {self.RAM_SYMBOL_SIZE_THRESHOLD} B ({len(large_ram_syms)}):"
)
- for symbol, demangled, size, section in large_ram_syms[:10]:
+ for _symbol, demangled, size, section in large_ram_syms[:10]:
# Format section label consistently by stripping leading dot
section_label = section.lstrip(".") if section else ""
display_name = _format_pstorage_name(demangled)
@@ -699,7 +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)
@@ -737,7 +739,6 @@ def main():
# Load build directory
import json
- from pathlib import Path
from esphome.platformio.toolchain import IDEData
@@ -759,45 +760,25 @@ def main():
print(f"Error: {build_path} is not a directory", file=sys.stderr)
sys.exit(1)
- # Find firmware.elf
- elf_file = None
- for elf_candidate in [
- build_path / "firmware.elf",
- build_path / ".pioenvs" / build_path.name / "firmware.elf",
- ]:
- if elf_candidate.exists():
- elf_file = str(elf_candidate)
- break
-
- if not elf_file:
- print(f"Error: firmware.elf not found in {build_dir}", file=sys.stderr)
+ elf_path = find_elf_path(build_path)
+ if not elf_path:
+ print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr)
sys.exit(1)
-
- # Find idedata.json - check current directory first, then home
- device_name = build_path.name
- idedata_candidates = [
- Path.cwd() / ".esphome" / "idedata" / f"{device_name}.json",
- Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
- ]
+ elf_file = str(elf_path)
idedata = None
- for idedata_path in idedata_candidates:
- if not idedata_path.exists():
- continue
+ if idedata_path := find_idedata_path(build_path):
try:
- with open(idedata_path, encoding="utf-8") as f:
+ with idedata_path.open(encoding="utf-8") as f:
raw_data = json.load(f)
idedata = IDEData(raw_data)
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
- break
except (json.JSONDecodeError, OSError) as e:
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
if not idedata:
- print(
- f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
- file=sys.stderr,
- )
+ searched = "\n ".join(str(p) for p in idedata_candidates(build_path))
+ print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr)
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
analyzer.analyze()
diff --git a/esphome/analyze_memory/demangle.py b/esphome/analyze_memory/demangle.py
index 8999108b51..7dbd6d4f63 100644
--- a/esphome/analyze_memory/demangle.py
+++ b/esphome/analyze_memory/demangle.py
@@ -154,7 +154,7 @@ def batch_demangle(
failed_count = 0
for original, stripped, prefix, demangled in zip(
- symbols, symbols_stripped, symbols_prefixes, demangled_lines
+ symbols, symbols_stripped, symbols_prefixes, demangled_lines, strict=True
):
# Add back any prefix that was removed
demangled = _restore_symbol_prefix(prefix, stripped, demangled)
diff --git a/esphome/analyze_memory/ram_strings.py b/esphome/analyze_memory/ram_strings.py
index fbcbeeca61..03da86de94 100644
--- a/esphome/analyze_memory/ram_strings.py
+++ b/esphome/analyze_memory/ram_strings.py
@@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266.
from __future__ import annotations
from collections import defaultdict
-from dataclasses import dataclass
+from dataclasses import dataclass, field
import logging
from pathlib import Path
import re
@@ -65,6 +65,7 @@ class RamSymbol:
size: int
section: str
demangled: str = "" # Demangled name, set after batch demangling
+ aliases: list[str] = field(default_factory=list) # Other names at same address
class RamStringsAnalyzer:
@@ -235,6 +236,11 @@ class RamStringsAnalyzer:
except (subprocess.CalledProcessError, FileNotFoundError):
return
+ # Track symbols by address so aliases (multiple names for the same
+ # object, e.g. the newlib __lock___* mutexes that all alias one
+ # StaticSemaphore_t) are reported once instead of once per name.
+ symbols_by_addr: dict[int, RamSymbol] = {}
+
for line in output.split("\n"):
parts = line.split()
if len(parts) < 4:
@@ -253,6 +259,18 @@ class RamStringsAnalyzer:
if sym_type not in DATA_SYMBOL_TYPES:
continue
+ if (existing := symbols_by_addr.get(addr)) is not None:
+ # Prefer a global (uppercase type) name as the primary so
+ # nm output order can't hide it behind a local alias.
+ if sym_type.isupper() and existing.sym_type.islower():
+ existing.aliases.append(existing.name)
+ existing.name = name
+ existing.sym_type = sym_type
+ else:
+ existing.aliases.append(name)
+ existing.size = max(existing.size, size)
+ continue
+
# Check if symbol is in a RAM section
for section_name in self.ram_sections:
if section_name not in self.sections:
@@ -260,15 +278,15 @@ class RamStringsAnalyzer:
section = self.sections[section_name]
if section.address <= addr < section.address + section.size:
- self.ram_symbols.append(
- RamSymbol(
- name=name,
- sym_type=sym_type,
- address=addr,
- size=size,
- section=section_name,
- )
+ symbol = RamSymbol(
+ name=name,
+ sym_type=sym_type,
+ address=addr,
+ size=size,
+ section=section_name,
)
+ symbols_by_addr[addr] = symbol
+ self.ram_symbols.append(symbol)
break
def _demangle_symbols(self) -> None:
@@ -436,7 +454,13 @@ class RamStringsAnalyzer:
for symbol in largest_symbols:
# Use demangled name if available, otherwise raw name
display_name = symbol.demangled or symbol.name
- name_display = display_name[:49] if len(display_name) > 49 else display_name
+ # Truncate the name, not the alias note, so merged aliases stay
+ # visible even for long demangled C++ names.
+ alias_note = f" (+{len(symbol.aliases)} aliases)" if symbol.aliases else ""
+ max_name_len = 49 - len(alias_note)
+ if len(display_name) > max_name_len:
+ display_name = display_name[:max_name_len]
+ name_display = display_name + alias_note
lines.append(
f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}"
)
diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py
index 3a8a5f7be4..19041ac807 100644
--- a/esphome/analyze_memory/toolchain.py
+++ b/esphome/analyze_memory/toolchain.py
@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
-import os
from pathlib import Path
import subprocess
from typing import TYPE_CHECKING
@@ -24,6 +23,78 @@ TOOLCHAIN_PREFIXES = [
]
+def find_elf_path(build_path: Path) -> Path | None:
+ """Locate the firmware ELF inside an ESPHome build directory.
+
+ The layout depends on the toolchain that produced the build, so try each
+ known one in turn.
+
+ Args:
+ build_path: Path to an ESPHome build directory
+
+ Returns:
+ Path to the ELF file, or None if no known layout matches
+ """
+ name = build_path.name
+ for candidate in (
+ # Native ESP-IDF: idf.py writes build/.elf, which ESPHome copies
+ # to build/firmware.elf (see espidf.toolchain.create_elf_copy)
+ build_path / "build" / "firmware.elf",
+ # PlatformIO
+ build_path / "firmware.elf",
+ build_path / ".pioenvs" / name / "firmware.elf",
+ # LibreTiny uses raw_firmware.elf
+ build_path / "raw_firmware.elf",
+ build_path / ".pioenvs" / name / "raw_firmware.elf",
+ # Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2
+ build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf",
+ build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf",
+ ):
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+def idedata_candidates(build_path: Path) -> list[Path]:
+ """Return the idedata locations searched for a build directory, in order.
+
+ Exposed so a caller reporting "not found" can name the paths it tried
+ without keeping its own copy of the list.
+
+ Args:
+ build_path: Path to an ESPHome build directory
+
+ Returns:
+ The candidate idedata JSON paths, most specific first
+ """
+ name = build_path.name
+ return [
+ # In .pioenvs for test builds
+ build_path / ".pioenvs" / name / "idedata.json",
+ # Both toolchains cache it in the data dir, which holds this build dir:
+ # /idedata/.json next to /build/
+ build_path.parent.parent / "idedata" / f"{name}.json",
+ # Regular builds, invoked from the config dir or from anywhere
+ Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
+ Path.home() / ".esphome" / "idedata" / f"{name}.json",
+ ]
+
+
+def find_idedata_path(build_path: Path) -> Path | None:
+ """Locate the idedata JSON belonging to an ESPHome build directory.
+
+ Args:
+ build_path: Path to an ESPHome build directory
+
+ Returns:
+ Path to the idedata JSON, or None if it was not found
+ """
+ for candidate in idedata_candidates(build_path):
+ if candidate.is_file():
+ return candidate
+ return None
+
+
def _find_in_platformio_packages(tool_name: str) -> str | None:
"""Search for a tool in PlatformIO package directories.
@@ -37,7 +108,7 @@ def _find_in_platformio_packages(tool_name: str) -> str | None:
Full path to the tool or None if not found
"""
# Get PlatformIO packages directory
- platformio_home = Path(os.path.expanduser("~/.platformio/packages"))
+ platformio_home = Path("~/.platformio/packages").expanduser()
if not platformio_home.exists():
return None
diff --git a/esphome/api_client.py b/esphome/api_client.py
new file mode 100644
index 0000000000..fb41075de8
--- /dev/null
+++ b/esphome/api_client.py
@@ -0,0 +1,198 @@
+from __future__ import annotations
+
+import asyncio
+from contextlib import suppress
+import logging
+import threading
+from typing import TYPE_CHECKING, Any
+import warnings
+
+# Suppress protobuf version warnings
+with warnings.catch_warnings():
+ warnings.filterwarnings(
+ "ignore", category=UserWarning, message=".*Protobuf gencode version.*"
+ )
+ from aioesphomeapi import APIClient, parse_log_message
+ from aioesphomeapi.log_runner import async_run
+
+from esphome.const import CONF_ENCRYPTION, CONF_KEY, CONF_PORT, __version__
+from esphome.core import CORE
+from esphome.stacktrace import LogLineProcessor
+from esphome.util import safe_print
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from aioesphomeapi.api_pb2 import (
+ SubscribeLogsResponse, # pylint: disable=no-name-in-module
+ )
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_run_logs(
+ config: dict[str, Any],
+ addresses: list[str],
+ subscribe_states: bool = True,
+ mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
+) -> None:
+ """Run the logs command in the event loop.
+
+ If ``mqtt_resolver`` is given, it is called in a worker thread (paho-mqtt
+ has no asyncio support on Windows) concurrently with the connection
+ attempts to ``addresses``, and any addresses it discovers are fed into
+ the running client. It owns its own failure handling (returning [] when
+ discovery fails) and must honor the ``threading.Event`` it is passed so
+ teardown is not delayed by the lookup's wait window; the initial broker
+ connect itself is only bounded by the socket timeout.
+ """
+ from datetime import datetime
+
+ conf = config["api"]
+ name = config["esphome"]["name"]
+ port: int = int(conf[CONF_PORT])
+ noise_psk: str | None = None
+ if (encryption := conf.get(CONF_ENCRYPTION)) and (key := encryption.get(CONF_KEY)):
+ noise_psk = key
+
+ _LOGGER.info(
+ "Starting log output from %s using esphome API", " or ".join(addresses)
+ )
+
+ cli = APIClient(
+ addresses[0], # Primary address for compatibility
+ port,
+ "", # Password auth removed in 2026.1.0
+ client_info=f"ESPHome Logs {__version__}",
+ noise_psk=noise_psk,
+ addresses=addresses, # Pass all addresses for automatic retry
+ provide_time=False,
+ )
+
+ # Decoder resolution policy lives in LogLineProcessor.
+ processor = LogLineProcessor(config, CORE.target_platform)
+
+ mqtt_task: asyncio.Task[None] | None = None
+ mqtt_stop_event = threading.Event()
+
+ def _cancel_mqtt_discovery() -> None:
+ """Stop the broker lookup once a connection has been established.
+
+ Its answer is only useful while still disconnected: after that it
+ either duplicates the connected address or arrives too late to
+ matter, so don't keep an idle broker session open for it.
+ """
+ mqtt_stop_event.set()
+ if mqtt_task is not None and not mqtt_task.done():
+ mqtt_task.cancel()
+
+ async def _resolve_mqtt_addresses() -> None:
+ """Discover the device address via the MQTT broker in the background."""
+ try:
+ mqtt_ips = await asyncio.to_thread(mqtt_resolver, mqtt_stop_event)
+ if not mqtt_ips:
+ _LOGGER.debug(
+ "MQTT discovery %s",
+ "aborted" if mqtt_stop_event.is_set() else "found no addresses",
+ )
+ return
+ if cli.add_addresses(mqtt_ips):
+ _LOGGER.info("Discovered address(es) via MQTT: %s", ", ".join(mqtt_ips))
+ else:
+ _LOGGER.debug(
+ "MQTT-discovered address(es) already known: %s", ", ".join(mqtt_ips)
+ )
+ except Exception: # pylint: disable=broad-except
+ # A background task failure would otherwise stay invisible for
+ # the whole session and only re-raise at teardown
+ _LOGGER.exception("MQTT address discovery failed")
+
+ def on_log(msg: SubscribeLogsResponse) -> None:
+ """Handle a new log message."""
+ time_ = datetime.now().astimezone()
+ message: bytes = msg.message
+ text = message.decode("utf8", "backslashreplace")
+ nanoseconds = time_.microsecond // 1000
+ timestamp = (
+ f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]"
+ )
+ for parsed_msg in parse_log_message(text, timestamp):
+ # safe_print handles the dashboard \033 escaping and falls back
+ # to backslashreplace encoding on stdouts that can't represent
+ # the wifi signal-bar block characters (Windows redirected
+ # cp1252 pipe).
+ safe_print(parsed_msg)
+ for raw_line in text.splitlines():
+ processor.process_line(raw_line)
+
+ # Safe to fall back to plaintext here only for this diagnostics use
+ # case: the stream is one-way from device to client, and this code
+ # never accepts commands or acts on any message the device sends.
+ # An on-path attacker could still both inject fabricated log lines
+ # and passively read the device's log output (and any state data
+ # delivered when subscribe_states is enabled), so this does lose
+ # confidentiality as well as authentication/integrity. That tradeoff
+ # is acceptable for operator-visible logs, which aioesphomeapi also
+ # warns may come from an unverified device. Never mirror this opt-in
+ # for any connection that sends data to the device or uses Home
+ # Assistant actions.
+ stop = await async_run(
+ cli,
+ on_log,
+ name=name,
+ subscribe_states=subscribe_states,
+ allow_plaintext_fallback=True,
+ # A top-level ``deep_sleep:`` block means the device is only awake
+ # briefly; cap the reconnect backoff so a wake window is not missed.
+ deep_sleep="deep_sleep" in config,
+ on_connect=_cancel_mqtt_discovery if mqtt_resolver is not None else None,
+ )
+ try:
+ # Don't start (or keep) the broker lookup if a connection already
+ # succeeded; the stop event doubles as the not-needed-anymore latch
+ # and get_esphome_device_ip returns immediately when it is set.
+ if mqtt_resolver is not None and not mqtt_stop_event.is_set():
+ mqtt_task = asyncio.create_task(_resolve_mqtt_addresses())
+ await asyncio.Event().wait()
+ finally:
+ try:
+ if mqtt_task is not None:
+ # Unblock the worker thread first so it can't hold up
+ # loop.shutdown_default_executor() for the full lookup timeout.
+ mqtt_stop_event.set()
+ # Give the worker a moment to exit through its own error
+ # handling; cancelling first would race out a late failure.
+ done, _ = await asyncio.wait([mqtt_task], timeout=1.0)
+ if not done:
+ mqtt_task.cancel()
+ # return_exceptions keeps a CancelledError from the cancel()
+ # above from re-raising here and jumping over the stop() below.
+ # The task handles Exception itself, so only a BaseException
+ # escape (e.g. SystemExit from the worker) can land here.
+ (result,) = await asyncio.gather(mqtt_task, return_exceptions=True)
+ if isinstance(result, BaseException) and not isinstance(
+ result, asyncio.CancelledError
+ ):
+ _LOGGER.error("MQTT address discovery failed", exc_info=result)
+ finally:
+ # Must run even if a second cancellation lands mid-cleanup above
+ await stop()
+
+
+def run_logs(
+ config: dict[str, Any],
+ addresses: list[str],
+ subscribe_states: bool = True,
+ mqtt_resolver: Callable[[threading.Event], list[str]] | None = None,
+) -> None:
+ """Run the logs command."""
+ with suppress(KeyboardInterrupt):
+ asyncio.run(
+ async_run_logs(
+ config,
+ addresses,
+ subscribe_states=subscribe_states,
+ mqtt_resolver=mqtt_resolver,
+ )
+ )
diff --git a/esphome/arduino8266/__init__.py b/esphome/arduino8266/__init__.py
new file mode 100644
index 0000000000..8f403a8553
--- /dev/null
+++ b/esphome/arduino8266/__init__.py
@@ -0,0 +1,9 @@
+"""Native (PlatformIO-free) build support for the ESP8266 Arduino core.
+
+This package downloads the Arduino ESP8266 core and the xtensa-lx106
+toolchain, generates a ninja build for them plus the ESPHome sources, and
+drives the build directly — the ESP8266 equivalent of ``esphome.espidf``.
+
+Deliberately importable without the esp8266 component to avoid circular
+imports; the component wires these modules in via lazy imports.
+"""
diff --git a/esphome/arduino8266/framework.py b/esphome/arduino8266/framework.py
new file mode 100644
index 0000000000..1edbe4b36f
--- /dev/null
+++ b/esphome/arduino8266/framework.py
@@ -0,0 +1,164 @@
+"""Download and install the Arduino ESP8266 core, toolchain, and ninja.
+
+Artifacts land in a machine-global cache (shared across projects, like the
+ESP-IDF install in ``esphome.espidf.framework``):
+
+ /arduino8266/frameworks// framework-arduinoespressif8266
+ /arduino8266/toolchains// toolchain-xtensa (gcc 10.3)
+
+Packages come from the PlatformIO registry (identical bits to the PlatformIO
+backend); ``ESPHOME_ARDUINO8266_*_MIRRORS`` overrides the URLs. ninja comes
+from PATH or the ninja PyPI wheel.
+"""
+
+from __future__ import annotations
+
+import os
+from pathlib import Path
+from typing import NamedTuple
+
+from esphome.build_helpers.ccache import ccache_defaults_env
+from esphome.build_helpers.ninja import find_ninja
+from esphome.build_helpers.tools_cache import ARDUINO8266_TOOLS_CACHE, tools_cache_path
+from esphome.core import EsphomeError, Version
+from esphome.framework_helpers import str_to_lst_of_str
+from esphome.platformio.registry import install_package, prefetch_packages
+
+FRAMEWORK_PACKAGE = "framework-arduinoespressif8266"
+TOOLCHAIN_PACKAGE = "toolchain-xtensa"
+# gcc 10.3, the toolchain Arduino core 3.x builds with; the build
+# generator's compile flags are tuned to it.
+TOOLCHAIN_VERSION = "2.100300.220621"
+
+ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS = str_to_lst_of_str(
+ os.environ.get("ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS", "")
+)
+ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS = str_to_lst_of_str(
+ os.environ.get("ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS", "")
+)
+
+
+def get_arduino8266_tools_path() -> Path:
+ # Machine-global so all projects share one install; see
+ # espidf.framework.get_idf_tools_path for the location rationale.
+ return tools_cache_path(*ARDUINO8266_TOOLS_CACHE)
+
+
+# 3.1.1 rather than 3.1.0: the registry has no package for 3.1.0, and the
+# encoder below cannot name 3.0.0/3.0.1 either (see its docstring)
+MIN_FRAMEWORK_VERSION = Version(3, 1, 1)
+
+
+def framework_package_version(ver: Version) -> str:
+ """Map an Arduino core version to its registry package version (3.1.2 ->
+ 3.30102.0; the leading 3 is the package major).
+
+ Exact registry names only for cores > 2.6.2 and >= 3.0.2; callers floor
+ at MIN_FRAMEWORK_VERSION.
+ """
+ if ver.major > 3:
+ raise EsphomeError(
+ f"Arduino core {ver} is not supported yet; "
+ "the newest known core series is 3.x"
+ )
+ if ver <= Version(2, 6, 2):
+ # Cores <= 2.6.2 use the older 1.x/2.x package-major encodings (same
+ # boundary as _format_framework_arduino_version's era guard)
+ raise EsphomeError(
+ f"Arduino core {ver} uses an older package encoding than this "
+ "helper implements (newer than 2.6.2)"
+ )
+ return f"3.{ver.major}{ver.minor:02d}{ver.patch:02d}.0"
+
+
+def get_framework_path(package_version: str) -> Path:
+ return get_arduino8266_tools_path() / "frameworks" / package_version
+
+
+def get_toolchain_path() -> Path:
+ return get_arduino8266_tools_path() / "toolchains" / TOOLCHAIN_VERSION
+
+
+class InstalledPaths(NamedTuple):
+ """Locations of the installed framework, toolchain, and ninja binary."""
+
+ framework: Path
+ toolchain: Path
+ ninja: Path
+
+
+def check_and_install(framework_version: Version) -> InstalledPaths:
+ """Ensure framework, toolchain, and ninja are installed; return their paths."""
+ if framework_version < MIN_FRAMEWORK_VERSION:
+ # Config validation enforces this too; keep the module honest when
+ # called directly.
+ raise EsphomeError(
+ f"The native toolchain requires the Arduino core "
+ f">= {MIN_FRAMEWORK_VERSION}, got {framework_version}"
+ )
+ # Probe the cheap local dependency before ~110 MB of downloads
+ ninja_path = find_ninja()
+ package_version = framework_package_version(framework_version)
+ framework_path = get_framework_path(package_version)
+ downloads_dir = get_arduino8266_tools_path() / "downloads"
+ toolchain_path = get_toolchain_path()
+ # One spec per package: the prefetch and the installs must agree
+ specs = (
+ (
+ FRAMEWORK_PACKAGE,
+ package_version,
+ framework_path,
+ ESPHOME_ARDUINO8266_FRAMEWORK_MIRRORS,
+ ("cores/esp8266", "tools/sdk", "libraries"),
+ ),
+ (
+ TOOLCHAIN_PACKAGE,
+ TOOLCHAIN_VERSION,
+ toolchain_path,
+ ESPHOME_ARDUINO8266_TOOLCHAIN_MIRRORS,
+ # xtensa-lx106-elf pins the target: every gcc package has a bin/
+ ("bin", "xtensa-lx106-elf"),
+ ),
+ )
+ # Fetch both archives at once; the installs below verify and extract
+ prefetch_packages([spec[:4] for spec in specs], downloads_dir)
+ for name, version, dest, mirrors, expect in specs:
+ install_package(name, version, dest, mirrors, downloads_dir, expect=expect)
+ return InstalledPaths(
+ framework=framework_path, toolchain=toolchain_path, ninja=ninja_path
+ )
+
+
+def toolchain_tool(toolchain_path: Path, name: str) -> Path:
+ """Path to one toolchain tool (gcc, g++, ar, size, addr2line, ...).
+
+ The single owner of the ``bin/xtensa-lx106-elf-`` layout and the
+ Windows suffix, so a toolchain package bump touches one spot.
+ """
+ suffix = ".exe" if os.name == "nt" else ""
+ return toolchain_path / "bin" / f"xtensa-lx106-elf-{name}{suffix}"
+
+
+def get_build_env(toolchain_path: Path, ccache: str | None) -> dict[str, str]:
+ env = os.environ.copy()
+ # Drop empty entries: a trailing separator from an absent PATH would
+ # make the shell search the current directory for tools
+ parts = [
+ str(toolchain_path / "bin"),
+ *filter(None, env.get("PATH", "").split(os.pathsep)),
+ ]
+ env["PATH"] = os.pathsep.join(parts)
+ env.update(ccache_env(ccache))
+ return env
+
+
+def ccache_env(ccache: str | None) -> dict[str, str]:
+ """Return ccache settings for the build subprocess (not os.environ).
+
+ ``ccache`` is the pre-resolved binary (resolve_ccache_path), or None
+ when disabled. Values the user already set in the environment are
+ respected.
+ """
+ if ccache is None:
+ return {}
+ return ccache_defaults_env(get_arduino8266_tools_path() / "ccache")
diff --git a/esphome/async_thread.py b/esphome/async_thread.py
index 7be3c83a9a..3296d65af6 100644
--- a/esphome/async_thread.py
+++ b/esphome/async_thread.py
@@ -11,46 +11,136 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
+from itertools import count
+import logging
import threading
-from typing import Generic, TypeVar
+from typing import cast
-_T = TypeVar("_T")
+_LOGGER = logging.getLogger(__name__)
+
+# How long the orphan watcher waits for an abandoned coroutine before giving
+# up, so a hung operation does not park a watcher thread forever.
+ORPHAN_WAIT_TIMEOUT = 300.0
-class AsyncThreadRunner(threading.Thread, Generic[_T]):
- """Run an async coroutine in a daemon thread and expose its result.
+_runner_ids = count(1)
- 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::
+class AsyncDispatchTimeout(TimeoutError):
+ """The caller stopped waiting; the coroutine was abandoned.
- 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
+ A subclass so callers can tell the dispatcher's own expiry apart from a
+ ``TimeoutError`` raised inside the coroutine, while existing
+ ``except TimeoutError`` handlers keep working.
"""
- def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None:
- super().__init__(daemon=True)
+
+class AsyncThreadRunner[T](threading.Thread):
+ """Run an async coroutine in a daemon thread and expose its result.
+
+ ``event`` is always set, even when the coroutine crashes, so waiters
+ never hang; ``completed`` distinguishes a delivered result (even a
+ legitimate ``None``) from a coroutine that never finished. Prefer
+ :func:`run_async`; use this class directly only when a failure should
+ degrade to a default value instead of raising.
+ """
+
+ def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None:
+ super().__init__(daemon=True, name=f"async-thread-runner-{next(_runner_ids)}")
self._coro_factory = coro_factory
- self.result: _T | None = None
+ self.result: T | None = None
self.exception: BaseException | None = None
+ self.completed = False
self.event = threading.Event()
async def _runner(self) -> None:
try:
self.result = await self._coro_factory()
- except Exception as exc: # pylint: disable=broad-except
- # Capture all exceptions so ``event`` is always set — otherwise a
- # crash would hang the waiter forever.
+ # Distinguishes a delivered result from "never ran", since None
+ # is a valid result value.
+ self.completed = True
+ except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
+ # Capture everything, including BaseException — otherwise a
+ # cancellation or SystemExit would leave ``exception`` unset and
+ # waiters would mistake the empty ``result`` for success.
self.exception = exc
finally:
self.event.set()
def run(self) -> None:
- asyncio.run(self._runner())
+ try:
+ asyncio.run(self._runner())
+ except BaseException as exc: # noqa: BLE001 # pylint: disable=broad-except
+ # asyncio.run itself can fail before _runner executes (e.g. loop
+ # creation under fd exhaustion); record it so waiters never hang.
+ # A failure during loop cleanup after the coroutine completed
+ # must not clobber the delivered result, hence the guard.
+ if self.exception is None and not self.completed:
+ self.exception = exc
+ else:
+ _LOGGER.debug(
+ "Event loop teardown failed after outcome recorded",
+ exc_info=True,
+ )
+ finally:
+ self.event.set()
+
+
+def run_async[T](
+ coro_factory: Callable[[], Awaitable[T]],
+ timeout: float | None = None,
+ on_orphan: Callable[[T], None] | None = None,
+) -> T:
+ """Run a coroutine in a daemon-thread event loop and return its result.
+
+ Raises :class:`AsyncDispatchTimeout` if the coroutine does not finish
+ within ``timeout`` seconds; the thread is abandoned and exits with the
+ interpreter. If the abandoned coroutine later produces a result,
+ ``on_orphan`` (if given) is called with it so resources such as a
+ connected socket can be released; delivery is best effort and bounded
+ by ``ORPHAN_WAIT_TIMEOUT``.
+ """
+ runner: AsyncThreadRunner[T] = AsyncThreadRunner(coro_factory)
+ runner.start()
+ if not runner.event.wait(timeout):
+
+ def _cleanup() -> None:
+ if not runner.event.wait(ORPHAN_WAIT_TIMEOUT):
+ # The one state where a resource can genuinely leak; leave
+ # a trace so a recurring hang is attributable.
+ _LOGGER.info(
+ "Orphan watcher gave up after %.0fs; a late result may leak",
+ ORPHAN_WAIT_TIMEOUT,
+ )
+ return
+ if not runner.completed:
+ # The only place an abandoned thread's real error surfaces;
+ # without it a late failure hides behind the TimeoutError.
+ # INFO, not DEBUG: it fires at most once per abandoned
+ # operation and the cause may not reproduce on a rerun.
+ _LOGGER.info(
+ "Abandoned async operation failed",
+ exc_info=runner.exception,
+ )
+ return
+ if (result := runner.result) is None:
+ return
+ if on_orphan is None:
+ _LOGGER.debug("Discarding late result; no on_orphan handler")
+ return
+ try:
+ on_orphan(result)
+ except Exception: # pylint: disable=broad-except
+ # INFO, not DEBUG: a failed release means a real leak, and
+ # it fires at most once per abandoned operation.
+ _LOGGER.info("Error releasing orphaned result", exc_info=True)
+
+ threading.Thread(
+ target=_cleanup, daemon=True, name="async-orphan-cleanup"
+ ).start()
+ raise AsyncDispatchTimeout("Timed out waiting for async operation")
+ if (exc := runner.exception) is not None:
+ raise exc
+ if not runner.completed:
+ raise RuntimeError("Async operation finished without a result or an exception")
+ return cast("T", runner.result)
diff --git a/esphome/build_gen/espidf.py b/esphome/build_gen/espidf.py
index dfe2d72b9d..2ef89cf595 100644
--- a/esphome/build_gen/espidf.py
+++ b/esphome/build_gen/espidf.py
@@ -1,64 +1,175 @@
"""ESP-IDF direct build generator for ESPHome."""
import json
+import logging
from pathlib import Path
-from esphome.components.esp32 import get_esp32_variant
+from esphome.components.esp32 import (
+ get_esp32_variant,
+ get_excluded_builtin_components,
+ get_managed_component_require_names,
+ idf_version,
+)
+import esphome.config_validation as cv
from esphome.core import CORE
+from esphome.espidf import variant_to_idf_target
+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
-from esphome.writer import update_storage_json
+
+_LOGGER = logging.getLogger(__name__)
+
+# 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.
+ """List the built-in ESP-IDF components from ``project_description.json``.
- Returns only internal ESP-IDF components, excluding external/managed
- components (from idf_component.yml).
+ Only components below its ``idf_path/components`` count, which leaves out
+ ``src``, IDF-managed components, converted PIO libs and project local
+ ones such as the Arduino ``component_stubs``. 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", {})
-
- result = []
- for name, info in component_info.items():
- # Exclude our own src component
- if name == "src":
- continue
-
- # Exclude managed/external components
- comp_dir = info.get("dir", "")
- if "managed_components" in comp_dir:
- continue
-
- result.append(name)
-
- return result
- except (json.JSONDecodeError, OSError):
+ root = (Path(data["idf_path"]) / "components").resolve()
+ result = [
+ name
+ for name, info in data.get("build_component_info", {}).items()
+ if (comp_dir := info.get("dir"))
+ and Path(comp_dir).resolve().is_relative_to(root)
+ ]
+ except (json.JSONDecodeError, KeyError, OSError) as err:
+ _LOGGER.debug("Could not read %s: %s", project_desc, err)
return None
+ if not result:
+ _LOGGER.warning("No ESP-IDF components found under %s", root)
+ return result
def has_discovered_components() -> bool:
- """Check if we have discovered components from a previous configure."""
- return get_available_components() is not None
+ """Check if a previous configure discovered any built-in components."""
+ return bool(get_available_components())
-def get_project_cmakelists() -> str:
- """Generate the top-level CMakeLists.txt for ESP-IDF project."""
- # Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
- variant = get_esp32_variant()
- idf_target = variant.lower().replace("-", "")
+def _cmake_quote(value: str) -> str:
+ """Quote a cmake arg value for a set() line. add_cmake_arg rejects
+ whitespace, quotes, and '$', so only backslashes need escaping."""
+ escaped = value.replace("\\", "\\\\")
+ return f'"{escaped}"'
- # Extract compile definitions from build flags (-DXXX -> XXX)
- compile_defs = [flag for flag in sorted(CORE.build_flags) if flag.startswith("-D")]
+
+def get_project_cmakelists(
+ minimal: bool = False, builtin_components: list[str] | None = None
+) -> 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.
+ ``builtin_components`` supplies the discovered list (from the cache)
+ instead of reading it from ``project_description.json``.
+ """
+ idf_target = variant_to_idf_target(get_esp32_variant())
+
+ # esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
+ # removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
+ # --format=raw because the legacy mode doesn't support it.
+ size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
+
+ # Project-wide compile options: -D defines and -W warning flags (skip
+ # -Wl, linker flags — those go on the src component via
+ # target_link_options below). Emitted via idf_build_set_property so the
+ # flags propagate to every IDF component (including managed ones like
+ # esphome__micro-mp3) rather than just src/. Required so suppressions
+ # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
+ # third-party components we don't author.
+ project_compile_opts = get_project_compile_flags()
extra_compile_options = "\n".join(
- f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)'
- for compile_def in compile_defs
+ f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
+ for flag in project_compile_opts
+ )
+
+ # Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
+ # (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
+ # -Wno-volatile is passed on a C compile.
+ cxx_compile_options = "\n".join(
+ f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
+ for flag in get_project_cxx_compile_flags()
+ )
+
+ cpp_standard_options = (
+ CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard)
+ if CORE.cpp_standard
+ else ""
+ )
+
+ # CMake variables registered via cg.add_cmake_arg(). Emitted before
+ # include(project.cmake) so values like EXCLUDE_COMPONENTS are already
+ # set when project.cmake seeds the component list, and on minimal
+ # (discovery) writes too so excluded components never register.
+ cmake_args = "\n".join(
+ f"set({name} {_cmake_quote(value)})"
+ for name, value in sorted(CORE.cmake_args.items())
+ )
+
+ # 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).
+ 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.
+ # Excluded components are dropped here as well: a stale
+ # project_description.json from a build without exclusions may still
+ # list them, and requiring an excluded component pulls it back into
+ # the build (IDF requirement expansion overrides EXCLUDE_COMPONENTS).
+ # Derived from the EXCLUDE_COMPONENTS cmake arg emitted above so the
+ # two can never disagree within one generated file.
+ builtin_components_property = (
+ ""
+ if minimal
+ else "\n".join(
+ f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
+ for name in sorted(
+ set(
+ builtin_components
+ if builtin_components is not None
+ else get_available_components() or []
+ ).difference(CORE.cmake_args.get("EXCLUDE_COMPONENTS", "").split(";"))
+ )
+ )
)
return f"""\
@@ -84,16 +195,26 @@ set(CMAKE_NINJA_FORCE_RESPONSE_FILE 1)
set(IDF_TARGET {idf_target})
set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
+{cmake_args}
+
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 --ng --format=raw
+ 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}}
@@ -102,44 +223,56 @@ add_custom_command(
"""
-def get_component_cmakelists(minimal: bool = False) -> str:
- """Generate the main component CMakeLists.txt."""
- idf_requires = [] if minimal else (get_available_components() or [])
- requires_str = " ".join(idf_requires)
+def get_component_cmakelists() -> str:
+ """Generate the main component CMakeLists.txt.
- # Extract compile options (-W flags, excluding linker flags)
- compile_opts = [
- flag
- for flag in CORE.build_flags
- if flag.startswith("-W") and not flag.startswith("-Wl,")
- ]
- compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else ""
-
- # Extract linker options (-Wl, flags)
- link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")]
- link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else ""
+ REQUIRES pulls in the discovered built-in IDF components via the
+ project-level variables set in the top-level CMakeLists.
+ """
+ # Extract linker options (-Wl, flags). Compile flags (-D, -W) are
+ # emitted project-wide via idf_build_set_property in
+ # get_project_cmakelists so they reach every component, not just src/.
+ link_opts = get_project_link_flags()
+ link_opts_str = "\n ".join(link_opts) if link_opts else ""
return f"""\
# Auto-generated by ESPHome
-file(GLOB_RECURSE app_sources
- "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
- "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
- "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
- "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
-)
+# CONFIGURE_DEPENDS asks CMake to re-check the glob each build so test
+# runs that reuse the build dir don't compile stale source paths. It's
+# invalid in script mode (cmake -P), which is how IDF's
+# component_get_requirements.cmake includes us, so skip it there.
+if(CMAKE_SCRIPT_MODE_FILE)
+ file(GLOB_RECURSE app_sources
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
+ )
+else()
+ file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cc"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.cxx"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c++"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cc"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cxx"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c++"
+ "${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
+ )
+endif()
idf_component_register(
SRCS ${{app_sources}}
INCLUDE_DIRS "." "esphome"
- REQUIRES {requires_str}
-)
-
-# Apply C++ standard
-target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
-
-# ESPHome compile options
-target_compile_options(${{COMPONENT_LIB}} PUBLIC
- {compile_opts_str}
+ REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
)
# ESPHome linker options
@@ -149,24 +282,31 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
"""
-def write_project(minimal: bool = False) -> None:
+def write_project(
+ minimal: bool = False, builtin_components: list[str] | None = None
+) -> None:
"""Write ESP-IDF project files."""
- # Refresh /storage/.yaml.json so the dashboard's
- # /info and /downloads endpoints can locate the build (they 404
- # otherwise). This mirrors the PlatformIO build-gen path's call
- # in build_gen/platformio.py:write_ini().
- update_storage_json()
mkdir_p(CORE.build_path)
mkdir_p(CORE.relative_src_path())
# Write top-level CMakeLists.txt
write_file_if_changed(
CORE.relative_build_path("CMakeLists.txt"),
- get_project_cmakelists(),
+ get_project_cmakelists(minimal=minimal, builtin_components=builtin_components),
)
# Write component CMakeLists.txt in src/
write_file_if_changed(
CORE.relative_src_path("CMakeLists.txt"),
- get_component_cmakelists(minimal=minimal),
+ get_component_cmakelists(),
+ )
+
+ # Snapshot the exclusion set so has_outdated_files() can trigger a
+ # discovery reconfigure when it changes. Excluded components never
+ # register in project_description.json, so re-including one (e.g. a
+ # config gains mqtt) requires a fresh discovery pass before the
+ # ESPHOME_PROJECT_BUILTIN_COMPONENTS property can list it.
+ write_file_if_changed(
+ CORE.relative_build_path("exclude_components.esphomeinternal"),
+ ";".join(get_excluded_builtin_components()),
)
diff --git a/esphome/build_gen/platformio.py b/esphome/build_gen/platformio.py
index 30dbb69d86..0a12d344a0 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))
@@ -48,6 +63,17 @@ def get_ini_content():
# Add extra script for C++ flags
CORE.add_platformio_option("extra_scripts", [f"pre:{CXX_FLAGS_FILE_NAME}"])
+ # Add CMake args. A user-supplied value (str or list) is deliberately
+ # replaced; this option was always overwritten at FINAL priority.
+ if CORE.cmake_args:
+ CORE.add_platformio_option(
+ "board_build.cmake_extra_args",
+ " ".join(
+ f"-D{name}={value}" for name, value in sorted(CORE.cmake_args.items())
+ ),
+ replace=True,
+ )
+
content = "[platformio]\n"
content += f"description = ESPHome {__version__}\n"
@@ -58,7 +84,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 +119,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/build_helpers/__init__.py b/esphome/build_helpers/__init__.py
new file mode 100644
index 0000000000..df956a2509
--- /dev/null
+++ b/esphome/build_helpers/__init__.py
@@ -0,0 +1 @@
+"""Build helpers shared by the native (non-PlatformIO) toolchains."""
diff --git a/esphome/build_helpers/ccache.py b/esphome/build_helpers/ccache.py
new file mode 100644
index 0000000000..5b5c7f247f
--- /dev/null
+++ b/esphome/build_helpers/ccache.py
@@ -0,0 +1,92 @@
+"""Shared ccache policy for build backends: env-knob parsing, binary
+resolution, and default ``CCACHE_*`` values."""
+
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+
+from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
+from esphome.helpers import FALSY_ENV_STRINGS, TRUTHY_ENV_STRINGS
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _ccache_runs(ccache: str) -> bool:
+ """Return True when the ``ccache`` found on PATH actually runs."""
+ return tool_version_runs(
+ ccache,
+ "Ignoring ccache at %s because it failed to run; compiling without ccache",
+ )
+
+
+def parse_enable_env(name: str) -> bool | None:
+ """Strictly parse an on/off environment knob; None when unset or invalid.
+
+ ``bool(str)`` truthiness would flip ``no``/``off`` to enabled, so only
+ 1/true/yes/on and 0/false/no/off count; anything else warns and reads
+ as unset so the caller's default policy applies.
+ """
+ raw = os.environ.get(name)
+ if raw is None:
+ return None
+ lowered = raw.strip().lower()
+ if not lowered:
+ # ENV KNOB= (Docker/CI) has always read as a disable
+ return False
+ if lowered in TRUTHY_ENV_STRINGS:
+ return True
+ if lowered in FALSY_ENV_STRINGS:
+ return False
+ _LOGGER.warning("Ignoring unrecognized %s=%r; use 1 or 0", name, raw)
+ return None
+
+
+def resolve_ccache_path() -> str | None:
+ """The ccache binary to wrap compiles with, or None when disabled.
+
+ An explicit ``ESPHOME_CCACHE_ENABLE=1`` skips the runnability probe; the
+ Windows extended-length prefix is stripped before probing (#18399).
+ """
+ import shutil
+
+ explicit = parse_enable_env("ESPHOME_CCACHE_ENABLE")
+ if explicit is False:
+ return None
+ ccache = shutil.which("ccache")
+ if ccache is None:
+ if explicit:
+ _LOGGER.warning(
+ "ESPHOME_CCACHE_ENABLE is set but no ccache binary is on PATH; "
+ "compiling without ccache"
+ )
+ return None
+ ccache = strip_win_long_path_prefix(ccache)
+ if not explicit and not _ccache_runs(ccache):
+ return None
+ return ccache
+
+
+def ccache_defaults_env(cache_dir: Path) -> dict[str, str]:
+ """Default ``CCACHE_*`` values for a build subprocess (not os.environ).
+
+ Values the user already set in the environment are respected. Depend
+ mode is on: both native backends emit depfiles (-MMD / CMake), which
+ keeps cache-miss overhead low.
+ """
+ from esphome.core import CORE
+
+ # An unset build_path means the env was built before preload; fail loudly
+ # rather than silently drop CCACHE_BASEDIR.
+ if CORE.build_path is None:
+ raise ValueError(
+ "CORE.build_path must be set before constructing the build environment"
+ )
+ defaults = {
+ "CCACHE_DIR": str(cache_dir),
+ "CCACHE_NOHASHDIR": "true",
+ "CCACHE_DEPEND": "1",
+ "CCACHE_BASEDIR": str(Path(CORE.build_path).resolve()),
+ }
+ return {k: v for k, v in defaults.items() if k not in os.environ}
diff --git a/esphome/build_helpers/idedata.py b/esphome/build_helpers/idedata.py
new file mode 100644
index 0000000000..038fe64970
--- /dev/null
+++ b/esphome/build_helpers/idedata.py
@@ -0,0 +1,380 @@
+"""Derive idedata from a native (non-PlatformIO) build's ``compile_commands.json``.
+
+PlatformIO exposes a curated ``pio run -t idedata`` JSON; the native
+toolchains have no such command, but each build produces a
+``compile_commands.json`` (CMAKE_EXPORT_COMPILE_COMMANDS for ESP-IDF, ninja's
+compdb tool otherwise). This module turns that file into the same fields
+consumers (IDE integration, clang-tidy) expect:
+
+ {cc_path, cxx_path, cxx_flags, defines, includes: {build, toolchain}}
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+from pathlib import Path
+import shlex
+import subprocess
+
+from esphome.core import EsphomeError
+from esphome.helpers import write_file
+
+# Everything idedata generation may raise after a successful link; idedata
+# is a bonus artifact, so consumers warn instead of failing the build
+IDEDATA_BEST_EFFORT_ERRORS = (
+ EsphomeError,
+ LookupError,
+ OSError,
+ RuntimeError,
+ ValueError,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+# C++ translation-unit suffixes used to identify ESPHome source files.
+_CXX_SUFFIXES = (".cpp", ".cc")
+# Suffixes of input/output files that appear bare on the command line (and so
+# must not be mistaken for compiler flags).
+_INPUT_FILE_SUFFIXES = (*_CXX_SUFFIXES, ".c", ".o", ".S", ".s")
+# Path marker identifying an ESPHome source translation unit.
+_ESPHOME_SRC_MARKER = "/src/esphome/"
+
+
+def _is_esphome_src(file: str) -> bool:
+ """Whether ``file`` is an ESPHome C++ translation unit; normalized to
+ ``/`` first since Windows compile DBs use backslashes."""
+ return _ESPHOME_SRC_MARKER in file.replace("\\", "/") and file.endswith(
+ _CXX_SUFFIXES
+ )
+
+
+def _split_command(command: str) -> list[str]:
+ r"""Tokenize a compile_commands.json / response-file command string.
+
+ On Windows, tokenize per Windows ``argv`` rules via ``CommandLineToArgvW``.
+ ESP-IDF's compile_commands.json there mixes two backslash conventions in one
+ string: literal path separators in the compiler path (``C:\Users\...g++.exe``,
+ no quote follows) and shell quote-escaping in -D defines (``-DVER=\"1.2.3\"``).
+ Only the real Windows parser — where a backslash escapes solely a following
+ quote — handles both, and it is the exact tokenizer the compiler is launched
+ with. ``shlex`` cannot: POSIX mode eats the path separators, and disabling
+ its escape mangles the defines.
+ """
+ if os.name != "nt":
+ return shlex.split(command)
+
+ import ctypes
+ from ctypes import wintypes
+
+ # CommandLineToArgvW("") returns the current process name, not []; guard it
+ # so an empty response file tokenizes the same as it would via shlex.
+ if not command.strip():
+ return []
+
+ CommandLineToArgvW = ctypes.windll.shell32.CommandLineToArgvW
+ CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)]
+ CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR)
+ argc = ctypes.c_int()
+ argv = CommandLineToArgvW(command, ctypes.byref(argc))
+ if not argv: # pragma: no cover
+ raise ctypes.WinError()
+ try:
+ return [argv[i] for i in range(argc.value)]
+ finally:
+ ctypes.windll.kernel32.LocalFree(argv)
+
+
+def _expand_response_files(tokens: list[str], directory: Path) -> list[str]:
+ """Inline any ``@response-file`` arguments (paths relative to ``directory``).
+
+ GCC response files embed flags that must be expanded so GCC-only flags
+ inside them (e.g. ``-mlongcalls``) can be filtered downstream; left as
+ ``@file`` clang would read them and choke.
+ """
+ out: list[str] = []
+ for tok in tokens:
+ if tok.startswith("@"):
+ rf = Path(tok[1:])
+ if not rf.is_absolute():
+ rf = directory / rf
+ try:
+ out.extend(
+ _expand_response_files(
+ _split_command(rf.read_text(encoding="utf-8")), directory
+ )
+ )
+ continue
+ except OSError as err:
+ # Keep the literal token if the file can't be read, but log it
+ # so the (otherwise opaque) downstream clang failure is traceable.
+ _LOGGER.warning("Could not read response file %s: %s", rf, err)
+ out.append(tok)
+ return out
+
+
+def _pick_entry(entries: list[dict]) -> dict:
+ """Pick a representative ESPHome C++ TU; all share the same component
+ flags/defines."""
+ for entry in entries:
+ if _is_esphome_src(entry["file"]):
+ return entry
+ for entry in entries:
+ if entry["file"].endswith(_CXX_SUFFIXES):
+ return entry
+ raise ValueError("no C++ translation unit found in compile_commands.json")
+
+
+# Compiler launchers that may prefix a compile command; a closed launcher
+# denylist beats enumerating compiler names, an open set.
+_LAUNCHER_STEMS = frozenset({"ccache", "sccache", "distcc", "icecc", "buildcache"})
+
+
+def _is_launcher(token: str) -> bool:
+ return Path(token).stem.lower() in _LAUNCHER_STEMS
+
+
+def parse_entry(
+ entry: dict, launcher: str | None = None
+) -> tuple[str, list[str], list[str], list[str]]:
+ """Parse one compile_commands entry -> (cxx_path, defines, includes, cxx_flags)."""
+ directory = Path(entry["directory"])
+ tokens = _expand_response_files(_split_command(entry["command"]), directory)
+
+ def _include(raw: str) -> str:
+ # Resolve against the entry's ``directory`` so cached idedata works
+ # from any cwd; emit forward slashes to match the JSON's own entries
+ raw = raw.strip()
+ if raw and not Path(raw).is_absolute():
+ raw = os.path.normpath(directory / raw)
+ return raw.replace("\\", "/")
+
+ # A launcher-wrapped command ("ccache g++ ...") names the compiler second
+ if launcher is not None and tokens[:1] == [launcher]:
+ tokens = tokens[1:]
+ if not tokens:
+ # An empty command, or one that was only the launcher; fail by name
+ raise ValueError(f"empty compile command for {entry.get('file')}")
+ if _is_launcher(tokens[0]) and len(tokens) > 1 and not tokens[1].startswith("-"):
+ # Stale DB built with a launcher this run no longer configures; the
+ # real compiler is the next token
+ _LOGGER.warning("Stripping unconfigured launcher %s", tokens[0])
+ tokens = tokens[1:]
+ # token0 is the compiler path; the rest of the command already uses forward
+ # slashes on Windows, so normalize it too for a consistent idedata file.
+ cxx_path = tokens[0].replace("\\", "/")
+ # Enforced here so no caller can record ccache as the compiler
+ reject_launcher_compiler(cxx_path)
+ defines: list[str] = []
+ includes: list[str] = []
+ cxx_flags: list[str] = []
+
+ it = iter(tokens[1:])
+ for tok in it:
+ if tok in ("-c", "-o"):
+ next(it, None) # drop the flag and its argument (input/output)
+ elif tok.startswith("-D"):
+ # ``.strip()`` handles tokens like ``-D CONFIGURED=1`` (a single
+ # quoted arg with a space after -D) that some flags arrive as.
+ defines.append(tok[2:].strip() if len(tok) > 2 else next(it, "").strip())
+ elif tok.startswith("-I"):
+ includes.append(_include(tok[2:] if len(tok) > 2 else next(it, "")))
+ elif tok == "-isystem":
+ includes.append(_include(next(it, "")))
+ elif tok.startswith("-isystem"):
+ includes.append(_include(tok[len("-isystem") :]))
+ elif tok in ("-MT", "-MF", "-MQ"):
+ next(it, None) # dependency-file flag + its argument
+ elif tok.startswith(("-MD", "-MMD", "-MP", "-MM")):
+ pass # dependency-generation flags, no argument
+ elif tok.endswith(_INPUT_FILE_SUFFIXES):
+ pass # input/output files
+ else:
+ cxx_flags.append(tok)
+ return cxx_path, defines, includes, cxx_flags
+
+
+def get_toolchain_includes(cxx_path: str) -> list[str]:
+ """Query the compiler for its builtin ``#include <...>`` search dirs."""
+ result = subprocess.run(
+ [cxx_path, "-E", "-x", "c++", "-", "-v"],
+ input="",
+ text=True,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ check=False,
+ close_fds=False,
+ )
+ includes: list[str] = []
+ capture = False
+ for line in result.stderr.splitlines():
+ if "#include <...> search starts here:" in line:
+ capture = True
+ continue
+ if "End of search list." in line:
+ break
+ if capture:
+ includes.append(line.strip())
+ if result.returncode != 0 or not includes:
+ raise RuntimeError(
+ f"Could not query builtin include dirs from {cxx_path} "
+ f"(return code {result.returncode}); stderr:\n{result.stderr.strip()}"
+ )
+ return includes
+
+
+def _cc_path_from_cxx(cxx_path: str) -> str:
+ """Derive the C compiler path from the C++ compiler path.
+
+ compile_commands.json only names the C++ compiler, but consumers reach the
+ rest of the toolchain (objdump, readelf, addr2line) by rewriting the tail of
+ ``cc_path``, so they need the ``gcc``-suffixed name.
+ """
+ stem, suffix = (
+ (cxx_path[: -len(".exe")], ".exe")
+ if cxx_path.endswith(".exe")
+ else (cxx_path, "")
+ )
+ # Rewrite the program name only when it is g++ itself, or a toolchain
+ # prefixed one such as xtensa-esp32-elf-g++ -> xtensa-esp32-elf-gcc.
+ # Requiring a separator before the "g++" keeps names that merely end in
+ # those three characters intact: "clang++" must not become "clangcc".
+ head = stem[: -len("g++")]
+ if stem.endswith("g++") and (not head or head.endswith(("-", "/", "\\"))):
+ stem = f"{head}gcc"
+ return f"{stem}{suffix}"
+
+
+def _cache_usable(cached: object) -> bool:
+ """Check a cached idedata dict against the guarantees of the write path.
+
+ Caches written by older versions predate the launcher rejection and the
+ include-union shape; serving one would bypass both. The dict check also
+ keeps "in" from substring-matching a bare JSON string.
+ """
+ if not isinstance(cached, dict) or "cc_path" not in cached:
+ return False
+ cxx_path = cached.get("cxx_path")
+ if not isinstance(cxx_path, str) or _is_launcher(cxx_path):
+ return False
+ includes = cached.get("includes")
+ return isinstance(includes, dict) and isinstance(includes.get("build"), list)
+
+
+def load_or_build_idedata(
+ compile_commands: Path,
+ elf_path: Path,
+ cache: Path,
+ launcher: str | None = None,
+) -> dict | None:
+ """Return idedata for a compile_commands.json build, cached on mtime.
+
+ Shared by the native ESP-IDF and ESP8266 Arduino toolchains. Returns None
+ when the compile DB doesn't exist yet (nothing was built). ``launcher``
+ is the compiler-launcher path (ccache) the build was generated with, if
+ any; commands in the compile DB are prefixed with it.
+ """
+ if not compile_commands.is_file():
+ _LOGGER.debug("No %s yet; skipping idedata generation", compile_commands)
+ return None
+
+ if cache.is_file() and cache.stat().st_mtime >= compile_commands.stat().st_mtime:
+ try:
+ cached = json.loads(cache.read_text(encoding="utf-8"))
+ except (ValueError, OSError) as err:
+ # A recurring cause (interrupted write, disk full) would otherwise
+ # look like unexplained slow builds
+ _LOGGER.warning("Discarding unreadable idedata cache %s: %s", cache, err)
+ else:
+ if _cache_usable(cached):
+ # Re-stamp so a relocated build dir cannot serve a stale ELF path
+ cached["prog_path"] = str(elf_path)
+ return cached
+ _LOGGER.debug("Regenerating idedata: cache %s fails validation", cache)
+
+ data = idedata_from_build(compile_commands, launcher)
+ data["prog_path"] = str(elf_path)
+ cache.parent.mkdir(parents=True, exist_ok=True)
+ # Atomic so a crash mid-write cannot leave a truncated cache
+ write_file(cache, json.dumps(data, indent=2) + "\n")
+ return data
+
+
+def reject_launcher_compiler(cxx_path: str) -> None:
+ """Reject a compile DB naming a launcher (ccache) as the compiler; it
+ must never be probed, cached, or consumed."""
+ if _is_launcher(cxx_path):
+ raise EsphomeError(
+ f"compile_commands.json names the launcher {cxx_path} as the "
+ "compiler; the compile database is unusable"
+ )
+
+
+def idedata_from_build(compile_commands: Path, launcher: str | None = None) -> dict:
+ """Parse compile_commands.json into the idedata fields consumers expect.
+
+ A single compile entry only carries the include set its own translation
+ unit was built with (per-component under ESP-IDF), but consumers
+ (clang-tidy) analyze ESPHome headers that transitively pull in other
+ components. So take cxx_path / cxx_flags / defines from a representative
+ ESPHome TU, but union the include dirs across all ESPHome TUs to get a
+ project-wide superset (as PlatformIO's idedata provides).
+ """
+ entries = json.loads(Path(compile_commands).read_text(encoding="utf-8"))
+ if not isinstance(entries, list) or not all(isinstance(e, dict) for e in entries):
+ # A TypeError here would escape IDEDATA_BEST_EFFORT_ERRORS
+ raise EsphomeError(f"{compile_commands} is not a compile-command list")
+
+ representative = _pick_entry(entries)
+ cxx_path, defines, rep_includes, cxx_flags = parse_entry(representative, launcher)
+
+ # Seed with the representative's includes so it is not parsed twice
+ has_esphome_tu = _is_esphome_src(representative["file"])
+ build_includes: dict[str, None] = dict.fromkeys(
+ rep_includes if has_esphome_tu else ()
+ )
+
+ def _shape(entry: dict) -> str:
+ # directory + command minus TU-specific paths: same shape means the
+ # same include set, so tokenize once per shape. Response-file
+ # commands never dedupe (the .rsp contents differ per object)
+ command = entry["command"]
+ directory = entry.get("directory", "")
+ if "@" in command:
+ return f"unique:{directory}|{entry.get('output') or command}"
+ stripped = command.replace(entry.get("file", ""), "").replace(
+ entry.get("output", ""), ""
+ )
+ return f"{directory}|{stripped}"
+
+ seen_shapes = {_shape(representative)}
+ for entry in entries:
+ if entry is representative or not _is_esphome_src(entry["file"]):
+ continue
+ has_esphome_tu = True
+ if (shape := _shape(entry)) in seen_shapes:
+ _LOGGER.debug("Include union: %s shares a command shape", entry["file"])
+ continue
+ seen_shapes.add(shape)
+ for inc in parse_entry(entry, launcher)[2]:
+ build_includes.setdefault(inc, None)
+
+ if not has_esphome_tu:
+ # An arbitrary fallback TU breaks clang-tidy/IDE consumers, and a
+ # warning would be cached into permanence; call sites downgrade this
+ raise EsphomeError(
+ f"No ESPHome translation unit found in {compile_commands}; "
+ "refusing to cache unusable idedata"
+ )
+
+ return {
+ "cc_path": _cc_path_from_cxx(cxx_path),
+ "cxx_path": cxx_path,
+ "cxx_flags": cxx_flags,
+ "defines": defines,
+ "includes": {
+ "build": list(build_includes),
+ "toolchain": get_toolchain_includes(cxx_path),
+ },
+ }
diff --git a/esphome/build_helpers/ninja.py b/esphome/build_helpers/ninja.py
new file mode 100644
index 0000000000..8c25bc9513
--- /dev/null
+++ b/esphome/build_helpers/ninja.py
@@ -0,0 +1,92 @@
+"""Platform-neutral helpers for ninja-driven native builds."""
+
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+import re
+import shutil
+
+from esphome.core import EsphomeError
+from esphome.framework_helpers import strip_win_long_path_prefix, tool_version_runs
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _ninja_runs(binary: str) -> bool:
+ """Whether the ninja found on PATH actually runs (see tool_version_runs)."""
+ return tool_version_runs(
+ binary,
+ "Ignoring ninja at %s because it failed to run; "
+ "falling back to the bundled wheel",
+ )
+
+
+def find_ninja() -> Path:
+ """Locate the ninja binary: a runnable PATH hit first, else the ninja
+ PyPI wheel."""
+ if binary := shutil.which("ninja"):
+ binary = strip_win_long_path_prefix(binary)
+ if _ninja_runs(binary):
+ return Path(binary)
+ import_error: ImportError | None = None
+ try:
+ import ninja
+ except ImportError as err:
+ import_error = err
+ wheel_binary = None
+ else:
+ wheel_binary = Path(ninja.BIN_DIR) / (
+ "ninja.exe" if os.name == "nt" else "ninja"
+ )
+ if wheel_binary is None or not wheel_binary.is_file():
+ raise EsphomeError(
+ "ninja not found on PATH or in the ninja package; reinstall the "
+ "esphome Python environment"
+ ) from import_error
+ return wheel_binary
+
+
+def escape(value: Path | str) -> str:
+ """Escape a path or token for a ninja file."""
+ return str(value).replace("$", "$$").replace(":", "$:").replace(" ", "$ ")
+
+
+def quote_arg(tok: str) -> str:
+ """Quote with the CreateProcess argv rule (as ``subprocess.list2cmdline``):
+ backslash runs double only before a quote. Windows-only; ``$`` must
+ already be doubled for ninja.
+ """
+ quoted = re.sub(r'(\\*)"', lambda m: m.group(1) * 2 + '\\"', tok)
+ quoted = re.sub(r"(\\+)\Z", lambda m: m.group(1) * 2, quoted)
+ return f'"{quoted}"'
+
+
+# Force-quote any token containing a character outside the shlex.quote-style
+# safe set: ninja hands POSIX commands to /bin/sh -c, so bare (, ;, <, *, `
+# and friends would be re-parsed as shell syntax.
+_NEEDS_QUOTE = re.compile(r"[^\w@%+=:,./-]")
+
+
+def shell_token(tok: str, force: bool = False) -> str:
+ """Re-quote a lexed token for the platform shell; ``force`` always quotes.
+
+ Single quotes on POSIX (/bin/sh), the argv rule on Windows
+ (CreateProcess). ``$`` is doubled first because ninja expands it before
+ the command reaches the shell.
+ """
+ tok = tok.replace("$", "$$") # ninja would expand a bare $ to nothing
+ if not (force or not tok or _NEEDS_QUOTE.search(tok)):
+ return tok
+ # An empty token must become '' / "" or it vanishes from the argv
+ if os.name == "nt":
+ return quote_arg(tok)
+ # shlex.quote's rule; inlined because the $-doubled token must not be
+ # re-examined for safe characters
+ return "'" + tok.replace("'", "'\"'\"'") + "'"
+
+
+def quote_path(value: Path | str) -> str:
+ """Force-quote a path for the ninja command line (shell/CreateProcess)."""
+ return shell_token(str(value), force=True)
diff --git a/esphome/build_helpers/size_summary.py b/esphome/build_helpers/size_summary.py
new file mode 100644
index 0000000000..b888111044
--- /dev/null
+++ b/esphome/build_helpers/size_summary.py
@@ -0,0 +1,24 @@
+"""The PlatformIO-format size bar shared by the native toolchains."""
+
+from __future__ import annotations
+
+
+def format_bar(used: int, total: int) -> str:
+ """Match PlatformIO's ``_format_availale_bytes`` (sic, pioupload.py) exactly."""
+ pct_raw = used / total if total else 0
+ blocks = 10
+ filled = min(int(round(blocks * pct_raw)), blocks)
+ progress = "=" * filled
+ return (
+ f"[{progress:<{blocks}}] {pct_raw: 6.1%} "
+ f"(used {used:d} bytes from {total:d} bytes)"
+ )
+
+
+def print_size_line(label: str, used: int, total: int) -> None:
+ """One PlatformIO-format summary line (``RAM``/``Flash``).
+
+ The label padding is part of the format: ``script/ci_memory_impact_extract.py``
+ matches these lines verbatim.
+ """
+ print(f"{label + ':':<7}{format_bar(used, total)}")
diff --git a/esphome/build_helpers/tools_cache.py b/esphome/build_helpers/tools_cache.py
new file mode 100644
index 0000000000..e7193a8e2a
--- /dev/null
+++ b/esphome/build_helpers/tools_cache.py
@@ -0,0 +1,36 @@
+"""Machine-global tools cache location shared by the native backends."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+
+def tools_cache_path(env_var: str, subdir: str) -> Path:
+ """A backend's machine-global tools directory, with an env override.
+
+ A blank/whitespace override is treated as unset: ``Path("")`` resolves
+ to the CWD, which ``clean-all`` would then delete.
+ """
+ import platformdirs
+
+ from esphome.helpers import get_str_env
+
+ if prefix := get_str_env(env_var, "").strip():
+ # resolve(): symlinked prefixes otherwise trip idf.py's
+ # venv-mismatch warning on every build
+ return Path(prefix).expanduser().resolve()
+ # appauthor=False keeps the Windows path short (no vendor segment);
+ # deep IDF trees run into MAX_PATH otherwise
+ return (
+ Path(platformdirs.user_cache_dir("esphome", appauthor=False)) / subdir
+ ).resolve()
+
+
+# (env override, cache subdir) per native backend. writer.clean_all wipes
+# every entry via tools_cache_path, so listing a cache here is the single
+# step that registers it for removal; the backends' own path getters use
+# the same named pairs so the two cannot drift.
+IDF_TOOLS_CACHE = ("ESPHOME_ESP_IDF_PREFIX", "idf")
+SDK_NRF_TOOLS_CACHE = ("ESPHOME_SDK_NRF_PREFIX", "sdk-nrf")
+ARDUINO8266_TOOLS_CACHE = ("ESPHOME_ARDUINO8266_PREFIX", "arduino8266")
+TOOLS_CACHE_SPECS = (IDF_TOOLS_CACHE, SDK_NRF_TOOLS_CACHE, ARDUINO8266_TOOLS_CACHE)
diff --git a/esphome/bundle.py b/esphome/bundle.py
index 70c4fad0fd..b633c5ca4f 100644
--- a/esphome/bundle.py
+++ b/esphome/bundle.py
@@ -7,12 +7,12 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from enum import StrEnum
import io
import json
import logging
-from pathlib import Path
+from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
import re
import shutil
import tarfile
@@ -20,6 +20,7 @@ from typing import Any
from esphome import const, yaml_util
from esphome.const import (
+ BUNDLE_EXTENSION,
CONF_ESPHOME,
CONF_EXTERNAL_COMPONENTS,
CONF_INCLUDES,
@@ -29,10 +30,12 @@ from esphome.const import (
CONF_TYPE,
)
from esphome.core import CORE, EsphomeError
+from esphome.util import filter_yaml_files
_LOGGER = logging.getLogger(__name__)
-BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
+DOMAIN = "bundle"
+
MANIFEST_FILENAME = "manifest.json"
CURRENT_MANIFEST_VERSION = 1
MAX_DECOMPRESSED_SIZE = 500 * 1024 * 1024 # 500 MB
@@ -49,6 +52,7 @@ class ManifestKey(StrEnum):
MANIFEST_VERSION = "manifest_version"
ESPHOME_VERSION = "esphome_version"
CONFIG_FILENAME = "config_filename"
+ CONFIG_DIR = "config_dir"
FILES = "files"
HAS_SECRETS = "has_secrets"
@@ -120,6 +124,153 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
return keys
+@dataclass
+class BundleData:
+ """Files components asked to include, keyed under DOMAIN in CORE.data."""
+
+ extra_files: list[Path] = field(default_factory=list)
+ # Directories whose YAML files are scanned for !secret references but
+ # never bundled, e.g. git package checkouts the builder re-fetches.
+ secret_scan_dirs: set[Path] = field(default_factory=set)
+ # Original config dir parsed from an extracted bundle's manifest.json,
+ # kept in the path flavor of the machine the bundle was created on.
+ # The checked flag makes the manifest lookup happen at most once per run;
+ # 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))
+
+
+def add_secret_scan_dir(path: Path) -> None:
+ """Register a directory to scan for ``!secret`` references when bundling.
+
+ The directory's files are not added to the bundle. Components call this
+ for YAML the build consumes without bundling it — such as git-fetched
+ packages, which the builder re-fetches — so the secrets those files
+ reference are still shipped in the filtered secrets file.
+
+ A relative path is taken as relative to the config directory.
+ """
+ if not path.is_absolute():
+ path = CORE.relative_config_path(path)
+ _get_data().secret_scan_dirs.add(path)
+
+
+def _secret_scan_yaml_files() -> list[Path]:
+ """Return the YAML files inside registered secret-scan directories."""
+ return filter_yaml_files(
+ f
+ for scan_dir in _get_data().secret_scan_dirs
+ for f in yaml_util.find_files(scan_dir, "*")
+ )
+
+
+# Windows paths start with a drive letter or contain backslashes; POSIX
+# paths do neither in practice, so this is how the flavor of a recorded
+# path string is recognized on any host.
+_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."""
@@ -146,6 +297,7 @@ class BundleManifest:
config_filename: str
files: list[str]
has_secrets: bool
+ config_dir: str | None = None
class ConfigBundleCreator:
@@ -186,6 +338,7 @@ class ConfigBundleCreator:
yaml_sources = [
bf.source for bf in files if bf.source.suffix in (".yaml", ".yml")
]
+ yaml_sources.extend(_secret_scan_yaml_files())
used_secret_keys = _find_used_secret_keys(yaml_sources)
filtered_secrets = self._build_filtered_secrets(used_secret_keys)
@@ -260,42 +413,27 @@ class ConfigBundleCreator:
def _discover_yaml_includes(self) -> None:
"""Discover YAML files loaded during config parsing.
- Deliberately uses a fresh re-parse and force-loads every deferred
- ``IncludeFile`` to include *all* potentially-reachable includes,
- even branches not selected by the local substitutions. Bundles are
- meant to be compiled on another system where command-line
- substitution overrides may choose a different branch — e.g.
- ``!include network/${eth_model}/config.yaml`` must ship every
- candidate so the remote build can pick any one.
-
- Entries with unresolved substitution variables in the filename
- path are skipped with a warning (they cannot be resolved without
- the substitution pass).
-
- Secrets files are tracked separately so we can filter them to
- only include the keys this config actually references.
+ Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a
+ fresh re-parse and force-loads every deferred ``IncludeFile`` so that
+ *all* potentially-reachable includes are captured (even branches not
+ selected by local substitutions). Bundles are meant to be compiled on
+ another system where command-line substitution overrides may choose a
+ different branch — e.g. ``!include network/${eth_model}/config.yaml``
+ must ship every candidate so the remote build can pick any one.
"""
- # Must be a fresh parse: IncludeFile.load() caches its result in
- # _content, and we discover files by listening for loader calls. On
- # an already-parsed tree the cache is populated, .load() returns
- # without calling the loader, the listener never fires, and the
- # referenced files would be silently dropped from the bundle.
- with yaml_util.track_yaml_loads() as loaded_files:
- try:
- data = yaml_util.load_yaml(self._config_path)
- except EsphomeError:
- _LOGGER.debug(
- "Bundle: re-loading YAML for include discovery failed, "
- "proceeding with partial file list"
- )
- else:
- _force_load_include_files(data)
-
- for fpath in loaded_files:
- if fpath == self._config_path.resolve():
+ discovered = yaml_util.discover_user_yaml_files(self._config_path)
+ self._secrets_paths.update(discovered.secrets)
+ # A !secret inside a file this re-parse does not reach (for example
+ # a git-fetched package the builder re-fetches) still resolves
+ # against the config-dir secrets.yaml at build time, so always
+ # consider that file; filtering no-ops when no key matches.
+ default_secrets = self._config_dir / yaml_util.SECRET_YAML
+ if default_secrets.is_file():
+ self._secrets_paths.add(default_secrets.resolve())
+ config_resolved = self._config_path.resolve()
+ for fpath in discovered.files:
+ if fpath == config_resolved:
continue # Already added as config
- if fpath.name in const.SECRETS_FILES:
- self._secrets_paths.add(fpath)
self._add_file(fpath)
def _discover_component_files(self) -> None:
@@ -308,13 +446,18 @@ class ConfigBundleCreator:
with known file extensions are also resolved and checked.
Core ESPHome concepts that use relative paths or directories
- are handled explicitly.
+ are handled explicitly. Files the config does not name at all are
+ registered by their component with add_bundle_file().
"""
config = self._config
# Generic walk: find all file paths in the validated config
self._walk_config_for_files(config)
+ # Files registered by components during validation
+ for extra_file in _get_data().extra_files:
+ self._add_file(extra_file)
+
# --- Core ESPHome concepts needing explicit handling ---
# esphome.includes / includes_c - can be relative paths and directories
@@ -427,6 +570,7 @@ class ConfigBundleCreator:
ManifestKey.MANIFEST_VERSION: CURRENT_MANIFEST_VERSION,
ManifestKey.ESPHOME_VERSION: const.__version__,
ManifestKey.CONFIG_FILENAME: self._config_path.name,
+ ManifestKey.CONFIG_DIR: str(self._config_dir),
ManifestKey.FILES: [f.path for f in files],
ManifestKey.HAS_SECRETS: has_secrets,
}
@@ -434,7 +578,7 @@ class ConfigBundleCreator:
@staticmethod
def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None:
"""Add a BundleFile to the tar archive with deterministic metadata."""
- with open(bf.source, "rb") as f:
+ with bf.source.open("rb") as f:
_add_bytes_to_tar(tar, bf.path, f.read())
@@ -511,12 +655,14 @@ def read_bundle_manifest(bundle_path: Path) -> BundleManifest:
except tarfile.TarError as err:
raise EsphomeError(f"Failed to read bundle: {err}") from err
+ config_dir = manifest.get(ManifestKey.CONFIG_DIR)
return BundleManifest(
manifest_version=manifest[ManifestKey.MANIFEST_VERSION],
esphome_version=manifest.get(ManifestKey.ESPHOME_VERSION, "unknown"),
config_filename=manifest[ManifestKey.CONFIG_FILENAME],
files=manifest.get(ManifestKey.FILES, []),
has_secrets=manifest.get(ManifestKey.HAS_SECRETS, False),
+ config_dir=config_dir if isinstance(config_dir, str) else None,
)
@@ -609,11 +755,6 @@ def _validate_tar_members(tar: tarfile.TarFile, target_dir: Path) -> None:
)
-def is_bundle_path(path: Path) -> bool:
- """Check if a path looks like a bundle file."""
- return path.name.lower().endswith(BUNDLE_EXTENSION)
-
-
def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
"""Add in-memory bytes to a tar archive with deterministic metadata."""
info = tarfile.TarInfo(name=name)
@@ -625,57 +766,6 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
tar.addfile(info, io.BytesIO(data))
-def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
- """Recursively resolve any ``IncludeFile`` instances in a YAML tree.
-
- Nested ``!include`` returns a deferred ``IncludeFile`` that is only
- resolved during the substitution pass. During bundle discovery we need
- the referenced files to actually load so the ``track_yaml_loads``
- listener fires for them.
-
- ``IncludeFile`` instances with unresolved substitution variables in the
- filename cannot be loaded — we skip and warn about those.
- """
- if _seen is None:
- _seen = set()
-
- if isinstance(obj, yaml_util.IncludeFile):
- if id(obj) in _seen:
- return
- _seen.add(id(obj))
- if obj.has_unresolved_expressions():
- _LOGGER.warning(
- "Bundle: cannot resolve !include %s (referenced from %s) "
- "with substitutions in path",
- obj.file,
- obj.parent_file,
- )
- return
- try:
- loaded = obj.load()
- except EsphomeError as err:
- _LOGGER.warning(
- "Bundle: failed to load !include %s (referenced from %s): %s",
- obj.file,
- obj.parent_file,
- err,
- )
- return
- _force_load_include_files(loaded, _seen)
- elif isinstance(obj, dict):
- if id(obj) in _seen:
- return
- _seen.add(id(obj))
- for value in obj.values():
- _force_load_include_files(value, _seen)
- elif isinstance(obj, (list, tuple)):
- if id(obj) in _seen:
- return
- _seen.add(id(obj))
- for item in obj:
- _force_load_include_files(item, _seen)
-
-
def _resolve_include_path(include_path: Any) -> Path | None:
"""Resolve an include path to absolute, skipping system includes."""
if isinstance(include_path, str) and include_path.startswith("<"):
diff --git a/esphome/codegen.py b/esphome/codegen.py
index a5b5abe447..2aa6a70abd 100644
--- a/esphome/codegen.py
+++ b/esphome/codegen.py
@@ -25,6 +25,8 @@ from esphome.cpp_generator import ( # noqa: F401
add,
add_build_flag,
add_build_unflag,
+ add_cmake_arg,
+ add_cxx_build_flag,
add_define,
add_global,
add_library,
@@ -48,10 +50,13 @@ from esphome.cpp_helpers import ( # noqa: F401
build_registry_entry,
build_registry_list,
extract_registry_entry_config,
+ get_slot_count,
gpio_pin_expression,
past_safe_mode,
register_component,
register_parented,
+ set_setup_priority,
+ slot_counter,
)
from esphome.cpp_types import ( # noqa: F401
NAN,
diff --git a/esphome/compiled_config.py b/esphome/compiled_config.py
new file mode 100644
index 0000000000..0d855d71db
--- /dev/null
+++ b/esphome/compiled_config.py
@@ -0,0 +1,219 @@
+"""Validated-config cache for the upload/logs fast path.
+
+compile dumps the validated config to /storage/.validated.json;
+the next upload/logs for that YAML reuses it instead of running the full
+read_config pipeline. The cache is deliberately lossy: only ``!lambda``
+bodies survive typed (``Lambda``); IDs, time periods, MAC/IP addresses,
+paths, UUIDs and enums store the same string form the YAML dumper
+produced for them. JSON additionally coerces non-str dict keys to
+strings; validated configs only use string keys (every schema key
+validator is ``cv.string``). mtime gates staleness.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+from pathlib import Path
+from typing import Any
+
+from esphome.const import __version__ as ESPHOME_VERSION
+from esphome.core import CORE, EsphomeError, Lambda
+from esphome.helpers import write_file
+from esphome.storage_json import StorageJSON, ext_storage_path, storage_path
+from esphome.types import ConfigType
+
+_LOGGER = logging.getLogger(__name__)
+
+# Bump when the on-disk shape changes; a mismatched version falls back
+# to read_config. The envelope also stamps the writing esphome version:
+# after an upgrade the cache holds the previous release's validation, so
+# it falls back once and the re-save self-heals.
+_CACHE_VERSION = 1
+_LAMBDA_KEY = "__esphome_lambda__"
+
+
+def compiled_config_path(config_filename: str) -> Path:
+ """Path to the cached validated config alongside the storage sidecar."""
+ return CORE.data_dir / "storage" / f"{config_filename}.validated.json"
+
+
+def save_compiled_config(config: ConfigType) -> None:
+ """Write the validated-config cache. Always-write so mtime stays fresh.
+
+ Mode 0600 because config validation resolved !secret inline.
+ Failures are non-fatal: the fast path falls back to read_config.
+ """
+ try:
+ # The legacy YAML cache holds inline-resolved secrets and nothing
+ # reads it anymore; drop it even when the write below fails. A
+ # failed removal leaves resolved secrets on disk, so it warns.
+ try:
+ _legacy_compiled_config_path(CORE.config_filename).unlink(missing_ok=True)
+ except OSError as err:
+ _LOGGER.warning(
+ "Could not remove the legacy validated-config cache: %s", err
+ )
+ rendered = json.dumps(
+ {"v": _CACHE_VERSION, "esphome": ESPHOME_VERSION, "config": config},
+ separators=(",", ":"),
+ default=_json_default,
+ )
+ write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
+ except TypeError as err:
+ # Structural, not transient: this config can never cache (e.g. a
+ # non-basic dict key), so every upload/logs pays the slow path.
+ _LOGGER.warning("Cannot cache the validated config: %s", err)
+ except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
+ # Likely persistent (permissions, full disk): every upload/logs
+ # pays the slow path until it clears, so surface it.
+ _LOGGER.warning("Skipping compiled config cache write: %s", err)
+
+
+def save_compiled_config_and_sidecar(config: ConfigType) -> None:
+ """Refresh the cache from the upload/logs fallback (CORE.config must be set).
+
+ The cache is only written when a complete sidecar is on disk:
+ load_compiled_config can't use it otherwise, and it holds resolved
+ secrets.
+ """
+ if _refresh_sidecar():
+ save_compiled_config(config)
+
+
+def _refresh_sidecar() -> bool:
+ """Ensure a complete sidecar is on disk; True when one is.
+
+ Writes one (without claiming a build) when missing or wizard-only.
+ Failures are non-fatal; the next upload/logs pays the slow path again.
+ """
+ try:
+ path = storage_path()
+ try:
+ old = StorageJSON.load_strict(path)
+ except Exception as err: # noqa: BLE001 # pylint: disable=broad-except
+ # Present but unreadable: it may hold a real build's metadata,
+ # and a fresh rewrite would also stop the next compile from
+ # cleaning a possibly incoherent build tree.
+ _LOGGER.warning(
+ "Not caching: storage sidecar %s is unreadable (%s)", path, err
+ )
+ return False
+ if old is not None and old.can_apply_to_core():
+ if (
+ old.toolchain is not None
+ and CORE.toolchain is not None
+ and old.toolchain != CORE.toolchain.value
+ ):
+ # Platforms normalize toolchain-sensitive keys differently;
+ # never cache a config validated under a different toolchain
+ # than the compile's
+ _LOGGER.debug(
+ "Not caching: config validated with toolchain %r but the "
+ "last compile used %r",
+ CORE.toolchain.value,
+ old.toolchain,
+ )
+ return False
+ # Compile-written; nothing to refresh.
+ return True
+ if CORE.build_path is not None and CORE.build_path.exists():
+ # An unvalidated build tree: its absent or mismatched sidecar
+ # is what makes the next compile wipe it, so don't vouch for
+ # a build this run never saw.
+ _LOGGER.warning(
+ "Not caching: build tree %s has no matching sidecar; "
+ "'esphome compile' will settle it",
+ CORE.build_path,
+ )
+ return False
+ new = StorageJSON.from_esphome_core(CORE, old, claim_build=False)
+ if not new.can_apply_to_core():
+ _LOGGER.warning("Not caching: rebuilt storage sidecar is still incomplete")
+ return False
+ new.save(path)
+ return True
+ except (OSError, EsphomeError) as err:
+ # write_file wraps OSError into EsphomeError. Persistent
+ # (unwritable storage dir), so surface that every upload/logs
+ # pays the slow path.
+ _LOGGER.warning("Could not refresh the storage sidecar: %s", err)
+ except Exception: # noqa: BLE001 # pylint: disable=broad-except
+ # A structural bug; keep the traceback so it isn't mistaken
+ # for the I/O failure above.
+ _LOGGER.warning(
+ "Unexpected error refreshing the storage sidecar", exc_info=True
+ )
+ return False
+
+
+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, a different cache
+ version, or the sidecar is incomplete. The loaded config carries no
+ source ranges; callers must not feed it into read_config/write_cpp.
+ """
+ cache_path = compiled_config_path(conf_path.name)
+ if not _cache_is_fresh(cache_path, conf_path):
+ return None
+
+ try:
+ envelope = json.loads(
+ cache_path.read_text(encoding="utf-8"), object_hook=_decode_object
+ )
+ except (OSError, ValueError) as err:
+ _LOGGER.debug("Ignoring unreadable compiled config cache: %s", err)
+ return None
+
+ if (
+ not isinstance(envelope, dict)
+ or envelope.get("v") != _CACHE_VERSION
+ or envelope.get("esphome") != ESPHOME_VERSION
+ or not isinstance(config := envelope.get("config"), dict)
+ ):
+ _LOGGER.debug("Ignoring compiled config cache with a foreign envelope")
+ return None
+
+ storage = StorageJSON.load(ext_storage_path(conf_path.name))
+ if storage is None or not storage.can_apply_to_core():
+ _LOGGER.debug("Ignoring compiled config cache: sidecar missing or incomplete")
+ return None
+ storage.apply_to_core()
+ return config
+
+
+# Remove before 2027.8: by then every maintained install has saved the
+# JSON cache at least once and dropped its legacy YAML file.
+def _legacy_compiled_config_path(config_filename: str) -> Path:
+ """Path of the pre-JSON YAML cache; only ever removed."""
+ return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
+
+
+def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
+ """True iff the cache file exists and isn't older than the source."""
+ try:
+ return cache_path.stat().st_mtime >= source_path.stat().st_mtime
+ except OSError:
+ return False
+
+
+def _json_default(value: Any) -> Any:
+ """Mirror ESPHomeDumper's representers: Lambda stays typed, the rest
+ stringify (IDs, time periods, MAC/IP addresses, paths, UUIDs, enums).
+
+ IncludeFile/Extend/Remove have no JSON mirror and would stringify
+ wrong, but none survive validation (config.py's packages merge and
+ the substitution pass consume them) so no guard is spent on them.
+ """
+ if isinstance(value, Lambda):
+ return {_LAMBDA_KEY: value.value}
+ return str(value)
+
+
+def _decode_object(obj: dict[str, Any]) -> Any:
+ """Revive the Lambda sentinel; every other mapping passes through."""
+ if len(obj) == 1 and isinstance(value := obj.get(_LAMBDA_KEY), str):
+ return Lambda(value)
+ return obj
diff --git a/esphome/component_aliases.py b/esphome/component_aliases.py
new file mode 100644
index 0000000000..e701bd98d4
--- /dev/null
+++ b/esphome/component_aliases.py
@@ -0,0 +1,10 @@
+"""Component alias registry.
+
+Generated by script/build_alias_registry.py - do not edit manually.
+See the component-alias section of esphome/loader.py.
+"""
+
+# alias -> (canonical component, removal version or None)
+COMPONENT_ALIASES: dict[str, tuple[str, str | None]] = {
+ "rp2040": ("rp2", "2027.7.0"),
+}
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 344456854b..6111af2b7e 100644
--- a/esphome/components/a01nyub/a01nyub.cpp
+++ b/esphome/components/a01nyub/a01nyub.cpp
@@ -25,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 {
diff --git a/esphome/components/a01nyub/a01nyub.h b/esphome/components/a01nyub/a01nyub.h
index 5c0d20bd37..69636eb8e4 100644
--- a/esphome/components/a01nyub/a01nyub.h
+++ b/esphome/components/a01nyub/a01nyub.h
@@ -8,7 +8,7 @@
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.
diff --git a/esphome/components/a01nyub/sensor.py b/esphome/components/a01nyub/sensor.py
index e5f4f7ef30..f84091d688 100644
--- a/esphome/components/a01nyub/sensor.py
+++ b/esphome/components/a01nyub/sensor.py
@@ -6,6 +6,7 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
UNIT_METER,
)
+from esphome.types import ConfigType
CODEOWNERS = ["@MrSuicideParrot"]
DEPENDENCIES = ["uart"]
@@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
diff --git a/esphome/components/a02yyuw/a02yyuw.h b/esphome/components/a02yyuw/a02yyuw.h
index 693bcfd03c..2e71651301 100644
--- a/esphome/components/a02yyuw/a02yyuw.h
+++ b/esphome/components/a02yyuw/a02yyuw.h
@@ -8,7 +8,7 @@
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.
diff --git a/esphome/components/a02yyuw/sensor.py b/esphome/components/a02yyuw/sensor.py
index f0bc59ae6c..7372f8f760 100644
--- a/esphome/components/a02yyuw/sensor.py
+++ b/esphome/components/a02yyuw/sensor.py
@@ -6,6 +6,7 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
UNIT_MILLIMETER,
)
+from esphome.types import ConfigType
CODEOWNERS = ["@TH-Braemer"]
DEPENDENCIES = ["uart"]
@@ -35,7 +36,7 @@ FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema(
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await uart.register_uart_device(var, config)
diff --git a/esphome/components/a4988/a4988.h b/esphome/components/a4988/a4988.h
index 04040241c0..f50b5926c1 100644
--- a/esphome/components/a4988/a4988.h
+++ b/esphome/components/a4988/a4988.h
@@ -6,7 +6,7 @@
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; }
diff --git a/esphome/components/a4988/stepper.py b/esphome/components/a4988/stepper.py
index 97f5a6fe0f..7a19bd550d 100644
--- a/esphome/components/a4988/stepper.py
+++ b/esphome/components/a4988/stepper.py
@@ -3,6 +3,7 @@ import esphome.codegen as cg
from esphome.components import stepper
import esphome.config_validation as cv
from esphome.const import CONF_DIR_PIN, CONF_ID, CONF_SLEEP_PIN, CONF_STEP_PIN
+from esphome.types import ConfigType
a4988_ns = cg.esphome_ns.namespace("a4988")
A4988 = a4988_ns.class_("A4988", stepper.Stepper, cg.Component)
@@ -17,7 +18,7 @@ CONFIG_SCHEMA = stepper.STEPPER_SCHEMA.extend(
).extend(cv.COMPONENT_SCHEMA)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await stepper.register_stepper(var, config)
diff --git a/esphome/components/absolute_humidity/absolute_humidity.h b/esphome/components/absolute_humidity/absolute_humidity.h
index be28d3dc50..9989bb17fc 100644
--- a/esphome/components/absolute_humidity/absolute_humidity.h
+++ b/esphome/components/absolute_humidity/absolute_humidity.h
@@ -13,7 +13,7 @@ enum SaturationVaporPressureEquation {
};
/// This class implements calculation of absolute humidity from temperature and relative humidity.
-class AbsoluteHumidityComponent : public sensor::Sensor, public Component {
+class AbsoluteHumidityComponent final : public sensor::Sensor, public Component {
public:
void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; }
void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; }
diff --git a/esphome/components/absolute_humidity/sensor.py b/esphome/components/absolute_humidity/sensor.py
index caaa546e25..84a69dfa23 100644
--- a/esphome/components/absolute_humidity/sensor.py
+++ b/esphome/components/absolute_humidity/sensor.py
@@ -9,6 +9,7 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
UNIT_GRAMS_PER_CUBIC_METER,
)
+from esphome.types import ConfigType
absolute_humidity_ns = cg.esphome_ns.namespace("absolute_humidity")
AbsoluteHumidityComponent = absolute_humidity_ns.class_(
@@ -43,7 +44,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
diff --git a/esphome/components/ac_dimmer/ac_dimmer.cpp b/esphome/components/ac_dimmer/ac_dimmer.cpp
index 3e21d6981d..477962a040 100644
--- a/esphome/components/ac_dimmer/ac_dimmer.cpp
+++ b/esphome/components/ac_dimmer/ac_dimmer.cpp
@@ -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_;
diff --git a/esphome/components/ac_dimmer/ac_dimmer.h b/esphome/components/ac_dimmer/ac_dimmer.h
index 6bfcf0bdb5..783a9d7e24 100644
--- a/esphome/components/ac_dimmer/ac_dimmer.h
+++ b/esphome/components/ac_dimmer/ac_dimmer.h
@@ -41,7 +41,7 @@ struct AcDimmerDataStore {
#endif
};
-class AcDimmer : public output::FloatOutput, public Component {
+class AcDimmer final : public output::FloatOutput, public Component {
public:
void setup() override;
diff --git a/esphome/components/ac_dimmer/output.py b/esphome/components/ac_dimmer/output.py
index 1f35095e0e..498565b0ea 100644
--- a/esphome/components/ac_dimmer/output.py
+++ b/esphome/components/ac_dimmer/output.py
@@ -4,6 +4,7 @@ from esphome.components import output
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_METHOD, CONF_MIN_POWER
from esphome.core import CORE
+from esphome.types import ConfigType
CODEOWNERS = ["@glmnet"]
@@ -48,7 +49,13 @@ CONFIG_SCHEMA = cv.All(
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
+ if CORE.is_esp32:
+ from esphome.components.esp32 import include_builtin_idf_component
+
+ # Re-enable the gptimer driver (excluded by default to save compile time)
+ include_builtin_idf_component("esp_driver_gptimer")
+
if CORE.is_esp8266:
# ac_dimmer uses setTimer1Callback which requires the waveform generator
from esphome.components.esp8266.const import require_waveform
diff --git a/esphome/components/adalight/__init__.py b/esphome/components/adalight/__init__.py
index 5e122676cd..afdfefaba6 100644
--- a/esphome/components/adalight/__init__.py
+++ b/esphome/components/adalight/__init__.py
@@ -4,6 +4,9 @@ from esphome.components.light.effects import register_addressable_effect
from esphome.components.light.types import AddressableLightEffect
import esphome.config_validation as cv
from esphome.const import CONF_NAME, CONF_UART_ID
+from esphome.core import ID
+from esphome.cpp_generator import MockObj
+from esphome.types import ConfigType
DEPENDENCIES = ["uart"]
@@ -21,7 +24,7 @@ CONFIG_SCHEMA = cv.Schema({})
"Adalight",
{cv.GenerateID(CONF_UART_ID): cv.use_id(uart.UARTComponent)},
)
-async def adalight_light_effect_to_code(config, effect_id):
+async def adalight_light_effect_to_code(config: ConfigType, effect_id: ID) -> MockObj:
effect = cg.new_Pvariable(effect_id, config[CONF_NAME])
await uart.register_uart_device(effect, config)
return effect
diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py
index 96c8334a6d..5c763a4f4c 100644
--- a/esphome/components/adc/__init__.py
+++ b/esphome/components/adc/__init__.py
@@ -1,3 +1,5 @@
+from typing import Any
+
from esphome import pins
import esphome.codegen as cg
from esphome.components.esp32 import (
@@ -16,6 +18,7 @@ from esphome.components.esp32 import (
import esphome.config_validation as cv
from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266
from esphome.core import CORE
+from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
@@ -225,14 +228,15 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = {
}
-def validate_adc_pin(value):
+def validate_adc_pin(value: Any) -> ConfigType | str:
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")
+ # Deprecated in favour of the `internal_temperature` platform, remove before 2027.2.0
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 +265,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 676940eca1..7131898747 100644
--- a/esphome/components/adc/adc_sensor.h
+++ b/esphome/components/adc/adc_sensor.h
@@ -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,9 +152,9 @@ 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;
diff --git a/esphome/components/adc/adc_sensor_common.cpp b/esphome/components/adc/adc_sensor_common.cpp
index 16c86aee18..5ca58df10e 100644
--- a/esphome/components/adc/adc_sensor_common.cpp
+++ b/esphome/components/adc/adc_sensor_common.cpp
@@ -3,7 +3,7 @@
namespace esphome::adc {
-static const char *const TAG = "adc.common";
+static const char *const TAG = "adc";
const LogString *sampling_mode_to_str(SamplingMode mode) {
switch (mode) {
diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp
index a761b37749..a0f7a1ed08 100644
--- a/esphome/components/adc/adc_sensor_esp32.cpp
+++ b/esphome/components/adc/adc_sensor_esp32.cpp
@@ -6,7 +6,7 @@
namespace esphome::adc {
-static const char *const TAG = "adc.esp32";
+static const char *const TAG = "adc";
adc_oneshot_unit_handle_t ADCSensor::shared_adc_handles[2] = {nullptr, nullptr};
diff --git a/esphome/components/adc/adc_sensor_esp8266.cpp b/esphome/components/adc/adc_sensor_esp8266.cpp
index e4f2f82f08..77a192e025 100644
--- a/esphome/components/adc/adc_sensor_esp8266.cpp
+++ b/esphome/components/adc/adc_sensor_esp8266.cpp
@@ -13,7 +13,7 @@ ADC_MODE(ADC_VCC)
namespace esphome::adc {
-static const char *const TAG = "adc.esp8266";
+static const char *const TAG = "adc";
void ADCSensor::setup() {
#ifndef USE_ADC_SENSOR_VCC
diff --git a/esphome/components/adc/adc_sensor_libretiny.cpp b/esphome/components/adc/adc_sensor_libretiny.cpp
index d9b9f50be1..dfa545b395 100644
--- a/esphome/components/adc/adc_sensor_libretiny.cpp
+++ b/esphome/components/adc/adc_sensor_libretiny.cpp
@@ -5,7 +5,7 @@
namespace esphome::adc {
-static const char *const TAG = "adc.libretiny";
+static const char *const TAG = "adc";
void ADCSensor::setup() {
#ifndef USE_ADC_SENSOR_VCC
diff --git a/esphome/components/adc/adc_sensor_rp2040.cpp b/esphome/components/adc/adc_sensor_rp2.cpp
similarity index 60%
rename from esphome/components/adc/adc_sensor_rp2040.cpp
rename to esphome/components/adc/adc_sensor_rp2.cpp
index 8d41edb814..ce665e8501 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"
@@ -17,7 +17,26 @@
namespace esphome::adc {
-static const char *const TAG = "adc.rp2040";
+static const char *const TAG = "adc";
+
+// The on-die temperature sensor sits on the last ADC channel: input 4 on RP2040
+// and RP2350A, but input 8 on RP2350B, which has eight external channels rather
+// than four.
+//
+// This deliberately does not use the SDK's ADC_TEMPERATURE_CHANNEL_NUM. That
+// derives from NUM_ADC_CHANNELS, which settles from a board header, and
+// arduino-pico supplies a fixed B-die one for every RP2350 build. The real die
+// is only declared later, by the variant's pins_arduino.h, so the SDK constant
+// reads 8 on A-die boards. PICO_RP2350A itself is correct by the time this file
+// is compiled, on both arduino-pico and pico-sdk builds.
+#if defined(PICO_RP2350) && !defined(PICO_RP2350A)
+#error "PICO_RP2350A is not defined, so the RP2350 die is unknown and the temperature ADC channel cannot be chosen"
+#endif
+#if defined(PICO_RP2350) && !PICO_RP2350A
+static constexpr uint8_t TEMPERATURE_ADC_INPUT = 8;
+#else
+static constexpr uint8_t TEMPERATURE_ADC_INPUT = 4;
+#endif
void ADCSensor::setup() {
static bool initialized = false;
@@ -52,7 +71,7 @@ float ADCSensor::sample() {
if (this->is_temperature_) {
adc_set_temp_sensor_enabled(true);
delay(1);
- adc_select_input(4);
+ adc_select_input(TEMPERATURE_ADC_INPUT);
for (uint8_t sample = 0; sample < this->sample_count_; sample++) {
raw = adc_read();
@@ -66,15 +85,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);
@@ -84,11 +106,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();
@@ -99,4 +121,4 @@ float ADCSensor::sample() {
} // 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 c3632b00e2..bf45059740 100644
--- a/esphome/components/adc/adc_sensor_zephyr.cpp
+++ b/esphome/components/adc/adc_sensor_zephyr.cpp
@@ -7,7 +7,7 @@
namespace esphome::adc {
-static const char *const TAG = "adc.zephyr";
+static const char *const TAG = "adc";
void ADCSensor::setup() {
if (!adc_is_ready_dt(this->channel_)) {
diff --git a/esphome/components/adc/sensor.py b/esphome/components/adc/sensor.py
index 09e09f0dc1..5d1031825e 100644
--- a/esphome/components/adc/sensor.py
+++ b/esphome/components/adc/sensor.py
@@ -10,8 +10,8 @@ from esphome.components.esp32 import (
from esphome.components.nrf52.const import AIN_TO_GPIO, EXTRA_ADC
from esphome.components.zephyr import (
zephyr_add_overlay,
+ zephyr_add_overlay_builder,
zephyr_add_prj_conf,
- zephyr_add_user,
)
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
@@ -52,7 +52,7 @@ _attenuation = cv.enum(ATTENUATION_MODES, lower=True)
_sampling_mode = cv.enum(SAMPLING_MODES, lower=True)
-def validate_config(config):
+def validate_config(config: ConfigType) -> ConfigType:
if config[CONF_RAW] and config.get(CONF_ATTENUATION, None) == "auto":
raise cv.Invalid("Automatic attenuation cannot be used when raw output is set")
@@ -67,6 +67,13 @@ def validate_config(config):
# Alter value here so `config` command prints the recommended change
config[CONF_ATTENUATION] = _attenuation("12db")
+ # Remove before 2027.2.0
+ if config[CONF_PIN] == "TEMPERATURE":
+ _LOGGER.warning(
+ "[adc] `pin: TEMPERATURE` is deprecated, use the `internal_temperature` "
+ "sensor platform instead. Will be removed in 2027.2.0"
+ )
+
return config
@@ -113,7 +120,19 @@ CONFIG_SCHEMA = cv.All(
CONF_ADC_CHANNEL_ID = "adc_channel_id"
-async def to_code(config):
+def _overlay_io_channels() -> str:
+ channel_count = CORE.data[CONF_ADC_CHANNEL_ID]
+ entries = ", ".join(f"<&adc {channel_id}>" for channel_id in range(channel_count))
+ return f"""
+ / {{
+ zephyr,user {{
+ io-channels = {entries};
+ }};
+ }};
+ """
+
+
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await sensor.register_sensor(var, config)
@@ -121,6 +140,7 @@ async def to_code(config):
if config[CONF_PIN] == "VCC":
cg.add_define("USE_ADC_SENSOR_VCC")
elif config[CONF_PIN] == "TEMPERATURE":
+ # Remove before 2027.2.0
cg.add(var.set_is_temperature())
elif not CORE.is_nrf52 or config[CONF_PIN][CONF_NUMBER] not in EXTRA_ADC:
pin = await cg.gpio_pin_expression(config[CONF_PIN])
@@ -173,9 +193,8 @@ async def to_code(config):
if isinstance(pin_number, int):
GPIO_TO_AIN = {v: k for k, v in AIN_TO_GPIO.items()}
pin_number = GPIO_TO_AIN[pin_number]
- zephyr_add_user("io-channels", f"<&adc {channel_id}>")
- zephyr_add_overlay(
- f"""
+ zephyr_add_overlay_builder(_overlay_io_channels)
+ zephyr_add_overlay(f"""
&adc {{
#address-cells = <1>;
#size-cells = <0>;
@@ -190,8 +209,7 @@ async def to_code(config):
zephyr,oversampling = <8>;
}};
}};
- """
- )
+ """)
FILTER_SOURCE_FILES = filter_source_files_from_platform(
@@ -201,7 +219,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/__init__.py b/esphome/components/adc128s102/__init__.py
index a5281aacc7..684147752d 100644
--- a/esphome/components/adc128s102/__init__.py
+++ b/esphome/components/adc128s102/__init__.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import spi
import esphome.config_validation as cv
from esphome.const import CONF_ID
+from esphome.types import ConfigType
DEPENDENCIES = ["spi"]
MULTI_CONF = True
@@ -17,7 +18,7 @@ CONFIG_SCHEMA = cv.Schema(
).extend(spi.spi_device_schema(cs_pin_required=True))
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await spi.register_spi_device(var, config)
diff --git a/esphome/components/adc128s102/adc128s102.h b/esphome/components/adc128s102/adc128s102.h
index f04ed87b2a..7d6355815e 100644
--- a/esphome/components/adc128s102/adc128s102.h
+++ b/esphome/components/adc128s102/adc128s102.h
@@ -6,9 +6,9 @@
namespace esphome::adc128s102 {
-class ADC128S102 : public Component,
- public spi::SPIDevice {
+class ADC128S102 final : public Component,
+ public spi::SPIDevice {
public:
ADC128S102() = default;
diff --git a/esphome/components/adc128s102/sensor/__init__.py b/esphome/components/adc128s102/sensor/__init__.py
index a65ae9d537..04589a7ce2 100644
--- a/esphome/components/adc128s102/sensor/__init__.py
+++ b/esphome/components/adc128s102/sensor/__init__.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import sensor, voltage_sampler
import esphome.config_validation as cv
from esphome.const import CONF_CHANNEL, CONF_ID
+from esphome.types import ConfigType
from .. import ADC128S102, adc128s102_ns
@@ -28,7 +29,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(
config[CONF_ID],
config[CONF_CHANNEL],
diff --git a/esphome/components/adc128s102/sensor/adc128s102_sensor.h b/esphome/components/adc128s102/sensor/adc128s102_sensor.h
index c840102380..3c42e709f2 100644
--- a/esphome/components/adc128s102/sensor/adc128s102_sensor.h
+++ b/esphome/components/adc128s102/sensor/adc128s102_sensor.h
@@ -9,10 +9,10 @@
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);
diff --git a/esphome/components/addressable_light/addressable_light_display.h b/esphome/components/addressable_light/addressable_light_display.h
index 917d334f05..39d62b8733 100644
--- a/esphome/components/addressable_light/addressable_light_display.h
+++ b/esphome/components/addressable_light/addressable_light_display.h
@@ -9,7 +9,7 @@
namespace esphome::addressable_light {
-class AddressableLightDisplay : public display::DisplayBuffer {
+class AddressableLightDisplay final : public display::DisplayBuffer {
public:
light::AddressableLight *get_light() const { return this->light_; }
diff --git a/esphome/components/addressable_light/display.py b/esphome/components/addressable_light/display.py
index 929d45121c..1db01b40f9 100644
--- a/esphome/components/addressable_light/display.py
+++ b/esphome/components/addressable_light/display.py
@@ -11,6 +11,7 @@ from esphome.const import (
CONF_UPDATE_INTERVAL,
CONF_WIDTH,
)
+from esphome.types import ConfigType
CODEOWNERS = ["@justfalter"]
@@ -38,7 +39,7 @@ CONFIG_SCHEMA = cv.All(
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
wrapped_light = await cg.get_variable(config[CONF_ADDRESSABLE_LIGHT_ID])
cg.add(var.set_width(config[CONF_WIDTH]))
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 9d19770c57..0f4189ad90 100644
--- a/esphome/components/ade7880/ade7880.cpp
+++ b/esphome/components/ade7880/ade7880.cpp
@@ -87,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() {
@@ -117,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) {
@@ -133,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) {
@@ -149,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);
diff --git a/esphome/components/ade7880/ade7880.h b/esphome/components/ade7880/ade7880.h
index 69c8e5abba..12be0849ff 100644
--- a/esphome/components/ade7880/ade7880.h
+++ b/esphome/components/ade7880/ade7880.h
@@ -65,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; }
@@ -105,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_();
diff --git a/esphome/components/ade7880/ade7880_registers.h b/esphome/components/ade7880/ade7880_registers.h
index aee4e42445..8b0b86fe7a 100644
--- a/esphome/components/ade7880/ade7880_registers.h
+++ b/esphome/components/ade7880/ade7880_registers.h
@@ -84,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;
diff --git a/esphome/components/ade7880/sensor.py b/esphome/components/ade7880/sensor.py
index beb74d7310..93c279e235 100644
--- a/esphome/components/ade7880/sensor.py
+++ b/esphome/components/ade7880/sensor.py
@@ -36,6 +36,7 @@ from esphome.const import (
UNIT_WATT,
UNIT_WATT_HOURS,
)
+from esphome.cpp_generator import MockObj
from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
@@ -243,7 +244,7 @@ CONFIG_SCHEMA = cv.All(
)
-async def neutral_channel(config):
+async def neutral_channel(config: ConfigType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID])
current = config[CONF_CURRENT]
@@ -257,7 +258,7 @@ async def neutral_channel(config):
return var
-async def power_channel(config):
+async def power_channel(config: ConfigType) -> MockObj:
var = cg.new_Pvariable(config[CONF_ID])
for sensor_type in POWER_SENSOR_TYPES:
@@ -280,7 +281,7 @@ async def power_channel(config):
return var
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
diff --git a/esphome/components/ade7953_base/__init__.py b/esphome/components/ade7953_base/__init__.py
index 4fc35352f9..71250ac94e 100644
--- a/esphome/components/ade7953_base/__init__.py
+++ b/esphome/components/ade7953_base/__init__.py
@@ -23,6 +23,8 @@ from esphome.const import (
UNIT_VOLT_AMPS_REACTIVE,
UNIT_WATT,
)
+from esphome.cpp_generator import MockObj
+from esphome.types import ConfigType
CODEOWNERS = ["@angelnu"]
@@ -163,7 +165,7 @@ ADE7953_CONFIG_SCHEMA = cv.Schema(
).extend(cv.polling_component_schema("60s"))
-async def register_ade7953(var, config):
+async def register_ade7953(var: MockObj, config: ConfigType) -> None:
await cg.register_component(var, config)
if irq_pin_config := config.get(CONF_IRQ_PIN):
diff --git a/esphome/components/ade7953_i2c/ade7953_i2c.h b/esphome/components/ade7953_i2c/ade7953_i2c.h
index 74d7e3e7cc..0b368a73ee 100644
--- a/esphome/components/ade7953_i2c/ade7953_i2c.h
+++ b/esphome/components/ade7953_i2c/ade7953_i2c.h
@@ -10,7 +10,7 @@
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;
diff --git a/esphome/components/ade7953_i2c/sensor.py b/esphome/components/ade7953_i2c/sensor.py
index 4b55acdafa..8447042d30 100644
--- a/esphome/components/ade7953_i2c/sensor.py
+++ b/esphome/components/ade7953_i2c/sensor.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import ade7953_base, i2c
import esphome.config_validation as cv
from esphome.const import CONF_ID
+from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
AUTO_LOAD = ["ade7953_base"]
@@ -20,7 +21,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await i2c.register_i2c_device(var, config)
await ade7953_base.register_ade7953(var, config)
diff --git a/esphome/components/ade7953_spi/sensor.py b/esphome/components/ade7953_spi/sensor.py
index dce021daad..6fdf2147f3 100644
--- a/esphome/components/ade7953_spi/sensor.py
+++ b/esphome/components/ade7953_spi/sensor.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import ade7953_base, spi
import esphome.config_validation as cv
from esphome.const import CONF_ID
+from esphome.types import ConfigType
DEPENDENCIES = ["spi"]
AUTO_LOAD = ["ade7953_base"]
@@ -20,7 +21,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await spi.register_spi_device(var, config)
await ade7953_base.register_ade7953(var, config)
diff --git a/esphome/components/ads1115/__init__.py b/esphome/components/ads1115/__init__.py
index 6d52fc83fd..b42ee918c5 100644
--- a/esphome/components/ads1115/__init__.py
+++ b/esphome/components/ads1115/__init__.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_ID
+from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
MULTI_CONF = True
@@ -24,7 +25,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
diff --git a/esphome/components/ads1115/ads1115.h b/esphome/components/ads1115/ads1115.h
index b1eed68aff..0b7f7ae700 100644
--- a/esphome/components/ads1115/ads1115.h
+++ b/esphome/components/ads1115/ads1115.h
@@ -43,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;
diff --git a/esphome/components/ads1115/sensor/__init__.py b/esphome/components/ads1115/sensor/__init__.py
index afb70d07c8..742f82d302 100644
--- a/esphome/components/ads1115/sensor/__init__.py
+++ b/esphome/components/ads1115/sensor/__init__.py
@@ -11,6 +11,7 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
UNIT_VOLT,
)
+from esphome.types import ConfigType
from .. import CONF_ADS1115_ID, ADS1115Component, ads1115_ns
@@ -86,7 +87,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await sensor.register_sensor(var, config)
await cg.register_component(var, config)
diff --git a/esphome/components/ads1115/sensor/ads1115_sensor.h b/esphome/components/ads1115/sensor/ads1115_sensor.h
index 3b82c153dd..ecc8fb7af8 100644
--- a/esphome/components/ads1115/sensor/ads1115_sensor.h
+++ b/esphome/components/ads1115/sensor/ads1115_sensor.h
@@ -11,10 +11,10 @@
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; }
diff --git a/esphome/components/ads1118/__init__.py b/esphome/components/ads1118/__init__.py
index 45d47a329e..956b9a0c1f 100644
--- a/esphome/components/ads1118/__init__.py
+++ b/esphome/components/ads1118/__init__.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import spi
import esphome.config_validation as cv
from esphome.const import CONF_ID
+from esphome.types import ConfigType
CODEOWNERS = ["@solomondg1"]
DEPENDENCIES = ["spi"]
@@ -23,7 +24,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await spi.register_spi_device(var, config)
diff --git a/esphome/components/ads1118/ads1118.h b/esphome/components/ads1118/ads1118.h
index ef125a0b44..275933c70d 100644
--- a/esphome/components/ads1118/ads1118.h
+++ b/esphome/components/ads1118/ads1118.h
@@ -26,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;
diff --git a/esphome/components/ads1118/sensor/__init__.py b/esphome/components/ads1118/sensor/__init__.py
index 33bfe97789..6bc3baa2e4 100644
--- a/esphome/components/ads1118/sensor/__init__.py
+++ b/esphome/components/ads1118/sensor/__init__.py
@@ -11,6 +11,7 @@ from esphome.const import (
UNIT_CELSIUS,
UNIT_VOLT,
)
+from esphome.types import ConfigType
from .. import ADS1118, CONF_ADS1118_ID, ads1118_ns
@@ -86,7 +87,7 @@ CONFIG_SCHEMA = cv.typed_schema(
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_ADS1118_ID])
diff --git a/esphome/components/ads1118/sensor/ads1118_sensor.h b/esphome/components/ads1118/sensor/ads1118_sensor.h
index b929e75c62..8987dba073 100644
--- a/esphome/components/ads1118/sensor/ads1118_sensor.h
+++ b/esphome/components/ads1118/sensor/ads1118_sensor.h
@@ -10,10 +10,10 @@
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;
diff --git a/esphome/components/ags10/ags10.h b/esphome/components/ags10/ags10.h
index 703acd5228..8ebc8da544 100644
--- a/esphome/components/ags10/ags10.h
+++ b/esphome/components/ags10/ags10.h
@@ -7,7 +7,7 @@
namespace esphome::ags10 {
-class AGS10Component : public PollingComponent, public i2c::I2CDevice {
+class AGS10Component final : public PollingComponent, public i2c::I2CDevice {
public:
/**
* Sets TVOC sensor.
@@ -100,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)
@@ -116,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)
diff --git a/esphome/components/ags10/sensor.py b/esphome/components/ags10/sensor.py
index 6491d7d810..8606e7c247 100644
--- a/esphome/components/ags10/sensor.py
+++ b/esphome/components/ags10/sensor.py
@@ -17,6 +17,9 @@ from esphome.const import (
UNIT_OHM,
UNIT_PARTS_PER_BILLION,
)
+from esphome.core import ID
+from esphome.cpp_generator import MockObj, TemplateArgsType
+from esphome.types import ConfigType
CONF_RESISTANCE = "resistance"
@@ -62,7 +65,7 @@ CONFIG_SCHEMA = (
FINAL_VALIDATE_SCHEMA = i2c.final_validate_device_schema("ags10", max_frequency="15khz")
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
@@ -94,7 +97,12 @@ AGS10_NEW_I2C_ADDRESS_SCHEMA = cv.maybe_simple_value(
AGS10_NEW_I2C_ADDRESS_SCHEMA,
synchronous=True,
)
-async def ags10newi2caddress_to_code(config, action_id, template_arg, args):
+async def ags10newi2caddress_to_code(
+ config: ConfigType,
+ action_id: ID,
+ template_arg: cg.TemplateArguments,
+ args: TemplateArgsType,
+) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
address = await cg.templatable(config[CONF_ADDRESS], args, cg.uint8)
@@ -126,7 +134,12 @@ AGS10_SET_ZERO_POINT_SCHEMA = cv.Schema(
AGS10_SET_ZERO_POINT_SCHEMA,
synchronous=True,
)
-async def ags10setzeropoint_to_code(config, action_id, template_arg, args):
+async def ags10setzeropoint_to_code(
+ config: ConfigType,
+ action_id: ID,
+ template_arg: cg.TemplateArguments,
+ args: TemplateArgsType,
+) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
mode = await cg.templatable(
diff --git a/esphome/components/aht10/aht10.h b/esphome/components/aht10/aht10.h
index 7b9b1761c4..e99ba6fb98 100644
--- a/esphome/components/aht10/aht10.h
+++ b/esphome/components/aht10/aht10.h
@@ -10,7 +10,7 @@ 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;
diff --git a/esphome/components/aht10/sensor.py b/esphome/components/aht10/sensor.py
index a5b1cf0ffb..ae669d0000 100644
--- a/esphome/components/aht10/sensor.py
+++ b/esphome/components/aht10/sensor.py
@@ -12,6 +12,7 @@ from esphome.const import (
UNIT_CELSIUS,
UNIT_PERCENT,
)
+from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
@@ -50,7 +51,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
diff --git a/esphome/components/aic3204/aic3204.h b/esphome/components/aic3204/aic3204.h
index 9b8c792824..ae99a8f4d6 100644
--- a/esphome/components/aic3204/aic3204.h
+++ b/esphome/components/aic3204/aic3204.h
@@ -61,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;
diff --git a/esphome/components/aic3204/audio_dac.py b/esphome/components/aic3204/audio_dac.py
index b478b573a3..50e2f81f1b 100644
--- a/esphome/components/aic3204/audio_dac.py
+++ b/esphome/components/aic3204/audio_dac.py
@@ -4,6 +4,9 @@ from esphome.components import i2c
from esphome.components.audio_dac import AudioDac
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_MODE
+from esphome.core import ID
+from esphome.cpp_generator import MockObj, TemplateArgsType
+from esphome.types import ConfigType
CODEOWNERS = ["@kbx81"]
DEPENDENCIES = ["i2c"]
@@ -39,7 +42,12 @@ SET_AUTO_MUTE_ACTION_SCHEMA = cv.maybe_simple_value(
SET_AUTO_MUTE_ACTION_SCHEMA,
synchronous=True,
)
-async def aic3204_set_volume_to_code(config, action_id, template_arg, args):
+async def aic3204_set_volume_to_code(
+ config: ConfigType,
+ action_id: ID,
+ template_arg: cg.TemplateArguments,
+ args: TemplateArgsType,
+) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, paren)
@@ -49,7 +57,7 @@ async def aic3204_set_volume_to_code(config, action_id, template_arg, args):
return var
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
diff --git a/esphome/components/aic3204/automation.h b/esphome/components/aic3204/automation.h
index 50ae03edbd..f0f8856614 100644
--- a/esphome/components/aic3204/automation.h
+++ b/esphome/components/aic3204/automation.h
@@ -6,7 +6,7 @@
namespace esphome::aic3204 {
-template class SetAutoMuteAction : public Action {
+template class SetAutoMuteAction final : public Action {
public:
explicit SetAutoMuteAction(AIC3204 *aic3204) : aic3204_(aic3204) {}
diff --git a/esphome/components/airthings_ble/__init__.py b/esphome/components/airthings_ble/__init__.py
index 1545110798..44534b80e9 100644
--- a/esphome/components/airthings_ble/__init__.py
+++ b/esphome/components/airthings_ble/__init__.py
@@ -1,23 +1,27 @@
import esphome.codegen as cg
-from esphome.components import esp32_ble_tracker
+from esphome.components import ble_device_base
import esphome.config_validation as cv
from esphome.const import CONF_ID
+from esphome.types import ConfigType
-DEPENDENCIES = ["esp32_ble_tracker"]
+AUTO_LOAD = ["ble_device_base"]
CODEOWNERS = ["@jeromelaban"]
airthings_ble_ns = cg.esphome_ns.namespace("airthings_ble")
AirthingsListener = airthings_ble_ns.class_(
- "AirthingsListener", esp32_ble_tracker.ESPBTDeviceListener
+ "AirthingsListener", ble_device_base.ESPBTDeviceListener
)
-CONFIG_SCHEMA = cv.Schema(
- {
- cv.GenerateID(): cv.declare_id(AirthingsListener),
- }
-).extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA)
+CONFIG_SCHEMA = cv.All(
+ ble_device_base.rename_legacy_hub_id("airthings_ble"),
+ cv.Schema(
+ {
+ cv.GenerateID(): cv.declare_id(AirthingsListener),
+ }
+ ).extend(ble_device_base.BLE_DEVICE_SCHEMA),
+)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
- await esp32_ble_tracker.register_ble_device(var, config)
+ await ble_device_base.register_ble_device(var, config)
diff --git a/esphome/components/airthings_ble/airthings_listener.cpp b/esphome/components/airthings_ble/airthings_listener.cpp
index 881b3e297b..f2625a7832 100644
--- a/esphome/components/airthings_ble/airthings_listener.cpp
+++ b/esphome/components/airthings_ble/airthings_listener.cpp
@@ -2,15 +2,13 @@
#include "esphome/core/log.h"
#include
-#ifdef USE_ESP32
-
namespace esphome::airthings_ble {
static const char *const TAG = "airthings_ble";
-bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &device) {
+bool AirthingsListener::parse_device(const ble_device_base::ESPBTDevice &device) {
for (auto &it : device.get_manufacturer_datas()) {
- if (it.uuid == esp32_ble_tracker::ESPBTUUID::from_uint32(0x0334)) {
+ if (it.uuid == ble_device_base::ESPBTUUID::from_uint32(0x0334)) {
if (it.data.size() < 4)
continue;
@@ -29,5 +27,3 @@ bool AirthingsListener::parse_device(const esp32_ble_tracker::ESPBTDevice &devic
}
} // namespace esphome::airthings_ble
-
-#endif
diff --git a/esphome/components/airthings_ble/airthings_listener.h b/esphome/components/airthings_ble/airthings_listener.h
index 707e9c3f21..8fdfeb972f 100644
--- a/esphome/components/airthings_ble/airthings_listener.h
+++ b/esphome/components/airthings_ble/airthings_listener.h
@@ -1,17 +1,13 @@
#pragma once
-#ifdef USE_ESP32
-
#include "esphome/core/component.h"
-#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h"
+#include "esphome/components/ble_device_base/ble_device.h"
namespace esphome::airthings_ble {
-class AirthingsListener : public esp32_ble_tracker::ESPBTDeviceListener {
+class AirthingsListener final : public ble_device_base::ESPBTDeviceListener {
public:
- bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override;
+ bool parse_device(const ble_device_base::ESPBTDevice &device) override;
};
} // namespace esphome::airthings_ble
-
-#endif
diff --git a/esphome/components/airthings_wave_base/__init__.py b/esphome/components/airthings_wave_base/__init__.py
index c3f3b8f199..58fde11a3d 100644
--- a/esphome/components/airthings_wave_base/__init__.py
+++ b/esphome/components/airthings_wave_base/__init__.py
@@ -20,8 +20,10 @@ from esphome.const import (
UNIT_PERCENT,
UNIT_VOLT,
)
+from esphome.cpp_generator import MockObj
+from esphome.types import ConfigType
-CODEOWNERS = ["@ncareau", "@jeromelaban", "@kpfleming"]
+CODEOWNERS = ["@ncareau", "@jeromelaban"]
DEPENDENCIES = ["ble_client"]
@@ -78,7 +80,7 @@ BASE_SCHEMA = (
)
-async def wave_base_to_code(var, config):
+async def wave_base_to_code(var: MockObj, config: ConfigType) -> None:
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
diff --git a/esphome/components/airthings_wave_mini/airthings_wave_mini.h b/esphome/components/airthings_wave_mini/airthings_wave_mini.h
index 910ac90239..c41dde15c9 100644
--- a/esphome/components/airthings_wave_mini/airthings_wave_mini.h
+++ b/esphome/components/airthings_wave_mini/airthings_wave_mini.h
@@ -12,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();
diff --git a/esphome/components/airthings_wave_mini/sensor.py b/esphome/components/airthings_wave_mini/sensor.py
index f231be6670..9136b333e2 100644
--- a/esphome/components/airthings_wave_mini/sensor.py
+++ b/esphome/components/airthings_wave_mini/sensor.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import airthings_wave_base
import esphome.config_validation as cv
from esphome.const import CONF_ID
+from esphome.types import ConfigType
DEPENDENCIES = airthings_wave_base.DEPENDENCIES
@@ -20,6 +21,6 @@ CONFIG_SCHEMA = airthings_wave_base.BASE_SCHEMA.extend(
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await airthings_wave_base.wave_base_to_code(var, config)
diff --git a/esphome/components/airthings_wave_plus/airthings_wave_plus.h b/esphome/components/airthings_wave_plus/airthings_wave_plus.h
index 6f51f3c65a..af355e45d6 100644
--- a/esphome/components/airthings_wave_plus/airthings_wave_plus.h
+++ b/esphome/components/airthings_wave_plus/airthings_wave_plus.h
@@ -19,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;
diff --git a/esphome/components/airthings_wave_plus/sensor.py b/esphome/components/airthings_wave_plus/sensor.py
index a12c70f04c..8ea79e644f 100644
--- a/esphome/components/airthings_wave_plus/sensor.py
+++ b/esphome/components/airthings_wave_plus/sensor.py
@@ -83,7 +83,7 @@ CONFIG_SCHEMA = cv.All(
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await airthings_wave_base.wave_base_to_code(var, config)
diff --git a/esphome/components/alarm_control_panel/automation.h b/esphome/components/alarm_control_panel/automation.h
index 022d2650d2..dcb5121c60 100644
--- a/esphome/components/alarm_control_panel/automation.h
+++ b/esphome/components/alarm_control_panel/automation.h
@@ -27,7 +27,7 @@ static_assert(std::is_trivially_copyable_v);
static_assert(sizeof(StateEnterForwarder) <= sizeof(void *));
static_assert(std::is_trivially_copyable_v>);
-template class ArmAwayAction : public Action {
+template class ArmAwayAction final : public Action {
public:
explicit ArmAwayAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -39,7 +39,7 @@ template class ArmAwayAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class ArmHomeAction : public Action {
+template class ArmHomeAction final : public Action {
public:
explicit ArmHomeAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -51,7 +51,7 @@ template class ArmHomeAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class ArmNightAction : public Action {
+template class ArmNightAction final : public Action {
public:
explicit ArmNightAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -63,7 +63,7 @@ template class ArmNightAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class DisarmAction : public Action {
+template class DisarmAction final : public Action {
public:
explicit DisarmAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -75,7 +75,7 @@ template class DisarmAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class PendingAction : public Action {
+template class PendingAction final : public Action {
public:
explicit PendingAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -85,7 +85,7 @@ template class PendingAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class TriggeredAction : public Action {
+template class TriggeredAction final : public Action {
public:
explicit TriggeredAction(AlarmControlPanel *alarm_control_panel) : alarm_control_panel_(alarm_control_panel) {}
@@ -95,7 +95,7 @@ template class TriggeredAction : public Action {
AlarmControlPanel *alarm_control_panel_;
};
-template class AlarmControlPanelCondition : public Condition {
+template class AlarmControlPanelCondition final : public Condition {
public:
AlarmControlPanelCondition(AlarmControlPanel *parent) : parent_(parent) {}
bool check(const Ts &...x) override {
diff --git a/esphome/components/alpha3/alpha3.h b/esphome/components/alpha3/alpha3.h
index c63129031a..5a5b01ac0b 100644
--- a/esphome/components/alpha3/alpha3.h
+++ b/esphome/components/alpha3/alpha3.h
@@ -31,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;
diff --git a/esphome/components/alpha3/sensor.py b/esphome/components/alpha3/sensor.py
index 279ab214cf..2c1a04ef27 100644
--- a/esphome/components/alpha3/sensor.py
+++ b/esphome/components/alpha3/sensor.py
@@ -20,6 +20,7 @@ from esphome.const import (
UNIT_VOLT,
UNIT_WATT,
)
+from esphome.types import ConfigType
alpha3_ns = cg.esphome_ns.namespace("alpha3")
Alpha3 = alpha3_ns.class_("Alpha3", ble_client.BLEClientNode, cg.PollingComponent)
@@ -68,7 +69,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
diff --git a/esphome/components/am2315c/am2315c.h b/esphome/components/am2315c/am2315c.h
index 5a959af4c3..73dc0d8758 100644
--- a/esphome/components/am2315c/am2315c.h
+++ b/esphome/components/am2315c/am2315c.h
@@ -27,7 +27,7 @@
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;
diff --git a/esphome/components/am2315c/sensor.py b/esphome/components/am2315c/sensor.py
index ec12ab717e..febb11409c 100644
--- a/esphome/components/am2315c/sensor.py
+++ b/esphome/components/am2315c/sensor.py
@@ -11,6 +11,7 @@ from esphome.const import (
UNIT_CELSIUS,
UNIT_PERCENT,
)
+from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
@@ -40,7 +41,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
diff --git a/esphome/components/am2320/am2320.h b/esphome/components/am2320/am2320.h
index ddb5c6f165..f92156b154 100644
--- a/esphome/components/am2320/am2320.h
+++ b/esphome/components/am2320/am2320.h
@@ -6,7 +6,7 @@
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;
diff --git a/esphome/components/am2320/sensor.py b/esphome/components/am2320/sensor.py
index ed4a5fd922..ffac0e6407 100644
--- a/esphome/components/am2320/sensor.py
+++ b/esphome/components/am2320/sensor.py
@@ -11,6 +11,7 @@ from esphome.const import (
UNIT_CELSIUS,
UNIT_PERCENT,
)
+from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
@@ -42,7 +43,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
diff --git a/esphome/components/am43/cover/__init__.py b/esphome/components/am43/cover/__init__.py
index e4ecf1444f..d1783b77df 100644
--- a/esphome/components/am43/cover/__init__.py
+++ b/esphome/components/am43/cover/__init__.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import ble_client, cover
import esphome.config_validation as cv
from esphome.const import CONF_PIN
+from esphome.types import ConfigType
CODEOWNERS = ["@buxtronix"]
DEPENDENCIES = ["ble_client"]
@@ -27,7 +28,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = await cover.new_cover(config)
cg.add(var.set_pin(config[CONF_PIN]))
cg.add(var.set_invert_position(config[CONF_INVERT_POSITION]))
diff --git a/esphome/components/am43/cover/am43_cover.cpp b/esphome/components/am43/cover/am43_cover.cpp
index 35366dbaa6..4b096983a4 100644
--- a/esphome/components/am43/cover/am43_cover.cpp
+++ b/esphome/components/am43/cover/am43_cover.cpp
@@ -114,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();
}
diff --git a/esphome/components/am43/cover/am43_cover.h b/esphome/components/am43/cover/am43_cover.h
index aa48aced15..be7af59ade 100644
--- a/esphome/components/am43/cover/am43_cover.h
+++ b/esphome/components/am43/cover/am43_cover.h
@@ -14,7 +14,7 @@ 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;
diff --git a/esphome/components/am43/sensor/__init__.py b/esphome/components/am43/sensor/__init__.py
index 2697d364ad..80341972a9 100644
--- a/esphome/components/am43/sensor/__init__.py
+++ b/esphome/components/am43/sensor/__init__.py
@@ -11,6 +11,7 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
UNIT_PERCENT,
)
+from esphome.types import ConfigType
AUTO_LOAD = ["am43"]
CODEOWNERS = ["@buxtronix"]
@@ -42,7 +43,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
diff --git a/esphome/components/am43/sensor/am43_sensor.h b/esphome/components/am43/sensor/am43_sensor.h
index 9198a5cbcb..944681bb60 100644
--- a/esphome/components/am43/sensor/am43_sensor.h
+++ b/esphome/components/am43/sensor/am43_sensor.h
@@ -14,7 +14,7 @@ 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;
diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h
index c768f1f82d..a4df00ff05 100644
--- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h
+++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h
@@ -7,7 +7,7 @@
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;
diff --git a/esphome/components/analog_threshold/binary_sensor.py b/esphome/components/analog_threshold/binary_sensor.py
index 8c13727755..b2de1d6184 100644
--- a/esphome/components/analog_threshold/binary_sensor.py
+++ b/esphome/components/analog_threshold/binary_sensor.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import binary_sensor, sensor
import esphome.config_validation as cv
from esphome.const import CONF_SENSOR_ID, CONF_THRESHOLD
+from esphome.types import ConfigType
analog_threshold_ns = cg.esphome_ns.namespace("analog_threshold")
@@ -32,7 +33,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = await binary_sensor.new_binary_sensor(config)
await cg.register_component(var, config)
diff --git a/esphome/components/animation/__init__.py b/esphome/components/animation/__init__.py
index e9630f5266..6da5268432 100644
--- a/esphome/components/animation/__init__.py
+++ b/esphome/components/animation/__init__.py
@@ -1,114 +1,41 @@
-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 . import image as animation_image
+from .image import ANIMATION_CONFIG_SCHEMA, setup_animation
-AUTO_LOAD = ["image"]
+# The deprecated top-level `animation:` shim gets the same batched
+# downloads as the `image:` platform form.
+PREFETCH_FILES = animation_image.PREFETCH_FILES
+
+AUTO_LOAD = ["image", "file"]
CODEOWNERS = ["@syndlex"]
DEPENDENCIES = ["display"]
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.h b/esphome/components/animation/animation.h
index ca800ad931..64cddbf09c 100644
--- a/esphome/components/animation/animation.h
+++ b/esphome/components/animation/animation.h
@@ -5,7 +5,7 @@
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);
@@ -35,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(); }
@@ -44,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(); }
@@ -53,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)
diff --git a/esphome/components/animation/image.py b/esphome/components/animation/image.py
new file mode 100644
index 0000000000..0265a350f7
--- /dev/null
+++ b/esphome/components/animation/image.py
@@ -0,0 +1,127 @@
+from esphome import automation
+import esphome.codegen as cg
+from esphome.components.const import CONF_LOOP
+from esphome.components.file import image as file_image
+from esphome.components.file.image import image_schema, write_image
+from esphome.components.image import Image_, validate_settings
+import esphome.config_validation as cv
+from esphome.const import CONF_ID, CONF_REPEAT
+from esphome.core import ID
+from esphome.cpp_generator import MockObj, TemplateArgsType
+from esphome.types import ConfigType
+
+CODEOWNERS = ["@syndlex"]
+
+# The animation platform shares the file platform's remote file handling,
+# including its batch-download hook.
+PREFETCH_FILES = file_image.PREFETCH_FILES
+AUTO_LOAD = ["file"]
+DEPENDENCIES = ["display"]
+
+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: ConfigType,
+ action_id: ID,
+ template_arg: cg.TemplateArguments,
+ args: TemplateArgsType,
+) -> MockObj:
+ 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.h b/esphome/components/anova/anova.h
index a3e175be28..49b1100c37 100644
--- a/esphome/components/anova/anova.h
+++ b/esphome/components/anova/anova.h
@@ -17,7 +17,7 @@ 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;
diff --git a/esphome/components/anova/anova_base.cpp b/esphome/components/anova/anova_base.cpp
index 84dd4393eb..806a441dcd 100644
--- a/esphome/components/anova/anova_base.cpp
+++ b/esphome/components/anova/anova_base.cpp
@@ -6,9 +6,9 @@
namespace esphome::anova {
-float ftoc(float f) { return (f - 32.0) * (5.0f / 9.0f); }
+float ftoc(float f) { return (f - 32.0f) * (5.0f / 9.0f); }
-float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0; }
+float ctof(float c) { return (c * 9.0f / 5.0f) + 32.0f; }
AnovaPacket *AnovaCodec::clean_packet_() {
this->packet_.length = strlen((char *) this->packet_.data);
diff --git a/esphome/components/anova/climate.py b/esphome/components/anova/climate.py
index e1fd38fddc..5590b18a83 100644
--- a/esphome/components/anova/climate.py
+++ b/esphome/components/anova/climate.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import ble_client, climate
import esphome.config_validation as cv
from esphome.const import CONF_UNIT_OF_MEASUREMENT
+from esphome.types import ConfigType
UNITS = {
"f": "f",
@@ -28,7 +29,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = await climate.new_climate(config)
await cg.register_component(var, config)
await ble_client.register_ble_node(var, config)
diff --git a/esphome/components/apds9306/apds9306.h b/esphome/components/apds9306/apds9306.h
index 093ec55bc6..f971290cdd 100644
--- a/esphome/components/apds9306/apds9306.h
+++ b/esphome/components/apds9306/apds9306.h
@@ -39,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; }
diff --git a/esphome/components/apds9306/sensor.py b/esphome/components/apds9306/sensor.py
index c3cba96fbf..4f165eec0b 100644
--- a/esphome/components/apds9306/sensor.py
+++ b/esphome/components/apds9306/sensor.py
@@ -1,6 +1,8 @@
# Based on this datasheet:
# https://www.mouser.ca/datasheet/2/678/AVGO_S_A0002854364_1-2574547.pdf
+from typing import Any
+
import esphome.codegen as cg
from esphome.components import i2c, sensor
import esphome.config_validation as cv
@@ -11,6 +13,8 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
UNIT_LUX,
)
+from esphome.cpp_generator import MockObj
+from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
@@ -55,7 +59,7 @@ AMBIENT_LIGHT_GAINS = {
}
-def _validate_measurement_rate(value):
+def _validate_measurement_rate(value: Any) -> MockObj:
value = cv.positive_time_period_milliseconds(value)
return cv.enum(MEASUREMENT_RATES, int=True)(value.total_milliseconds)
@@ -85,7 +89,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = await sensor.new_sensor(config)
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
diff --git a/esphome/components/apds9960/__init__.py b/esphome/components/apds9960/__init__.py
index 99e37d3764..7ac1e5eb32 100644
--- a/esphome/components/apds9960/__init__.py
+++ b/esphome/components/apds9960/__init__.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import i2c
import esphome.config_validation as cv
from esphome.const import CONF_ID
+from esphome.types import ConfigType
DEPENDENCIES = ["i2c"]
MULTI_CONF = True
@@ -57,7 +58,7 @@ CONFIG_SCHEMA = (
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await i2c.register_i2c_device(var, config)
diff --git a/esphome/components/apds9960/apds9960.h b/esphome/components/apds9960/apds9960.h
index 2823294207..bfa64bcc74 100644
--- a/esphome/components/apds9960/apds9960.h
+++ b/esphome/components/apds9960/apds9960.h
@@ -12,7 +12,7 @@
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)
diff --git a/esphome/components/apds9960/binary_sensor.py b/esphome/components/apds9960/binary_sensor.py
index 48e923ab2b..342f688249 100644
--- a/esphome/components/apds9960/binary_sensor.py
+++ b/esphome/components/apds9960/binary_sensor.py
@@ -2,6 +2,7 @@ import esphome.codegen as cg
from esphome.components import binary_sensor
import esphome.config_validation as cv
from esphome.const import CONF_DIRECTION, DEVICE_CLASS_MOVING
+from esphome.types import ConfigType
from . import APDS9960, CONF_APDS9960_ID
@@ -19,7 +20,7 @@ CONFIG_SCHEMA = binary_sensor.binary_sensor_schema(
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_APDS9960_ID])
var = await binary_sensor.new_binary_sensor(config)
func = getattr(hub, f"set_{config[CONF_DIRECTION]}_direction_binary_sensor")
diff --git a/esphome/components/apds9960/sensor.py b/esphome/components/apds9960/sensor.py
index 468eb0995f..a75fb79d1b 100644
--- a/esphome/components/apds9960/sensor.py
+++ b/esphome/components/apds9960/sensor.py
@@ -7,6 +7,7 @@ from esphome.const import (
STATE_CLASS_MEASUREMENT,
UNIT_PERCENT,
)
+from esphome.types import ConfigType
from . import APDS9960, CONF_APDS9960_ID
@@ -27,7 +28,7 @@ CONFIG_SCHEMA = sensor.sensor_schema(
)
-async def to_code(config):
+async def to_code(config: ConfigType) -> None:
hub = await cg.get_variable(config[CONF_APDS9960_ID])
var = await sensor.new_sensor(config)
func = getattr(hub, f"set_{config[CONF_TYPE]}_sensor")
diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py
index ca74483a2b..2e891a9663 100644
--- a/esphome/components/api/__init__.py
+++ b/esphome/components/api/__init__.py
@@ -1,11 +1,21 @@
-import base64
import logging
+import re
+from typing import Any
from esphome import automation
from esphome.automation import Condition
import esphome.codegen as cg
from esphome.components.logger import request_log_listener
-from esphome.config_helpers import get_logger_level
+
+# ENCRYPTION_SCHEMA and validate_encryption_key are re-exported for external
+# components and downstream consumers that import them from api
+from esphome.components.noise import ( # noqa: F401
+ ENCRYPTION_SCHEMA,
+ decode_encryption_key,
+ encryption_schema,
+ validate_encryption_key,
+)
+from esphome.config_helpers import filter_source_files_from_defines, get_logger_level
import esphome.config_validation as cv
from esphome.const import (
CONF_ACTION,
@@ -13,6 +23,7 @@ from esphome.const import (
CONF_CAPTURE_RESPONSE,
CONF_DATA,
CONF_DATA_TEMPLATE,
+ CONF_ENCRYPTION,
CONF_EVENT,
CONF_ID,
CONF_KEY,
@@ -36,6 +47,10 @@ from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_pr
from esphome.cpp_generator import MockObj, TemplateArgsType
from esphome.types import ConfigFragmentType, ConfigType
+# Compat alias: downstream consumers (e.g. device-builder) referenced the
+# schema by its old private name before it moved to the noise component
+_encryption_schema = encryption_schema
+
_LOGGER = logging.getLogger(__name__)
DOMAIN = "api"
@@ -44,9 +59,15 @@ CODEOWNERS = ["@esphome/core"]
def AUTO_LOAD(config: ConfigType) -> list[str]:
- """Conditionally auto-load json only when capture_response is used."""
+ """Conditionally auto-load noise (encryption) and json (capture_response)."""
base = ["socket"]
+ # A falsy config is a tooling probe for the maximal set (None from
+ # dependency resolution, {} from the components-graph platform probe);
+ # a validated config always carries defaults, never empty
+ if not config or CONF_ENCRYPTION in config:
+ base = base + ["noise"]
+
# Check if any homeassistant.action/homeassistant.service has capture_response: true
# This flag is set during config validation in _validate_response_config
if not config or CORE.data.get(DOMAIN, {}).get(CONF_CAPTURE_RESPONSE, False):
@@ -102,7 +123,6 @@ SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
for name, t in _SERVICE_ARG_SCALAR_TYPES.items()
},
}
-CONF_ENCRYPTION = "encryption"
CONF_BATCH_DELAY = "batch_delay"
CONF_CUSTOM_SERVICES = "custom_services"
CONF_HOMEASSISTANT_SERVICES = "homeassistant_services"
@@ -112,18 +132,21 @@ CONF_MAX_SEND_QUEUE = "max_send_queue"
CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only"
-def validate_encryption_key(value):
- value = cv.string_strict(value)
- try:
- decoded = base64.b64decode(value, validate=True)
- except ValueError as err:
- raise cv.Invalid("Invalid key format, please check it's using base64") from err
+def _register_provisioning_source(config: ConfigType) -> ConfigType:
+ """Register the API as a provisioning source when encryption is enabled.
- if len(decoded) != 32:
- raise cv.Invalid("Encryption key must be base64 and 32 bytes long")
+ 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
- # Return original data for roundtrip conversion
- return value
+ provisioning.register_source("api")
+ if CONF_KEY in encryption:
+ provisioning.report_hardcoded_credentials("api")
+ return config
CONF_SUPPORTS_RESPONSE = "supports_response"
@@ -200,7 +223,7 @@ def _auto_detect_supports_response(config: ConfigType) -> ConfigType:
return config
-def _validate_supports_response(value):
+def _validate_supports_response(value: Any) -> str:
"""Validate supports_response after auto-detection has set the value."""
return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value)
@@ -232,18 +255,6 @@ ACTIONS_SCHEMA = automation.validate_automation(
),
)
-ENCRYPTION_SCHEMA = cv.Schema(
- {
- cv.Optional(CONF_KEY): validate_encryption_key,
- }
-)
-
-
-def _encryption_schema(config):
- if config is None:
- config = {}
- return ENCRYPTION_SCHEMA(config)
-
def _consume_api_sockets(config: ConfigType) -> ConfigType:
"""Register socket needs for API component."""
@@ -279,7 +290,7 @@ CONFIG_SCHEMA = cv.All(
CONF_SERVICES, group_of_exclusion=CONF_ACTIONS
): ACTIONS_SCHEMA,
cv.Exclusive(CONF_ACTIONS, group_of_exclusion=CONF_ACTIONS): ACTIONS_SCHEMA,
- cv.Optional(CONF_ENCRYPTION): _encryption_schema,
+ cv.Optional(CONF_ENCRYPTION): encryption_schema,
cv.Optional(CONF_BATCH_DELAY, default="100ms"): cv.All(
cv.positive_time_period_milliseconds,
cv.Range(max=cv.TimePeriod(milliseconds=65535)),
@@ -300,21 +311,23 @@ CONFIG_SCHEMA = cv.All(
CONF_LISTEN_BACKLOG,
esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets
esp32=4, # More RAM (520KB), BSD sockets
- rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266
+ rp2=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266
bk72xx=4, # Moderate RAM, BSD-style sockets
rtl87xx=4, # Moderate RAM, BSD-style sockets
host=4, # Abundant resources
ln882x=4, # Moderate RAM
+ nrf52=4, # ~256KB RAM, BSD sockets
): cv.int_range(min=1, max=10),
cv.SplitDefault(
CONF_MAX_CONNECTIONS,
esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes
esp32=5, # 520KB RAM available
- rp2040=4, # 264KB RAM but LWIP constraints
+ rp2=4, # 264KB RAM but LWIP constraints
bk72xx=5, # Moderate RAM
rtl87xx=5, # Moderate RAM
host=8, # Abundant resources
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
@@ -324,7 +337,7 @@ CONFIG_SCHEMA = cv.All(
CONF_MAX_SEND_QUEUE,
esp8266=4, # Limited RAM, need to fail fast
esp32=8, # More RAM, can buffer more
- rp2040=8, # Moderate RAM
+ rp2=8, # Moderate RAM
bk72xx=8, # Moderate RAM
nrf52=8, # Moderate RAM
rtl87xx=8, # Moderate RAM
@@ -335,6 +348,7 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
cv.rename_key(CONF_SERVICES, CONF_ACTIONS),
_consume_api_sockets,
+ _register_provisioning_source,
)
@@ -373,7 +387,7 @@ async def to_code(config: ConfigType) -> None:
if actions := config.get(CONF_ACTIONS, []):
# Collect all triggers first, then register all at once with initializer_list
- triggers: list[cg.Pvariable] = []
+ triggers: list[cg.MockObj] = []
for conf in actions:
func_args: list[tuple[MockObj, str]] = []
service_template_args: list[MockObj] = [] # User service argument types
@@ -463,21 +477,20 @@ async def to_code(config: ConfigType) -> None:
if (encryption_config := config.get(CONF_ENCRYPTION, None)) is not None:
if key := encryption_config.get(CONF_KEY):
- decoded = base64.b64decode(key)
+ decoded = decode_encryption_key(key)
cg.add(var.set_noise_psk(list(decoded)))
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")
@@ -487,6 +500,40 @@ async def to_code(config: ConfigType) -> None:
KEY_VALUE_SCHEMA = cv.Schema({cv.string: cv.templatable(cv.string_strict)})
+_ID_CALL_PROG = re.compile(r"\bid\s*\(")
+
+
+# Remove before 2027.3.0: untagged strings that look like lambda source keep
+# being compiled as lambdas during the deprecation window
+def _coerce_implicit_lambda(value: Any) -> Any:
+ if not isinstance(value, str):
+ return value
+ if cv.looks_like_returning_lambda(value):
+ _LOGGER.warning(
+ "[api] The 'variables' value '%s' looks like a lambda but is "
+ "missing the !lambda tag. It is compiled as a lambda for now but "
+ "will be sent as literal text from 2027.3.0. Add !lambda to keep "
+ "it evaluated; literal text belongs under 'data:'.",
+ value,
+ )
+ # cv.templatable runs returning_lambda on the coerced Lambda
+ return cv.lambda_(value)
+ if _ID_CALL_PROG.search(value):
+ # lambda source without a return: issue 5394's mistake class
+ _LOGGER.warning(
+ "[api] The 'variables' value '%s' is sent as literal text; wrap "
+ "it in !lambda 'return ...;' to evaluate it instead.",
+ value,
+ )
+ return value
+
+
+# Static strings or !lambda values. cv.templatable stays introspectable for
+# schema tooling; removing the shim leaves KEY_VALUE_SCHEMA.
+VARIABLES_SCHEMA = cv.Schema(
+ {cv.string: cv.All(_coerce_implicit_lambda, cv.templatable(cv.string_strict))}
+)
+
def _validate_response_config(config: ConfigType) -> ConfigType:
# Validate dependencies:
@@ -523,9 +570,7 @@ HOMEASSISTANT_ACTION_ACTION_SCHEMA = cv.All(
),
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
- cv.Optional(CONF_VARIABLES, default={}): cv.Schema(
- {cv.string: cv.returning_lambda}
- ),
+ cv.Optional(CONF_VARIABLES, default={}): VARIABLES_SCHEMA,
cv.Optional(CONF_RESPONSE_TEMPLATE): cv.templatable(cv.string),
cv.Optional(CONF_CAPTURE_RESPONSE, default=False): cv.boolean,
cv.Optional(CONF_ON_SUCCESS): automation.validate_automation(single=True),
@@ -538,24 +583,27 @@ 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,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
-):
+) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, False)
@@ -583,6 +631,8 @@ async def homeassistant_service_to_code(
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None)
+ if isinstance(templ, str):
+ templ = cg.FlashStringLiteral(templ)
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
if on_error := config.get(CONF_ON_ERROR):
@@ -621,7 +671,7 @@ async def homeassistant_service_to_code(
return var
-def validate_homeassistant_event(value):
+def validate_homeassistant_event(value: Any) -> str:
value = cv.string(value)
if not value.startswith("esphome."):
raise cv.Invalid(
@@ -637,18 +687,25 @@ HOMEASSISTANT_EVENT_ACTION_SCHEMA = cv.Schema(
cv.Required(CONF_EVENT): validate_homeassistant_event,
cv.Optional(CONF_DATA, default={}): KEY_VALUE_SCHEMA,
cv.Optional(CONF_DATA_TEMPLATE, default={}): KEY_VALUE_SCHEMA,
- cv.Optional(CONF_VARIABLES, default={}): KEY_VALUE_SCHEMA,
+ cv.Optional(CONF_VARIABLES, default={}): VARIABLES_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,
HOMEASSISTANT_EVENT_ACTION_SCHEMA,
synchronous=True,
)
-async def homeassistant_event_to_code(config, action_id, template_arg, args):
+async def homeassistant_event_to_code(
+ config: ConfigType,
+ action_id: ID,
+ template_arg: cg.TemplateArguments,
+ args: TemplateArgsType,
+) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
@@ -676,6 +733,8 @@ async def homeassistant_event_to_code(config, action_id, template_arg, args):
cg.add(var.init_variables(len(config[CONF_VARIABLES])))
for key, value in config[CONF_VARIABLES].items():
templ = await cg.templatable(value, args, None)
+ if isinstance(templ, str):
+ templ = cg.FlashStringLiteral(templ)
cg.add(var.add_variable(cg.FlashStringLiteral(key), templ))
return var
@@ -696,7 +755,12 @@ HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA = cv.maybe_simple_value(
HOMEASSISTANT_TAG_SCANNED_ACTION_SCHEMA,
synchronous=True,
)
-async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, args):
+async def homeassistant_tag_scanned_to_code(
+ config: ConfigType,
+ action_id: ID,
+ template_arg: cg.TemplateArguments,
+ args: TemplateArgsType,
+) -> MockObj:
cg.add_define("USE_API_HOMEASSISTANT_SERVICES")
serv = await cg.get_variable(config[CONF_ID])
var = cg.new_Pvariable(action_id, template_arg, serv, True)
@@ -712,7 +776,7 @@ CONF_SUCCESS = "success"
CONF_ERROR_MESSAGE = "error_message"
-def _validate_api_respond_data(config):
+def _validate_api_respond_data(config: ConfigType) -> ConfigType:
"""Set flag during validation so AUTO_LOAD can include json component."""
if CONF_DATA in config:
CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True
@@ -796,18 +860,32 @@ API_CONNECTED_CONDITION_SCHEMA = cv.Schema(
@automation.register_condition(
"api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA
)
-async def api_connected_to_code(config, condition_id, template_arg, args):
+async def api_connected_to_code(
+ config: ConfigType,
+ condition_id: ID,
+ template_arg: cg.TemplateArguments,
+ args: TemplateArgsType,
+) -> MockObj:
var = cg.new_Pvariable(condition_id, template_arg)
templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_)
cg.add(var.set_state_subscription_only(templ))
return var
+# user_services.cpp is only needed when user defined actions exist; the
+# frame helpers are fully #ifdef'd on the protocol defines set in to_code
+# (both are set when encryption is configured without a key).
+_define_filter = filter_source_files_from_defines(
+ {
+ "user_services.cpp": "USE_API_USER_DEFINED_ACTIONS",
+ "api_frame_helper_noise.cpp": "USE_API_NOISE",
+ "api_frame_helper_plaintext.cpp": "USE_API_PLAINTEXT",
+ }
+)
+
+
def FILTER_SOURCE_FILES() -> list[str]:
- """Filter out api_pb2_dump.cpp when proto message dumping is not enabled,
- user_services.cpp when no services are defined, and protocol-specific
- implementations based on encryption configuration."""
- files_to_filter: list[str] = []
+ files_to_filter = _define_filter()
# api_pb2_dump.cpp is only needed when HAS_PROTO_MESSAGE_DUMP is defined
# This is a particularly large file that still needs to be opened and read
@@ -818,21 +896,4 @@ def FILTER_SOURCE_FILES() -> list[str]:
if get_logger_level() != "VERY_VERBOSE":
files_to_filter.append("api_pb2_dump.cpp")
- # user_services.cpp is only needed when services are defined
- config = CORE.config.get(DOMAIN, {})
- if config and not config.get(CONF_ACTIONS) and not config[CONF_CUSTOM_SERVICES]:
- files_to_filter.append("user_services.cpp")
-
- # Filter protocol-specific implementations based on encryption configuration
- encryption_config = config.get(CONF_ENCRYPTION) if config else None
-
- # If encryption is not configured at all, we only need plaintext
- if encryption_config is None:
- files_to_filter.append("api_frame_helper_noise.cpp")
- # If encryption is configured with a key, we only need noise
- elif encryption_config.get(CONF_KEY):
- files_to_filter.append("api_frame_helper_plaintext.cpp")
- # If encryption is configured but no key is provided, we need both
- # (this allows a plaintext client to provide a noise key)
-
return files_to_filter
diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto
index f4f15c1042..c11700782e 100644
--- a/esphome/components/api/api.proto
+++ b/esphome/components/api/api.proto
@@ -19,6 +19,7 @@ service APIConnection {
rpc device_info (DeviceInfoRequest) returns (DeviceInfoResponse) {
option (needs_authentication) = false;
}
+ rpc device_capabilities (DeviceCapabilitiesRequest) returns (DeviceCapabilitiesResponse) {}
rpc list_entities (ListEntitiesRequest) returns (void) {}
rpc subscribe_states (SubscribeStatesRequest) returns (void) {}
rpc subscribe_logs (SubscribeLogsRequest) returns (void) {}
@@ -158,6 +159,16 @@ message AuthenticationResponse {
bool invalid_password = 1;
}
+// Reason a party is requesting the connection be closed.
+enum DisconnectReason {
+ // No specific reason / not provided (default for older peers).
+ DISCONNECT_REASON_UNSPECIFIED = 0;
+ // The device's provisioning window has expired. The device must be reset
+ // (power-cycled) to reopen the provisioning window before it will accept a
+ // connection again.
+ DISCONNECT_REASON_PROVISIONING_CLOSED = 1;
+}
+
// Request to close the connection.
// Can be sent by both the client and server
message DisconnectRequest {
@@ -166,6 +177,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 {
@@ -217,6 +232,7 @@ enum SerialProxyPortType {
message SerialProxyInfo {
string name = 1; // Human-readable port name
SerialProxyPortType port_type = 2; // Port type (RS232, RS485)
+ uint32 configured_line_states = 3; // Bitmask of SerialProxyLineStateFlags this instance can drive
}
// DeviceInfoResponse max_data_length values:
@@ -229,6 +245,12 @@ message SerialProxyInfo {
// model = 127 (core/config.BOARD_MAX_LENGTH, validated in platform schemas)
// project_name/project_version = 127 (core/config.PROJECT_MAX_LENGTH)
// suggested_area = 120 (core/config.FRIENDLY_NAME_MAX_LEN via AREA_SCHEMA)
+//
+// Some fields below are marked "Superseded by DeviceCapabilitiesResponse". They
+// have moved to that message as of API 1.15, but are still sent here so that
+// older clients keep working. Do NOT mark them (deprecated) until the removal
+// release: in this repo (deprecated) makes the generator drop the field
+// entirely, so the device would stop sending it.
message DeviceInfoResponse {
option (id) = 10;
option (source) = SOURCE_SERVER;
@@ -266,6 +288,8 @@ message DeviceInfoResponse {
// Deprecated in API version 1.9
uint32 legacy_bluetooth_proxy_version = 11 [deprecated=true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
+
+ // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy as of API 1.15.
uint32 bluetooth_proxy_feature_flags = 15 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
string manufacturer = 12 [(max_data_length) = 20, (force) = true];
@@ -274,11 +298,14 @@ message DeviceInfoResponse {
// Deprecated in API version 1.10
uint32 legacy_voice_assistant_version = 14 [deprecated=true, (field_ifdef) = "USE_VOICE_ASSISTANT"];
+
+ // Superseded by DeviceCapabilitiesResponse.voice_assistant as of API 1.15.
uint32 voice_assistant_feature_flags = 17 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
string suggested_area = 16 [(max_data_length) = 120, (force) = true, (field_ifdef) = "USE_AREAS"];
// The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
+ // Superseded by DeviceCapabilitiesResponse.bluetooth_proxy.mac_address as of API 1.15.
string bluetooth_mac_address = 18 [(max_data_length) = 17, (force) = true, (field_ifdef) = "USE_BLUETOOTH_PROXY"];
// Supports receiving and saving api encryption key
@@ -291,11 +318,76 @@ message DeviceInfoResponse {
AreaInfo area = 22 [(field_ifdef) = "USE_AREAS"];
// Indicates if Z-Wave proxy support is available and features supported
+ // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
uint32 zwave_proxy_feature_flags = 23 [(field_ifdef) = "USE_ZWAVE_PROXY"];
+ // Superseded by DeviceCapabilitiesResponse.zwave_proxy as of API 1.15.
uint32 zwave_home_id = 24 [(field_ifdef) = "USE_ZWAVE_PROXY"];
// Serial proxy instance metadata
+ // Superseded by DeviceCapabilitiesResponse.serial_proxies as of API 1.15.
repeated SerialProxyInfo serial_proxies = 25 [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
+
+ // Device is unprovisioned and accepts Noise handshakes with the well-known
+ // 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"];
+}
+
+// ==================== DEVICE CAPABILITIES ====================
+
+// Asks the device which optional features it supports.
+//
+// This message exists so that DeviceInfoResponse does not have to keep growing
+// a flat list of feature flags. DeviceInfoResponse is served before
+// authentication, so it is limited to identity information. Capabilities are
+// only served on an authenticated connection (encrypted as well, when
+// encryption is configured).
+//
+// Clients that see api_version >= 1.15 should read these values from
+// DeviceCapabilitiesResponse and ignore the matching DeviceInfoResponse fields.
+// Older clients keep reading DeviceInfoResponse, which still carries the same
+// values, so this is not a breaking change.
+message DeviceCapabilitiesRequest {
+ option (id) = 149;
+ option (source) = SOURCE_CLIENT;
+ // Empty
+}
+
+// Each feature gets its own sub-message so that it can gain fields over time
+// without crowding the top-level field numbering.
+//
+// Note: a sub-message whose fields are all at their default value is not sent
+// at all, so the presence of a sub-message is not a reliable test for "this
+// feature is compiled in". Clients should test a value inside it, for example
+// a non-zero feature_flags, exactly as they do today with DeviceInfoResponse.
+
+message BluetoothProxyCapabilities {
+ // Bitmask of the features this proxy supports
+ uint32 feature_flags = 1;
+ // The Bluetooth mac address of the device. For example "AC:BC:32:89:0E:AA"
+ string mac_address = 2 [(max_data_length) = 17, (force) = true];
+}
+
+message VoiceAssistantCapabilities {
+ // Bitmask of the features this voice assistant supports
+ uint32 feature_flags = 1;
+}
+
+message ZWaveProxyCapabilities {
+ // Bitmask of the features this proxy supports
+ uint32 feature_flags = 1;
+ uint32 home_id = 2;
+}
+
+message DeviceCapabilitiesResponse {
+ option (id) = 150;
+ option (source) = SOURCE_SERVER;
+
+ BluetoothProxyCapabilities bluetooth_proxy = 1 [(field_ifdef) = "USE_BLUETOOTH_PROXY"];
+ VoiceAssistantCapabilities voice_assistant = 2 [(field_ifdef) = "USE_VOICE_ASSISTANT"];
+ ZWaveProxyCapabilities zwave_proxy = 3 [(field_ifdef) = "USE_ZWAVE_PROXY"];
+ repeated SerialProxyInfo serial_proxies = 4
+ [(field_ifdef) = "USE_SERIAL_PROXY", (fixed_array_size_define) = "SERIAL_PROXY_COUNT"];
}
message ListEntitiesRequest {
@@ -911,8 +1003,12 @@ message GetTimeResponse {
option (no_delay) = true;
fixed32 epoch_seconds = 1;
- string timezone = 2;
- ParsedTimezone parsed_timezone = 3;
+ // Deprecated in 2026.9.0: clients still send this string for older firmware,
+ // but new firmware only reads parsed_timezone. Clients older than Home
+ // Assistant 2026.3.0 that send only the string leave the device on its
+ // codegen-configured timezone (or UTC).
+ string timezone = 2 [deprecated = true];
+ ParsedTimezone parsed_timezone = 3 [(track_presence) = true];
}
// ==================== USER-DEFINES SERVICES ====================
@@ -1558,7 +1654,8 @@ message ListEntitiesMediaPlayerResponse {
bool disabled_by_default = 6;
EntityCategory entity_category = 7;
- bool supports_pause = 8;
+ // Deprecated in ESPHome 2026.9.0; use feature_flags instead.
+ bool supports_pause = 8 [deprecated = true];
repeated MediaPlayerSupportedFormat supported_formats = 9;
@@ -1669,7 +1766,7 @@ enum BluetoothDeviceRequestType {
message BluetoothDeviceRequest {
option (id) = 68;
option (source) = SOURCE_CLIENT;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
BluetoothDeviceRequestType request_type = 2;
@@ -1680,7 +1777,7 @@ message BluetoothDeviceRequest {
message BluetoothDeviceConnectionResponse {
option (id) = 69;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool connected = 2;
@@ -1691,7 +1788,7 @@ message BluetoothDeviceConnectionResponse {
message BluetoothGATTGetServicesRequest {
option (id) = 70;
option (source) = SOURCE_CLIENT;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
}
@@ -1735,7 +1832,7 @@ message BluetoothGATTService {
message BluetoothGATTGetServicesResponse {
option (id) = 71;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
repeated BluetoothGATTService services = 2;
@@ -1744,7 +1841,7 @@ message BluetoothGATTGetServicesResponse {
message BluetoothGATTGetServicesDoneResponse {
option (id) = 72;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
}
@@ -1752,7 +1849,7 @@ message BluetoothGATTGetServicesDoneResponse {
message BluetoothGATTReadRequest {
option (id) = 73;
option (source) = SOURCE_CLIENT;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1761,7 +1858,7 @@ message BluetoothGATTReadRequest {
message BluetoothGATTReadResponse {
option (id) = 74;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1773,7 +1870,7 @@ message BluetoothGATTReadResponse {
message BluetoothGATTWriteRequest {
option (id) = 75;
option (source) = SOURCE_CLIENT;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1785,7 +1882,7 @@ message BluetoothGATTWriteRequest {
message BluetoothGATTReadDescriptorRequest {
option (id) = 76;
option (source) = SOURCE_CLIENT;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1794,7 +1891,7 @@ message BluetoothGATTReadDescriptorRequest {
message BluetoothGATTWriteDescriptorRequest {
option (id) = 77;
option (source) = SOURCE_CLIENT;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1805,7 +1902,7 @@ message BluetoothGATTWriteDescriptorRequest {
message BluetoothGATTNotifyRequest {
option (id) = 78;
option (source) = SOURCE_CLIENT;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1815,7 +1912,7 @@ message BluetoothGATTNotifyRequest {
message BluetoothGATTNotifyDataResponse {
option (id) = 79;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1826,13 +1923,13 @@ message BluetoothGATTNotifyDataResponse {
message SubscribeBluetoothConnectionsFreeRequest {
option (id) = 80;
option (source) = SOURCE_CLIENT;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
}
message BluetoothConnectionsFreeResponse {
option (id) = 81;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint32 free = 1;
uint32 limit = 2;
@@ -1845,7 +1942,7 @@ message BluetoothConnectionsFreeResponse {
message BluetoothGATTErrorResponse {
option (id) = 82;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1855,7 +1952,7 @@ message BluetoothGATTErrorResponse {
message BluetoothGATTWriteResponse {
option (id) = 83;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1864,7 +1961,7 @@ message BluetoothGATTWriteResponse {
message BluetoothGATTNotifyResponse {
option (id) = 84;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 handle = 2;
@@ -1873,7 +1970,7 @@ message BluetoothGATTNotifyResponse {
message BluetoothDevicePairingResponse {
option (id) = 85;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool paired = 2;
@@ -1883,7 +1980,7 @@ message BluetoothDevicePairingResponse {
message BluetoothDeviceUnpairingResponse {
option (id) = 86;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool success = 2;
@@ -1899,7 +1996,7 @@ message UnsubscribeBluetoothLEAdvertisementsRequest {
message BluetoothDeviceClearCacheResponse {
option (id) = 88;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
bool success = 2;
@@ -2531,6 +2628,22 @@ message ZWaveProxyRequest {
bytes data = 2;
}
+enum ZWaveProxyStatus {
+ ZWAVE_PROXY_STATUS_OK = 0; // Request completed successfully
+ ZWAVE_PROXY_STATUS_IN_USE = 1; // Denied: another client is already subscribed
+ ZWAVE_PROXY_STATUS_NOT_SUPPORTED = 2; // Request type not supported
+}
+
+// Acknowledges a ZWaveProxyRequest (subscribe/unsubscribe). Sent since API 1.16.
+message ZWaveProxyRequestResponse {
+ option (id) = 151;
+ option (source) = SOURCE_SERVER;
+ option (ifdef) = "USE_ZWAVE_PROXY";
+
+ ZWaveProxyRequestType type = 1; // Which request type this responds to
+ ZWaveProxyStatus status = 2; // Result status
+}
+
// ==================== INFRARED ====================
// Note: Feature and capability flag enums are defined in
// esphome/components/infrared/infrared.h
@@ -2674,12 +2787,18 @@ message SerialProxyGetModemPinsResponse {
uint32 instance = 1; // Instance index (0-based)
uint32 line_states = 2; // Bitmask of SerialProxyLineStateFlags
+ SerialProxyStatus status = 3; // INVALID_ARGUMENT if the instance index is out of range (since API 1.16)
}
enum SerialProxyRequestType {
SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0; // Subscribe to receive data from this serial proxy instance
SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1; // Unsubscribe from this serial proxy instance
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2; // Flush the serial port (block until all TX data is sent)
+ // Values below are only valid in SerialProxyRequestResponse.type, identifying which
+ // operation is being acknowledged. Sending them in SerialProxyRequest.type is an
+ // error the device answers with INVALID_ARGUMENT.
+ SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3; // Acknowledges a SerialProxyConfigureRequest
+ SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4; // Acknowledges a SerialProxySetModemPinsRequest
}
enum SerialProxyStatus {
@@ -2688,6 +2807,8 @@ enum SerialProxyStatus {
SERIAL_PROXY_STATUS_ERROR = 2; // Driver or hardware error
SERIAL_PROXY_STATUS_TIMEOUT = 3; // Timed out before TX completed
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4; // Request type not supported by this instance
+ SERIAL_PROXY_STATUS_PORT_IN_USE = 5; // Denied: another client holds the port
+ SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6; // Invalid instance index or parameter value
}
// Generic request message for simple serial proxy operations
@@ -2700,7 +2821,9 @@ message SerialProxyRequest {
SerialProxyRequestType type = 2; // Request type
}
-// Response to a SerialProxyRequest (e.g. flush completion or failure)
+// Acknowledges a serial proxy operation; the type field identifies which
+// operation is being acknowledged. Flush has been acknowledged since the
+// message was introduced; all other acknowledgements are sent since API 1.16.
message SerialProxyRequestResponse {
option (id) = 147;
option (source) = SOURCE_SERVER;
@@ -2716,7 +2839,7 @@ message SerialProxyRequestResponse {
message BluetoothSetConnectionParamsRequest {
option (id) = 145;
option (source) = SOURCE_CLIENT;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
uint32 min_interval = 2; // units of 1.25ms
@@ -2728,7 +2851,7 @@ message BluetoothSetConnectionParamsRequest {
message BluetoothSetConnectionParamsResponse {
option (id) = 146;
option (source) = SOURCE_SERVER;
- option (ifdef) = "USE_BLUETOOTH_PROXY";
+ option (ifdef) = "USE_BLUETOOTH_PROXY_CONNECTIONS";
uint64 address = 1;
int32 error = 2;
diff --git a/esphome/components/api/api_buffer.cpp b/esphome/components/api/api_buffer.cpp
index 6db18b0365..fc45a4e971 100644
--- a/esphome/components/api/api_buffer.cpp
+++ b/esphome/components/api/api_buffer.cpp
@@ -1,13 +1,20 @@
#include "api_buffer.h"
+#include
namespace esphome::api {
-void APIBuffer::grow_(size_t n) {
- auto new_data = make_buffer(n);
+bool APIBuffer::grow_(size_t n) {
+ // nothrow (no zero-fill) so OOM is reportable; plain new aborts instead
+ // (NEW_OOM_ABORT on ESP8266 Arduino, exception stub on ESP-IDF).
+ // RAMAllocator is no fit here: unique_ptr needs delete[]-compatible memory.
+ std::unique_ptr new_data(new (std::nothrow) uint8_t[n]);
+ if (new_data == nullptr)
+ return false;
if (this->size_)
std::memcpy(new_data.get(), this->data_.get(), this->size_);
this->data_ = std::move(new_data);
this->capacity_ = n;
+ return true;
}
} // namespace esphome::api
diff --git a/esphome/components/api/api_buffer.h b/esphome/components/api/api_buffer.h
index 1d0cccf61c..396dadbe58 100644
--- a/esphome/components/api/api_buffer.h
+++ b/esphome/components/api/api_buffer.h
@@ -9,16 +9,6 @@
namespace esphome::api {
-/// Helper to use make_unique_for_overwrite where available (skips zero-fill),
-/// falling back to make_unique on older GCC (ESP8266, LibreTiny).
-inline std::unique_ptr make_buffer(size_t n) {
-#if defined(USE_ESP8266) || defined(USE_LIBRETINY)
- return std::make_unique(n);
-#else
- return std::make_unique_for_overwrite(n);
-#endif
-}
-
/// Byte buffer that skips zero-initialization on resize().
///
/// std::vector::resize() zero-fills new bytes via memset. For the
@@ -36,23 +26,23 @@ inline std::unique_ptr make_buffer(size_t n) {
class APIBuffer {
public:
void clear() { this->size_ = 0; }
- inline void reserve(size_t n) ESPHOME_ALWAYS_INLINE {
- if (n > this->capacity_)
- this->grow_(n);
- }
- inline void resize(size_t n) ESPHOME_ALWAYS_INLINE {
- this->reserve(n);
- this->size_ = n; // no zero-fill
- }
+ /// Returns false if allocation fails; the buffer is left unchanged.
+ [[nodiscard]] inline bool reserve(size_t n) ESPHOME_ALWAYS_INLINE { return n <= this->capacity_ || this->grow_(n); }
+ /// Returns false if allocation fails; the buffer is left unchanged. No zero-fill.
+ [[nodiscard]] inline bool resize(size_t n) ESPHOME_ALWAYS_INLINE { return this->reserve_and_resize(n, n); }
/// 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));
+ /// Returns false if allocation fails; the buffer is left unchanged.
+ [[nodiscard]] inline bool reserve_and_resize(size_t reserve_size, size_t new_size) ESPHOME_ALWAYS_INLINE {
+ if (!this->reserve(std::max(reserve_size, new_size)))
+ return false;
this->size_ = new_size;
+ return true;
}
uint8_t *data() { return this->data_.get(); }
const uint8_t *data() const { return this->data_.get(); }
size_t size() const { return this->size_; }
+ size_t capacity() const { return this->capacity_; }
bool empty() const { return this->size_ == 0; }
uint8_t &operator[](size_t i) { return this->data_[i]; }
const uint8_t &operator[](size_t i) const { return this->data_[i]; }
@@ -64,7 +54,7 @@ class APIBuffer {
}
protected:
- void grow_(size_t n);
+ bool grow_(size_t n);
std::unique_ptr data_;
size_t size_{0};
size_t capacity_{0};
diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp
index b6f4aa2141..bc088ca473 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 the APIServer-dependent APIConnection inlines
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
#endif
@@ -22,8 +23,12 @@
#include "esphome/core/application.h"
#include "esphome/core/entity_base.h"
#include "esphome/core/hal.h"
+#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/version.h"
+#ifdef USE_PROVISIONING
+#include "esphome/components/provisioning/provisioning.h"
+#endif
#ifdef USE_DEEP_SLEEP
#include "esphome/components/deep_sleep/deep_sleep_component.h"
@@ -84,6 +89,13 @@ static_assert(ESPHOME_DEVICE_NAME_MAX_LEN <= 31, "Update max_data_length for nam
static_assert(ESPHOME_FRIENDLY_NAME_MAX_LEN <= 120, "Update max_data_length for friendly_name in api.proto");
static const char *const TAG = "api.connection";
+
+#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
+void log_dropped_message(const char *tag, int line, const LogString *what) {
+ esp_log_printf_(ESPHOME_LOG_LEVEL_WARN, tag, line, ESPHOME_LOG_FORMAT("%s dropped, TCP buffer full"),
+ LOG_STR_ARG(what));
+}
+#endif
#ifdef USE_CAMERA
static const int CAMERA_STOP_STREAM = 5000;
#endif
@@ -148,11 +160,6 @@ APIConnection::APIConnection(std::unique_ptr sock, APIServer *pa
#else
#error "No frame helper defined"
#endif
-#ifdef USE_CAMERA
- if (camera::Camera::instance() != nullptr) {
- this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()};
- }
-#endif
}
void APIConnection::start() {
@@ -194,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:
@@ -252,6 +282,15 @@ void APIConnection::loop() {
// No more data available
break;
} else if (err != APIError::OK) {
+#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
+ // Checked inside the error branch to keep the hot err == OK path
+ // free of it; this can only fire on the first bytes of a plaintext
+ // helper on an unprovisioned device
+ if (err == APIError::PROTOCOL_SWITCH_TO_NOISE) {
+ this->upgrade_helper_to_noise_();
+ return;
+ }
+#endif
this->fatal_error_with_log_(LOG_STR("Reading failed"), err);
return;
} else {
@@ -373,15 +412,15 @@ void APIConnection::finalize_iterator_sync_() {
}
void APIConnection::process_iterator_batch_(ComponentIterator &iterator) {
- size_t initial_size = this->deferred_batch_.size();
- size_t max_batch = this->get_max_batch_size_();
- while (!iterator.completed() && (this->deferred_batch_.size() - initial_size) < max_batch) {
- iterator.advance();
- }
+ // Budget by remaining batch capacity so a pass cannot overfill the batch;
+ // stops early on a refused send and resumes next loop pass
+ size_t batch_size = this->deferred_batch_.size();
+ if (batch_size < MAX_INITIAL_BATCH_SIZE)
+ iterator.try_advance(MAX_INITIAL_BATCH_SIZE - batch_size);
- // If the batch is full, process it immediately
- // Note: iterator.advance() already calls schedule_batch_() via schedule_message_()
- if (this->deferred_batch_.size() >= max_batch) {
+ // Flush immediately once enough is queued (not guaranteed every pass);
+ // partial batches go out via the batch timer or finalize_iterator_sync_()
+ if (this->deferred_batch_.size() >= MAX_INITIAL_BATCH_SIZE) {
this->process_batch_();
}
}
@@ -417,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();
}
@@ -768,6 +797,7 @@ uint16_t APIConnection::try_send_climate_info(EntityBase *entity, APIConnection
msg.supports_action = traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION);
// Current feature flags and other supported parameters
msg.feature_flags = traits.get_feature_flags();
+ msg.temperature_unit = static_cast(traits.get_temperature_unit());
msg.supported_modes = &traits.get_supported_modes();
msg.visual_min_temperature = traits.get_visual_min_temperature();
msg.visual_max_temperature = traits.get_visual_max_temperature();
@@ -1069,7 +1099,6 @@ uint16_t APIConnection::try_send_media_player_info(EntityBase *entity, APIConnec
auto *media_player = static_cast(entity);
ListEntitiesMediaPlayerResponse msg;
auto traits = media_player->get_traits();
- msg.supports_pause = traits.get_supports_pause();
msg.feature_flags = traits.get_feature_flags();
for (auto &supported_format : traits.get_supported_formats()) {
msg.supported_formats.emplace_back();
@@ -1105,6 +1134,7 @@ void APIConnection::try_send_camera_image_() {
if (!this->image_reader_)
return;
+ const auto *cam = camera::Camera::instance();
// Send as many chunks as possible without blocking
while (this->image_reader_->available()) {
if (!this->helper_->can_write_without_blocking())
@@ -1114,11 +1144,11 @@ void APIConnection::try_send_camera_image_() {
bool done = this->image_reader_->available() == to_send;
CameraImageResponse msg;
- msg.key = camera::Camera::instance()->get_object_id_hash();
+ msg.key = cam->get_object_id_hash();
msg.set_data(this->image_reader_->peek_data_buffer(), to_send);
msg.done = done;
#ifdef USE_DEVICES
- msg.device_id = camera::Camera::instance()->get_device_id();
+ msg.device_id = cam->get_device_id();
#endif
if (!this->send_message(msg)) {
@@ -1134,15 +1164,19 @@ void APIConnection::try_send_camera_image_() {
void APIConnection::set_camera_state(std::shared_ptr image) {
if (!this->flags_.state_subscription)
return;
- if (!this->image_reader_)
+ if (this->image_reader_ && this->image_reader_->available())
return;
- if (this->image_reader_->available())
+ if (!image->was_requested_by(esphome::camera::API_REQUESTER) && !image->was_requested_by(esphome::camera::IDLE))
return;
- if (image->was_requested_by(esphome::camera::API_REQUESTER) || image->was_requested_by(esphome::camera::IDLE)) {
- this->image_reader_->set_image(std::move(image));
- // Try to send immediately to reduce latency
- this->try_send_camera_image_();
+ if (!this->image_reader_) {
+ // Created on the first image this connection will send, so connections
+ // that never receive one never pay for a reader. Only a registered
+ // camera's listener can reach this, so instance() is non-null here.
+ this->image_reader_ = std::unique_ptr{camera::Camera::instance()->create_image_reader()};
}
+ this->image_reader_->set_image(std::move(image));
+ // Try to send immediately to reduce latency
+ this->try_send_camera_image_();
}
uint16_t APIConnection::try_send_camera_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size) {
auto *camera = static_cast(entity);
@@ -1168,32 +1202,29 @@ 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 (!value.timezone.empty()) {
- // Check if the sender provided pre-parsed timezone data.
- // If std_offset is non-zero or DST rules are present, the parsed data was populated.
- // For UTC (all zeros), string parsing produces the same result, so the fallback is equivalent.
+#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE)
+ // Apply only if the sender provided pre-parsed timezone data (Home Assistant 2026.3.0
+ // and newer); field presence distinguishes a genuine all-zero UTC timezone from an
+ // absent field. Older clients send only the deprecated timezone string, which is no
+ // longer decoded; for them the device keeps its codegen-configured timezone.
+ if (value.has_parsed_timezone) {
const auto &pt = value.parsed_timezone;
- if (pt.std_offset_seconds != 0 || pt.dst_start.type != enums::DST_RULE_TYPE_NONE) {
- time::ParsedTimezone tz{};
- tz.std_offset_seconds = pt.std_offset_seconds;
- tz.dst_offset_seconds = pt.dst_offset_seconds;
- tz.dst_start.time_seconds = pt.dst_start.time_seconds;
- tz.dst_start.day = static_cast(pt.dst_start.day);
- tz.dst_start.type = static_cast(pt.dst_start.type);
- tz.dst_start.month = static_cast(pt.dst_start.month);
- tz.dst_start.week = static_cast(pt.dst_start.week);
- tz.dst_start.day_of_week = static_cast(pt.dst_start.day_of_week);
- tz.dst_end.time_seconds = pt.dst_end.time_seconds;
- tz.dst_end.day = static_cast(pt.dst_end.day);
- tz.dst_end.type = static_cast(pt.dst_end.type);
- tz.dst_end.month = static_cast(pt.dst_end.month);
- tz.dst_end.week = static_cast(pt.dst_end.week);
- tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week);
- time::set_global_tz(tz);
- } else {
- homeassistant::global_homeassistant_time->set_timezone(value.timezone.c_str(), value.timezone.size());
- }
+ time::ParsedTimezone tz{};
+ tz.std_offset_seconds = pt.std_offset_seconds;
+ tz.dst_offset_seconds = pt.dst_offset_seconds;
+ tz.dst_start.time_seconds = pt.dst_start.time_seconds;
+ tz.dst_start.day = static_cast(pt.dst_start.day);
+ tz.dst_start.type = static_cast(pt.dst_start.type);
+ tz.dst_start.month = static_cast(pt.dst_start.month);
+ tz.dst_start.week = static_cast(pt.dst_start.week);
+ tz.dst_start.day_of_week = static_cast(pt.dst_start.day_of_week);
+ tz.dst_end.time_seconds = pt.dst_end.time_seconds;
+ tz.dst_end.day = static_cast(pt.dst_end.day);
+ tz.dst_end.type = static_cast(pt.dst_end.type);
+ tz.dst_end.month = static_cast(pt.dst_end.month);
+ tz.dst_end.week = static_cast(pt.dst_end.week);
+ tz.dst_end.day_of_week = static_cast(pt.dst_end.day_of_week);
+ time::set_global_tz(tz);
}
#endif
}
@@ -1208,6 +1239,7 @@ void APIConnection::on_subscribe_bluetooth_le_advertisements_request(
void APIConnection::on_unsubscribe_bluetooth_le_advertisements_request() {
bluetooth_proxy::global_bluetooth_proxy->unsubscribe_api_connection(this);
}
+#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void APIConnection::on_bluetooth_device_request(const BluetoothDeviceRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_device_request(msg);
}
@@ -1241,13 +1273,15 @@ void APIConnection::on_subscribe_bluetooth_connections_free_request() {
}
}
+void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
+ bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
+}
+#endif
+
void APIConnection::on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg) {
bluetooth_proxy::global_bluetooth_proxy->bluetooth_scanner_set_mode(
msg.mode == enums::BluetoothScannerMode::BLUETOOTH_SCANNER_MODE_ACTIVE);
}
-void APIConnection::on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg) {
- bluetooth_proxy::global_bluetooth_proxy->bluetooth_set_connection_params(msg);
-}
#endif
#ifdef USE_VOICE_ASSISTANT
@@ -1302,9 +1336,13 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
}
}
-bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
+bool APIConnection::send_voice_assistant_get_configuration_response_(
+ const VoiceAssistantConfigurationRequest & /*msg*/) {
VoiceAssistantConfigurationResponse resp;
if (!this->check_voice_assistant_api_connection_()) {
+ // send_message encodes synchronously, so this stack local outlives the encode
+ const std::vector empty_wake_words;
+ resp.active_wake_words = &empty_wake_words;
return this->send_message(resp);
}
@@ -1319,22 +1357,6 @@ bool APIConnection::send_voice_assistant_get_configuration_response_(const Voice
}
}
- // Filter external wake words
- for (auto &wake_word : msg.external_wake_words) {
- if (wake_word.model_type != "micro") {
- // microWakeWord only
- continue;
- }
-
- resp.available_wake_words.emplace_back();
- auto &resp_wake_word = resp.available_wake_words.back();
- resp_wake_word.id = StringRef(wake_word.id);
- resp_wake_word.wake_word = StringRef(wake_word.wake_word);
- for (const auto &lang : wake_word.trained_languages) {
- resp_wake_word.trained_languages.push_back(lang);
- }
- }
-
resp.active_wake_words = &config.active_wake_words;
resp.max_active_wake_words = config.max_active_wake_words;
return this->send_message(resp);
@@ -1354,11 +1376,16 @@ 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) {
- zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
+ ZWaveProxyRequestResponse resp{};
+ resp.type = msg.type;
+ resp.status = zwave_proxy::global_zwave_proxy->zwave_proxy_request(this, msg.type);
+ if (!this->send_message(resp)) {
+ API_LOG_MSG_DROPPED(TAG, "Z-Wave proxy response");
+ }
}
#endif
@@ -1439,6 +1466,7 @@ uint16_t APIConnection::try_send_water_heater_info(EntityBase *entity, APIConnec
msg.target_temperature_step = traits.get_target_temperature_step();
msg.supported_modes = &traits.get_supported_modes();
msg.supported_features = traits.get_feature_flags();
+ msg.temperature_unit = static_cast(traits.get_temperature_unit());
return fill_and_encode_entity_info(wh, msg, conn, remaining_size);
}
@@ -1516,19 +1544,60 @@ void APIConnection::on_infrared_rf_transmit_raw_timings_request(const InfraredRF
#endif
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
-void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) { this->send_message(msg); }
+void APIConnection::send_infrared_rf_receive_event(const InfraredRFReceiveEvent &msg) {
+ if (!this->send_message(msg)) {
+ // V: fires per decoded frame with no subscription gate, so a warning
+ // would flood the congested link it reports on.
+ ESP_LOGV(TAG, "IR/RF event dropped, TCP buffer full");
+ }
+}
#endif
#ifdef USE_SERIAL_PROXY
+static enums::SerialProxyStatus serial_proxy_result_to_status(serial_proxy::SerialProxyResult result) {
+ switch (result) {
+ case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_OK:
+ return enums::SERIAL_PROXY_STATUS_OK;
+ case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ASSUMED_SUCCESS:
+ return enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
+ case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_PORT_IN_USE:
+ return enums::SERIAL_PROXY_STATUS_PORT_IN_USE;
+ case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_INVALID_ARGUMENT:
+ return enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
+ case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_TIMEOUT:
+ return enums::SERIAL_PROXY_STATUS_TIMEOUT;
+ case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_NOT_SUPPORTED:
+ return enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
+ case serial_proxy::SerialProxyResult::SERIAL_PROXY_RESULT_ERROR:
+ return enums::SERIAL_PROXY_STATUS_ERROR;
+ }
+ return enums::SERIAL_PROXY_STATUS_ERROR; // Unreachable; all enum values handled above
+}
+
+static void send_serial_proxy_ack(APIConnection *conn, uint32_t instance, enums::SerialProxyRequestType type,
+ enums::SerialProxyStatus status) {
+ SerialProxyRequestResponse resp{};
+ resp.instance = instance;
+ resp.type = type;
+ resp.status = status;
+ if (!conn->send_message(resp)) {
+ API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
+ }
+}
+
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 %" PRIu32 " out of range (max %" PRIu32 ")", msg.instance,
static_cast(proxies.size()));
+ send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
+ enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
- proxies[msg.instance]->configure(msg.baudrate, msg.flow_control, static_cast(msg.parity), msg.stop_bits,
- msg.data_size);
+ serial_proxy::SerialProxyResult result = proxies[msg.instance]->configure(
+ this, msg.baudrate, msg.flow_control, static_cast(msg.parity), msg.stop_bits, msg.data_size);
+ send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE,
+ serial_proxy_result_to_status(result));
}
void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest &msg) {
@@ -1537,69 +1606,78 @@ void APIConnection::on_serial_proxy_write_request(const SerialProxyWriteRequest
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
return;
}
- proxies[msg.instance]->write_from_client(msg.data, msg.data_len);
+ proxies[msg.instance]->write_from_client(this, msg.data, msg.data_len);
}
void APIConnection::on_serial_proxy_set_modem_pins_request(const SerialProxySetModemPinsRequest &msg) {
auto &proxies = App.get_serial_proxies();
if (msg.instance >= proxies.size()) {
ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
+ send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
+ enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
- proxies[msg.instance]->set_modem_pins(msg.line_states);
+ serial_proxy::SerialProxyResult result = proxies[msg.instance]->set_modem_pins(this, msg.line_states);
+ send_serial_proxy_ack(this, msg.instance, enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS,
+ serial_proxy_result_to_status(result));
}
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 %" PRIu32 " out of range", msg.instance);
- return;
- }
SerialProxyGetModemPinsResponse resp{};
resp.instance = msg.instance;
- resp.line_states = proxies[msg.instance]->get_modem_pins();
- this->send_message(resp);
+ if (msg.instance >= proxies.size()) {
+ ESP_LOGW(TAG, "Serial proxy instance %" PRIu32 " out of range", msg.instance);
+ // Pre-1.16 clients do not read the status field and would take this error
+ // for a successful "both pins deasserted" answer; let them time out as before
+ if (!this->client_supports_api_version(1, 16)) {
+ return;
+ }
+ resp.status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
+ } else {
+ resp.line_states = proxies[msg.instance]->get_modem_pins();
+ }
+ if (!this->send_message(resp)) {
+ API_LOG_MSG_DROPPED(TAG, "Serial proxy response");
+ }
}
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 %" PRIu32 " out of range", msg.instance);
+ send_serial_proxy_ack(this, msg.instance, msg.type, enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT);
return;
}
+ auto *proxy = proxies[msg.instance];
+ enums::SerialProxyStatus status;
switch (msg.type) {
case enums::SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE:
case enums::SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE:
- proxies[msg.instance]->serial_proxy_request(this, msg.type);
+ status = serial_proxy_result_to_status(proxy->serial_proxy_request(this, msg.type));
break;
- case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH: {
- SerialProxyRequestResponse resp{};
- resp.instance = msg.instance;
- resp.type = enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH;
- switch (proxies[msg.instance]->flush_port()) {
- case uart::UARTFlushResult::UART_FLUSH_RESULT_SUCCESS:
- resp.status = enums::SERIAL_PROXY_STATUS_OK;
- break;
- case uart::UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS:
- resp.status = enums::SERIAL_PROXY_STATUS_ASSUMED_SUCCESS;
- break;
- case uart::UARTFlushResult::UART_FLUSH_RESULT_TIMEOUT:
- resp.status = enums::SERIAL_PROXY_STATUS_TIMEOUT;
- break;
- case uart::UARTFlushResult::UART_FLUSH_RESULT_FAILED:
- resp.status = enums::SERIAL_PROXY_STATUS_ERROR;
- break;
- }
- this->send_message(resp);
+ case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH:
+ status = serial_proxy_result_to_status(proxy->flush_port(this));
+ break;
+ case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
+ case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
+ // Response-only discriminators; never valid in a request
+ ESP_LOGW(TAG, "Response-only serial proxy request type: %" PRIu32, static_cast(msg.type));
+ status = enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT;
break;
- }
default:
ESP_LOGW(TAG, "Unknown serial proxy request type: %" PRIu32, static_cast(msg.type));
+ status = enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED;
break;
}
+ send_serial_proxy_ack(this, msg.instance, msg.type, status);
}
-void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) { this->send_message(msg); }
+void APIConnection::send_serial_proxy_data(const SerialProxyDataReceived &msg) {
+ if (!this->send_message(msg)) {
+ ESP_LOGV(TAG, "Serial proxy data dropped, TCP buffer full");
+ }
+}
#endif
#ifdef USE_INFRARED
@@ -1711,25 +1789,36 @@ void APIConnection::complete_authentication_() {
bool APIConnection::send_hello_response_(const HelloRequest &msg) {
// Copy client name with truncation if needed (set_client_name handles truncation)
this->helper_->set_client_name(msg.client_info.c_str(), msg.client_info.size());
- this->client_api_version_major_ = msg.api_version_major;
- this->client_api_version_minor_ = msg.api_version_minor;
+ this->client_api_version_major_ =
+ static_cast(std::min(msg.api_version_major, std::numeric_limits::max()));
+ this->client_api_version_minor_ =
+ static_cast(std::min(msg.api_version_minor, std::numeric_limits::max()));
char peername[socket::SOCKADDR_STR_LEN];
- ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %" PRIu16 ".%" PRIu16, this->helper_->get_client_name(),
+ ESP_LOGV(TAG, "Hello from client: '%s' | %s | API Version %u.%u", this->helper_->get_client_name(),
this->helper_->get_peername_to(peername), this->client_api_version_major_, this->client_api_version_minor_);
- // TODO: Remove before 2026.8.0 (one version after get_object_id backward compat removal)
- if (!this->client_supports_api_version(1, 14)) {
- ESP_LOGW(TAG, "'%s' using outdated API %" PRIu16 ".%" PRIu16 ", update to 1.14+", this->helper_->get_client_name(),
- this->client_api_version_major_, this->client_api_version_minor_);
- }
-
HelloResponse resp;
resp.api_version_major = 1;
- resp.api_version_minor = 14;
+ resp.api_version_minor = 16;
// Send only the version string - the client only logs this for debugging and doesn't use it otherwise
resp.server_info = ESPHOME_VERSION_REF;
resp.name = StringRef(App.get_name());
+#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"));
+ if (!this->send_message(resp)) {
+ API_LOG_MSG_DROPPED(TAG, "Hello response");
+ }
+ DisconnectRequest req;
+ req.reason = enums::DISCONNECT_REASON_PROVISIONING_CLOSED;
+ return this->send_message(req);
+ }
+#endif
+
// Auto-authenticate - password auth was removed in ESPHome 2026.1.0
this->complete_authentication_();
@@ -1748,9 +1837,8 @@ bool APIConnection::send_device_info_response_() {
#ifdef USE_AREAS
resp.suggested_area = StringRef(App.get_area());
#endif
- // Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
- char mac_address[18];
- uint8_t mac[6];
+ char mac_address[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
+ uint8_t mac[MAC_ADDRESS_SIZE];
get_mac_address_raw(mac);
format_mac_addr_upper(mac, mac_address);
resp.mac_address = StringRef(mac_address);
@@ -1765,7 +1853,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"
@@ -1826,8 +1914,7 @@ bool APIConnection::send_device_info_response_() {
#endif
#ifdef USE_BLUETOOTH_PROXY
resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
- // Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes)
- char bluetooth_mac[18];
+ char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
resp.bluetooth_mac_address = StringRef(bluetooth_mac);
#endif
@@ -1846,10 +1933,17 @@ bool APIConnection::send_device_info_response_() {
auto &info = resp.serial_proxies[serial_proxy_index++];
info.name = StringRef(proxy->get_name());
info.port_type = proxy->get_port_type();
+ info.configured_line_states = proxy->get_configured_modem_pins();
}
#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;
@@ -1875,12 +1969,43 @@ bool APIConnection::send_device_info_response_() {
return this->send_message(resp);
}
+bool APIConnection::send_device_capabilities_response_() {
+ // These are the same values DeviceInfoResponse still reports for older clients. Keep the blocks
+ // below in sync with send_device_info_response_() until those copies are removed.
+ DeviceCapabilitiesResponse resp;
+#ifdef USE_BLUETOOTH_PROXY
+ resp.bluetooth_proxy.feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags();
+ char bluetooth_mac[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
+ bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac);
+ resp.bluetooth_proxy.mac_address = StringRef(bluetooth_mac);
+#endif
+#ifdef USE_VOICE_ASSISTANT
+ resp.voice_assistant.feature_flags = voice_assistant::global_voice_assistant->get_feature_flags();
+#endif
+#ifdef USE_ZWAVE_PROXY
+ resp.zwave_proxy.feature_flags = zwave_proxy::global_zwave_proxy->get_feature_flags();
+ resp.zwave_proxy.home_id = zwave_proxy::global_zwave_proxy->get_home_id();
+#endif
+#ifdef USE_SERIAL_PROXY
+ size_t serial_proxy_index = 0;
+ for (auto const &proxy : App.get_serial_proxies()) {
+ if (serial_proxy_index >= SERIAL_PROXY_COUNT)
+ break;
+ auto &info = resp.serial_proxies[serial_proxy_index++];
+ info.name = StringRef(proxy->get_name());
+ info.port_type = proxy->get_port_type();
+ info.configured_line_states = proxy->get_configured_modem_pins();
+ }
+#endif
+ return this->send_message(resp);
+}
void APIConnection::on_hello_request(const HelloRequest &msg) {
if (!this->send_hello_response_(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();
}
@@ -1895,6 +2020,11 @@ void APIConnection::on_device_info_request() {
this->on_fatal_error();
}
}
+void APIConnection::on_device_capabilities_request() {
+ if (!this->send_device_capabilities_response_()) {
+ this->on_fatal_error();
+ }
+}
#ifdef USE_API_HOMEASSISTANT_STATES
void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) {
@@ -1973,7 +2103,9 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
resp.call_id = call_id;
resp.success = success;
resp.error_message = error_message;
- this->send_message(resp);
+ if (!this->send_message(resp)) {
+ API_LOG_MSG_DROPPED(TAG, "Action response");
+ }
}
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
void APIConnection::send_execute_service_response(uint32_t call_id, bool success, StringRef error_message,
@@ -1984,12 +2116,34 @@ void APIConnection::send_execute_service_response(uint32_t call_id, bool success
resp.error_message = error_message;
resp.response_data = response_data;
resp.response_data_len = response_data_len;
- this->send_message(resp);
+ if (!this->send_message(resp)) {
+ API_LOG_MSG_DROPPED(TAG, "Action response");
+ }
}
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
#endif
+#ifdef USE_API_HOMEASSISTANT_SERVICES
+bool APIConnection::send_homeassistant_action(const HomeassistantActionRequest &call) {
+ if (!this->flags_.service_call_subscription)
+ return false;
+ if (!this->send_message(call)) {
+ API_LOG_MSG_DROPPED(TAG, "Action request");
+ }
+ return true;
+}
+#endif // USE_API_HOMEASSISTANT_SERVICES
+
+#ifdef USE_HOMEASSISTANT_TIME
+void APIConnection::send_time_request() {
+ GetTimeRequest req;
+ if (!this->send_message(req)) {
+ API_LOG_MSG_DROPPED(TAG, "Time request");
+ }
+}
+#endif // USE_HOMEASSISTANT_TIME
+
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void APIConnection::on_homeassistant_action_response(const HomeassistantActionResponse &msg) {
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON
@@ -2008,7 +2162,16 @@ bool APIConnection::send_noise_encryption_set_key_response_(const NoiseEncryptio
NoiseEncryptionSetKeyResponse resp;
resp.success = false;
- psk_t psk{};
+#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
+
+ noise::psk_t psk{};
if (msg.key_len == 0) {
if (this->parent_->clear_noise_psk(true)) {
resp.success = true;
@@ -2017,10 +2180,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 (noise::NoiseContext::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);
@@ -2044,11 +2218,14 @@ bool APIConnection::try_to_clear_buffer_slow_(bool log_out_of_space) {
if (this->helper_->can_write_without_blocking())
return true;
if (log_out_of_space) {
- ESP_LOGV(TAG, "Cannot send message because of TCP buffer space");
+ // VV: refusals are either reported by the sending call site (naming what
+ // was lost) or retried without loss (the deferred batch), so this generic
+ // line only duplicates them.
+ ESP_LOGVV(TAG, "Cannot send message because of TCP buffer space");
}
return false;
}
-bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn,
+bool APIConnection::send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn,
const void *msg) {
#ifdef HAS_PROTO_MESSAGE_DUMP
// Skip dump for log messages (recursive logging risk) and camera frames (high-frequency noise)
@@ -2062,10 +2239,17 @@ bool APIConnection::send_message_(uint32_t payload_size, uint8_t message_type, M
this->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
}
#endif
+ if (!this->prepare_first_message_buffer(payload_size)) [[unlikely]] {
+ this->fatal_out_of_memory_();
+ return false;
+ }
auto &shared_buf = this->parent_->get_shared_buffer_ref();
- this->prepare_first_message_buffer(shared_buf, payload_size);
size_t write_start = shared_buf.size();
- shared_buf.resize(write_start + payload_size);
+#ifdef ESPHOME_DEBUG_API
+ assert(shared_buf.capacity() >= write_start + payload_size);
+#endif
+ // Capacity reserved above, cannot fail
+ (void) shared_buf.resize(write_start + payload_size);
ProtoWriteBuffer buffer{&shared_buf, write_start};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type);
@@ -2077,7 +2261,7 @@ uint16_t APIConnection::encode_to_buffer_slow(uint32_t calculated_size, MessageE
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) {
+bool APIConnection::send_buffer(ProtoWriteBuffer buffer, uint16_t message_type) {
const bool is_log_message = (message_type == SubscribeLogsResponse::MESSAGE_TYPE);
if (!this->try_to_clear_buffer(!is_log_message)) {
@@ -2101,30 +2285,42 @@ void APIConnection::on_no_setup_connection() {
this->on_fatal_error();
this->log_client_(ESPHOME_LOG_LEVEL_DEBUG, LOG_STR("no connection setup"));
}
+void APIConnection::fatal_out_of_memory_() {
+ this->fatal_error_with_log_(LOG_STR("Out of memory"), APIError::OUT_OF_MEMORY);
+}
void APIConnection::on_fatal_error() {
// Don't close socket here - keep it open so getpeername() works for logging
// Socket will be closed when client is removed from the list in APIServer::loop()
this->flags_.remove = true;
}
-bool APIConnection::schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
+bool APIConnection::schedule_message_front_(EntityBase *entity, uint16_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,
+bool APIConnection::send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index) {
if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) {
- auto &shared_buf = this->parent_->get_shared_buffer_ref();
- this->prepare_first_message_buffer(shared_buf, estimated_size);
+ // No local for the shared buffer here: keeping it live across
+ // dispatch_message_ costs a register and spills message_type into the
+ // batching path's dedup loop (measured on x86 GCC -Os)
+ if (!this->prepare_first_message_buffer(estimated_size)) [[unlikely]] {
+ this->fatal_out_of_memory_();
+ return false;
+ }
DeferredBatch::BatchItem item{entity, message_type, estimated_size, aux_data_index};
if (this->dispatch_message_(item, MAX_BATCH_PACKET_SIZE, true) &&
- this->send_buffer(ProtoWriteBuffer{&shared_buf}, message_type)) {
+ this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) {
#ifdef HAS_PROTO_MESSAGE_DUMP
this->log_batch_item_(item);
#endif
return true;
}
+ // An OOM during the immediate attempt marks the connection for removal;
+ // don't queue more work (schedule_message_'s push_back may allocate again)
+ if (this->flags_.remove) [[unlikely]]
+ return false;
}
return this->schedule_message_(entity, message_type, estimated_size, aux_data_index);
}
@@ -2174,7 +2370,11 @@ void APIConnection::process_batch_() {
total_estimated_size = MAX_BATCH_PACKET_SIZE;
}
- this->prepare_first_message_buffer(shared_buf, header_padding, total_estimated_size);
+ if (!this->prepare_first_message_buffer(header_padding, total_estimated_size)) [[unlikely]] {
+ this->fatal_out_of_memory_();
+ this->clear_batch_();
+ return;
+ }
// Fast path for single message - buffer already allocated above
if (num_items == 1) {
@@ -2189,8 +2389,10 @@ void APIConnection::process_batch_() {
#endif
this->clear_batch_();
} else if (payload_size == 0) {
- // Message too large to fit in available space
- ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
+ // payload_size == 0 with remove set means encoding hit OOM and the
+ // connection is being dropped; warn only for a genuinely oversized message
+ if (!this->flags_.remove)
+ ESP_LOGW(TAG, "Message too large to send: type=%u", item.message_type);
this->clear_batch_();
}
return;
@@ -2253,8 +2455,10 @@ void APIConnection::process_batch_multi_(APIBuffer &shared_buf, size_t num_items
if (items_processed > 0) {
// Add footer space for the last message (for Noise protocol MAC)
- if (footer_size > 0) {
- shared_buf.resize(shared_buf.size() + footer_size);
+ if (footer_size > 0 && !shared_buf.resize(shared_buf.size() + footer_size)) [[unlikely]] {
+ this->fatal_out_of_memory_();
+ this->clear_batch_();
+ return;
}
// Send all collected messages
diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h
index 4165b7f3a2..a4c49dccf4 100644
--- a/esphome/components/api/api_connection.h
+++ b/esphome/components/api/api_connection.h
@@ -11,19 +11,21 @@
#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/log.h"
#include "esphome/core/string_ref.h"
#include
@@ -36,16 +38,26 @@ 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;
+
+// One shared flash string for every refused-frame warning: send_message()
+// fails as soon as the TCP buffer is full, and each caller only pays for its
+// short name. The guard drops the helper and its arguments below WARN.
+#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_WARN
+void log_dropped_message(const char *tag, int line, const LogString *what);
+#define API_LOG_MSG_DROPPED(tag, what) esphome::api::log_dropped_message(tag, __LINE__, LOG_STR(what))
+#else
+#define API_LOG_MSG_DROPPED(tag, what)
+#endif
+
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
-// Maximum number of entities to process in a single batch during initial state/info sending
-// 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)
+// Deferred batch size cap during initial state/info sync
+static constexpr size_t MAX_INITIAL_BATCH_SIZE = 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");
+static_assert(MAX_MESSAGES_PER_BATCH >= MAX_INITIAL_BATCH_SIZE,
+ "MAX_MESSAGES_PER_BATCH must be >= MAX_INITIAL_BATCH_SIZE");
#ifdef USE_BENCHMARK
class APIConnection;
@@ -165,11 +177,10 @@ class APIConnection final : public APIServerConnectionBase {
#endif
bool try_send_log_message(int level, const char *tag, const char *line, size_t message_len);
#ifdef USE_API_HOMEASSISTANT_SERVICES
- void send_homeassistant_action(const HomeassistantActionRequest &call) {
- if (!this->flags_.service_call_subscription)
- return;
- this->send_message(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);
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
void on_homeassistant_action_response(const HomeassistantActionResponse &msg);
#endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES
@@ -178,6 +189,7 @@ class APIConnection final : public APIServerConnectionBase {
void on_subscribe_bluetooth_le_advertisements_request(const SubscribeBluetoothLEAdvertisementsRequest &msg);
void on_unsubscribe_bluetooth_le_advertisements_request();
+#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
void on_bluetooth_device_request(const BluetoothDeviceRequest &msg);
void on_bluetooth_gatt_read_request(const BluetoothGATTReadRequest &msg);
void on_bluetooth_gatt_write_request(const BluetoothGATTWriteRequest &msg);
@@ -186,15 +198,13 @@ class APIConnection final : public APIServerConnectionBase {
void on_bluetooth_gatt_get_services_request(const BluetoothGATTGetServicesRequest &msg);
void on_bluetooth_gatt_notify_request(const BluetoothGATTNotifyRequest &msg);
void on_subscribe_bluetooth_connections_free_request();
- void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
void on_bluetooth_set_connection_params_request(const BluetoothSetConnectionParamsRequest &msg);
+#endif
+ void on_bluetooth_scanner_set_mode_request(const BluetoothScannerSetModeRequest &msg);
#endif
#ifdef USE_HOMEASSISTANT_TIME
- void send_time_request() {
- GetTimeRequest req;
- this->send_message(req);
- }
+ void send_time_request();
#endif
#ifdef USE_VOICE_ASSISTANT
@@ -258,9 +268,10 @@ class APIConnection final : public APIServerConnectionBase {
void on_get_time_response(const GetTimeResponse &value);
#endif
void on_hello_request(const HelloRequest &msg);
- void on_disconnect_request();
+ void on_disconnect_request(const DisconnectRequest &msg);
void on_ping_request();
void on_device_info_request();
+ void on_device_capabilities_request();
void on_list_entities_request() { this->begin_iterator_(ActiveIterator::LIST_ENTITIES); }
void on_subscribe_states_request() {
this->flags_.state_subscription = true;
@@ -278,8 +289,8 @@ class APIConnection final : public APIServerConnectionBase {
esp32::crash_handler_log();
esp32::crash_handler_clear();
#endif
-#ifdef USE_RP2040_CRASH_HANDLER
- rp2040::crash_handler_log();
+#ifdef USE_RP2_CRASH_HANDLER
+ rp2::crash_handler_log();
#endif
#ifdef USE_ESP8266_CRASH_HANDLER
esp8266::crash_handler_log();
@@ -315,8 +326,10 @@ class APIConnection final : public APIServerConnectionBase {
bool is_marked_for_removal() const { return this->flags_.remove; }
uint8_t get_log_subscription_level() const { return this->flags_.log_subscription; }
- // Get client API version for feature detection
- bool client_supports_api_version(uint16_t major, uint16_t minor) const {
+ // Get client API version for feature detection.
+ // Stored versions saturate at 255 (see send_hello_response_), so requesting
+ // a minimum above that can never match.
+ bool client_supports_api_version(uint8_t major, uint8_t minor) const {
return this->client_api_version_major_ > major ||
(this->client_api_version_major_ == major && this->client_api_version_minor_ >= minor);
}
@@ -329,7 +342,9 @@ class APIConnection final : public APIServerConnectionBase {
// Function pointer type for type-erased size calculation
using CalculateSizeFn = uint32_t (*)(const void *);
- template bool send_message(const T &msg) {
+ /// Returns false as soon as the TCP buffer is full. Marked nodiscard so we
+ /// have no silent failures: every caller must handle (or log) a refusal.
+ template [[nodiscard]] bool send_message(const T &msg) {
if constexpr (T::ESTIMATED_SIZE == 0) {
return this->send_message_(0, T::MESSAGE_TYPE, &encode_msg_noop, &msg);
} else {
@@ -337,22 +352,13 @@ class APIConnection final : public APIServerConnectionBase {
}
}
- void prepare_first_message_buffer(APIBuffer &shared_buf, size_t header_padding, size_t total_size) {
- shared_buf.clear();
- // 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)
- // 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);
- }
+ /// Clear the shared write buffer and reserve space for the first message.
+ /// Returns false if the allocation fails (out of memory).
+ /// Defined in api_connection_buffer.h (needs APIServer complete).
+ [[nodiscard]] bool prepare_first_message_buffer(size_t header_padding, size_t total_size);
// Convenience overload - computes frame overhead internally
- void prepare_first_message_buffer(APIBuffer &shared_buf, size_t payload_size) {
- const uint8_t header_padding = this->helper_->frame_header_padding();
- const uint8_t footer_size = this->helper_->frame_footer_size();
- this->prepare_first_message_buffer(shared_buf, header_padding, payload_size + header_padding + footer_size);
- }
+ [[nodiscard]] bool prepare_first_message_buffer(size_t payload_size);
bool try_to_clear_buffer(bool log_out_of_space) {
if (this->flags_.remove)
@@ -361,7 +367,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);
+ bool send_buffer(ProtoWriteBuffer buffer, uint16_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
@@ -380,10 +386,11 @@ class APIConnection final : public APIServerConnectionBase {
bool send_disconnect_response_();
bool send_ping_response_();
bool send_device_info_response_();
+ bool send_device_capabilities_response_();
#ifdef USE_API_NOISE
bool send_noise_encryption_set_key_response_(const NoiseEncryptionSetKeyRequest &msg);
#endif
-#ifdef USE_BLUETOOTH_PROXY
+#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool send_subscribe_bluetooth_connections_free_response_();
#endif
#ifdef USE_VOICE_ASSISTANT
@@ -409,46 +416,12 @@ class APIConnection final : public APIServerConnectionBase {
}
// Non-template buffer management for send_message
- bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
+ bool send_message_(uint32_t payload_size, uint16_t message_type, MessageEncodeFn encode_fn, const void *msg);
- // Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
- // ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
- static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
- const void *msg, APIConnection *conn,
- uint32_t remaining_size) {
-#ifdef HAS_PROTO_MESSAGE_DUMP
- if (conn->flags_.log_only_mode) {
- auto *proto_msg = static_cast(msg);
- DumpBuffer dump_buf;
- conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
- return 1;
- }
-#endif
- const uint8_t footer_size = conn->helper_->frame_footer_size();
-
- // First message uses max padding (already in buffer), subsequent use exact header size
- size_t to_add;
- if (conn->flags_.batch_first_message) {
- conn->flags_.batch_first_message = false;
- conn->batch_header_size_ = conn->helper_->frame_header_padding();
- to_add = calculated_size;
- } else {
- conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
- to_add = calculated_size + conn->batch_header_size_ + footer_size;
- }
-
- // Check if it fits (using actual header size, not max padding)
- uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
- if (total_calculated_size > remaining_size)
- return 0;
-
- auto &shared_buf = conn->parent_->get_shared_buffer_ref();
- shared_buf.resize(shared_buf.size() + to_add);
- ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
- encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
-
- return total_calculated_size;
- }
+ // Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
+ // Defined in api_connection_buffer.h (needs APIServer complete).
+ static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
+ const void *msg, APIConnection *conn, uint32_t remaining_size);
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
@@ -511,13 +484,6 @@ class APIConnection final : public APIServerConnectionBase {
inline bool check_voice_assistant_api_connection_() const;
#endif
- // Get the max batch size based on client API version
- // API 1.14+ clients don't receive object_id, so messages are smaller and more fit per batch
- // TODO: Remove this method before 2026.7.0 and use MAX_INITIAL_PER_BATCH directly
- size_t get_max_batch_size_() const {
- return this->client_supports_api_version(1, 14) ? MAX_INITIAL_PER_BATCH : MAX_INITIAL_PER_BATCH_LEGACY;
- }
-
// Send keepalive ping or disconnect unresponsive client.
// Cold path — extracted from loop() to reduce instruction cache pressure.
void __attribute__((noinline)) check_keepalive_(uint32_t now);
@@ -666,6 +632,11 @@ class APIConnection final : public APIServerConnectionBase {
void destroy_active_iterator_();
void begin_iterator_(ActiveIterator type);
void finalize_iterator_sync_();
+#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
+ // Swap the plaintext helper for a Noise helper after the client opened
+ // with a Noise hello on an unprovisioned device (zero-PSK provisioning).
+ void upgrade_helper_to_noise_();
+#endif
#ifdef USE_CAMERA
std::unique_ptr image_reader_;
#endif
@@ -686,10 +657,9 @@ class APIConnection final : public APIServerConnectionBase {
struct BatchItem {
EntityBase *entity; // 4 bytes - Entity pointer
- uint8_t message_type; // 1 byte - Message type for protocol and dispatch
+ uint16_t message_type; // 2 bytes - Message type for protocol and dispatch
uint8_t estimated_size; // 1 byte - Estimated message size (max 255 bytes)
uint8_t aux_data_index{AUX_DATA_UNUSED}; // 1 byte - For events: index into entity's event_types
- // 1 byte padding
};
std::vector items;
@@ -699,7 +669,7 @@ class APIConnection final : public APIServerConnectionBase {
// connections that do, buffers are released after initial sync anyway
// Add item to the batch (with deduplication)
- void add_item(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
+ void add_item(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = AUX_DATA_UNUSED) {
// Dedup: O(n) scan but optimized for RAM over performance
// Skip deduplication for events - they are edge-triggered, every occurrence matters
@@ -715,7 +685,7 @@ class APIConnection final : public APIServerConnectionBase {
this->items.push_back({entity, message_type, estimated_size, aux_data_index});
}
// Add item to the front of the batch (for high priority messages like ping)
- void add_item_front(EntityBase *entity, uint8_t message_type, uint8_t estimated_size) {
+ void add_item_front(EntityBase *entity, uint16_t message_type, uint8_t estimated_size) {
// Swap to front avoids expensive vector::insert which shifts all elements
this->items.push_back({entity, message_type, estimated_size, AUX_DATA_UNUSED});
if (this->items.size() > 1) {
@@ -780,19 +750,22 @@ class APIConnection final : public APIServerConnectionBase {
#endif
} flags_{}; // 2 bytes total
- // 2-byte types immediately after flags_ (no padding between them)
- uint16_t client_api_version_major_{0};
- uint16_t client_api_version_minor_{0};
+ // 2-byte type immediately after flags_ (no padding between them)
+ uint16_t batch_message_type_{0}; // Current message type during batch encoding
// 1-byte types to fill remaining space before next 4-byte boundary
+ // Client API versions are clamped to 255 on receive (see send_hello_response_)
+ uint8_t client_api_version_major_{0};
+ uint8_t client_api_version_minor_{0};
ActiveIterator active_iterator_{ActiveIterator::NONE};
- uint8_t batch_message_type_{0}; // Current message type during batch encoding
- // Total: 2 (flags) + 2 + 2 + 1 + 1 = 8 bytes, aligned to 4-byte boundary
+ // Total: 2 (flags) + 2 + 1 + 1 + 1 + 1 (batch_header_size_ below) = 8 bytes,
+ // aligned to 4-byte boundary
// Actual header size used by encode_to_buffer for the current message.
// Read by process_batch_multi_ to pass into MessageInfo.
uint8_t batch_header_size_{0};
- uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
+ // Defined in api_connection_buffer.h (needs APIServer complete).
+ uint32_t get_batch_delay_ms_() const;
// Message will use 8 more bytes than the minimum size, and typical
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
// If its IPv6 the header is 40 bytes, and if its IPv4
@@ -834,7 +807,7 @@ class APIConnection final : public APIServerConnectionBase {
// 2. It's an EventResponse (events are edge-triggered - every occurrence matters)
// 3. OR: User has opted into immediate sending (should_try_send_immediately = true
// AND batch_delay = 0)
- inline bool should_send_immediately_(uint8_t message_type) const {
+ inline bool should_send_immediately_(uint16_t message_type) const {
return (
#ifdef USE_UPDATE
message_type == UpdateStateResponse::MESSAGE_TYPE ||
@@ -848,11 +821,11 @@ class APIConnection final : public APIServerConnectionBase {
// Helper method to send a message either immediately or via batching
// Tries immediate send if should_send_immediately_() returns true and buffer has space
// Falls back to batching if immediate send fails or isn't applicable
- bool send_message_smart_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
+ bool send_message_smart_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED);
// Helper function to schedule a deferred message with known message type
- bool schedule_message_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size,
+ bool schedule_message_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size,
uint8_t aux_data_index = DeferredBatch::AUX_DATA_UNUSED) {
this->deferred_batch_.add_item(entity, message_type, estimated_size, aux_data_index);
return this->schedule_batch_();
@@ -860,7 +833,7 @@ class APIConnection final : public APIServerConnectionBase {
// Helper function to schedule a high priority message at the front of the batch
// Out-of-line: callers (on_shutdown, check_keepalive_) are cold paths
- bool schedule_message_front_(EntityBase *entity, uint8_t message_type, uint8_t estimated_size);
+ bool schedule_message_front_(EntityBase *entity, uint16_t message_type, uint8_t estimated_size);
// Helper function to log client messages with name and peername
void log_client_(int level, const LogString *message);
@@ -871,6 +844,9 @@ class APIConnection final : public APIServerConnectionBase {
this->on_fatal_error();
this->log_warning_(message, err);
}
+ // Shared cold path for buffer allocation failures — noinline keeps the
+ // OOM handling out of the hot send paths
+ void __attribute__((noinline)) fatal_out_of_memory_();
};
} // namespace esphome::api
diff --git a/esphome/components/api/api_connection_buffer.h b/esphome/components/api/api_connection_buffer.h
new file mode 100644
index 0000000000..08520249bf
--- /dev/null
+++ b/esphome/components/api/api_connection_buffer.h
@@ -0,0 +1,74 @@
+#pragma once
+
+#include "esphome/core/defines.h"
+#ifdef USE_API
+
+// Inline APIConnection members that need APIServer complete. Include this
+// instead of api_connection.h when calling them.
+
+#include "api_connection.h"
+#include "api_server.h"
+
+namespace esphome::api {
+
+inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size,
+ MessageEncodeFn encode_fn, const void *msg,
+ APIConnection *conn, uint32_t remaining_size) {
+#ifdef HAS_PROTO_MESSAGE_DUMP
+ if (conn->flags_.log_only_mode) {
+ auto *proto_msg = static_cast(msg);
+ DumpBuffer dump_buf;
+ conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
+ return 1;
+ }
+#endif
+ const uint8_t footer_size = conn->helper_->frame_footer_size();
+
+ // First message uses max padding (already in buffer), subsequent use exact header size
+ size_t to_add;
+ if (conn->flags_.batch_first_message) {
+ conn->flags_.batch_first_message = false;
+ conn->batch_header_size_ = conn->helper_->frame_header_padding();
+ to_add = calculated_size;
+ } else {
+ conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
+ to_add = calculated_size + conn->batch_header_size_ + footer_size;
+ }
+
+ // Check if it fits (using actual header size, not max padding)
+ uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
+ if (total_calculated_size > remaining_size)
+ return 0;
+
+ auto &shared_buf = conn->parent_->get_shared_buffer_ref();
+ if (!shared_buf.resize(shared_buf.size() + to_add)) [[unlikely]] {
+ conn->fatal_out_of_memory_();
+ return 0;
+ }
+ ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
+ encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
+
+ return total_calculated_size;
+}
+
+inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
+
+inline bool APIConnection::prepare_first_message_buffer(size_t header_padding, size_t total_size) {
+ auto &shared_buf = this->parent_->get_shared_buffer_ref();
+ shared_buf.clear();
+ // 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)
+ // Reserve full size but only set initial size to header padding
+ // so message encoding starts at the correct position
+ return shared_buf.reserve_and_resize(total_size, header_padding);
+}
+
+inline bool APIConnection::prepare_first_message_buffer(size_t payload_size) {
+ const uint8_t header_padding = this->helper_->frame_header_padding();
+ const uint8_t footer_size = this->helper_->frame_footer_size();
+ return this->prepare_first_message_buffer(header_padding, payload_size + header_padding + footer_size);
+}
+
+} // namespace esphome::api
+#endif
diff --git a/esphome/components/api/api_frame_helper.cpp b/esphome/components/api/api_frame_helper.cpp
index 90353b6402..38da444a18 100644
--- a/esphome/components/api/api_frame_helper.cpp
+++ b/esphome/components/api/api_frame_helper.cpp
@@ -97,6 +97,8 @@ const LogString *api_error_to_logstr(APIError err) {
return LOG_STR("BAD_HANDSHAKE_ERROR_BYTE");
}
#endif
+ // PROTOCOL_SWITCH_TO_NOISE is intercepted in APIConnection::loop() before
+ // any logging can happen, so it intentionally has no entry here.
return LOG_STR("UNKNOWN");
}
@@ -170,7 +172,7 @@ APIError APIFrameHelper::write_raw_iov_(const struct iovec *iov, int iovcnt, uin
// Queue unsent data into overflow buffer
if (!this->overflow_buf_.enqueue_iov(iov, iovcnt, total_write_len, static_cast(sent))) {
- HELPER_LOG("Overflow buffer full, dropping connection");
+ HELPER_LOG("Overflow buffer full or out of memory, dropping connection");
this->state_ = State::FAILED;
return APIError::SOCKET_WRITE_FAILED;
}
diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h
index f98eca8076..ff8aa7834c 100644
--- a/esphome/components/api/api_frame_helper.h
+++ b/esphome/components/api/api_frame_helper.h
@@ -36,7 +36,7 @@ static constexpr uint16_t MAX_MESSAGE_SIZE = 32768; // 32 KiB for ESP32 and oth
static constexpr uint16_t RX_BUF_NULL_TERMINATOR = 1;
// Maximum number of messages to batch in a single write operation
-// Must be >= MAX_INITIAL_PER_BATCH in api_connection.h (enforced by static_assert there)
+// Must be >= MAX_INITIAL_BATCH_SIZE in api_connection.h (enforced by static_assert there)
static constexpr size_t MAX_MESSAGES_PER_BATCH = 34;
// Max client name length (e.g., "Home Assistant 2026.1.0.dev0" = 28 chars)
@@ -49,16 +49,16 @@ struct ReadPacketBuffer {
};
// Packed message info structure to minimize memory usage
-// Note: message_type is uint8_t — all current protobuf message types fit in 8 bits.
-// The noise wire format encodes types as 16-bit, but the high byte is always 0.
-// If message types ever exceed 255, this and encrypt_noise_message_ must be updated.
+// message_type matches the wire formats: noise carries a fixed 16-bit type
+// field, plaintext a type varint. The proto codegen caps message IDs at 16383
+// so the plaintext type varint fits the 2 bytes budgeted in HEADER_PADDING.
struct MessageInfo {
uint16_t offset; // Offset in buffer where message starts
uint16_t payload_size; // Size of the message payload
- uint8_t message_type; // Message type (0-255)
+ uint16_t message_type; // Message type (0-16383)
uint8_t header_size; // Actual header size used (avoids recomputation in write path)
- MessageInfo(uint8_t type, uint16_t off, uint16_t size, uint8_t hdr)
+ MessageInfo(uint16_t type, uint16_t off, uint16_t size, uint8_t hdr)
: offset(off), payload_size(size), message_type(type), header_size(hdr) {}
};
@@ -88,6 +88,11 @@ enum class APIError : uint16_t {
HANDSHAKESTATE_SPLIT_FAILED = 1020,
BAD_HANDSHAKE_ERROR_BYTE = 1021,
#endif
+#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
+ // Not an error: an unprovisioned device received a Noise client hello on a
+ // plaintext connection; the caller must hand the socket off to a Noise helper.
+ PROTOCOL_SWITCH_TO_NOISE = 1023,
+#endif
};
const LogString *api_error_to_logstr(APIError err);
@@ -144,7 +149,7 @@ class APIFrameHelper {
// holding data too long waiting for Nagle's timer causes buffer exhaustion
// and dropped messages.
//
- // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (8×MSS) / LibreTiny (4×MSS): 4 logs per cycle
+ // ESP32 (TCP_SND_BUF=4×MSS+) / RP2040 (4×MSS) / LibreTiny (4×MSS): 4 logs per cycle
// ESP8266 (2×MSS): 3 logs per cycle (tightest buffers)
//
// Flow (ESP32/RP2040/LT): Log 1 (Nagle on) -> Log 2 -> Log 3 -> Log 4 (NODELAY, flush)
@@ -168,7 +173,7 @@ class APIFrameHelper {
}
// Write a single protobuf message - the hot path (87-100% of all writes).
// Caller must ensure state is DATA before calling.
- virtual APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) = 0;
+ virtual APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) = 0;
// Write multiple protobuf messages in a single batched operation.
// Caller must ensure state is DATA and messages is not empty.
// messages contains (message_type, offset, length) for each message in the buffer.
@@ -182,15 +187,15 @@ class APIFrameHelper {
// Distinguishes protocols via frame_footer_size_ (noise always has a non-zero MAC
// footer, plaintext has footer=0). If a protocol with a plaintext footer is ever
// added, this should become a virtual method.
- uint8_t frame_header_size(uint16_t payload_size, uint8_t message_type) const {
+ uint8_t frame_header_size(uint16_t payload_size, uint16_t message_type) const {
#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
return this->frame_footer_size_
? this->frame_header_padding_
- : static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
+ : static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type));
#elif defined(USE_API_NOISE)
return this->frame_header_padding_;
#else // USE_API_PLAINTEXT only
- return static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint8(message_type));
+ return static_cast(1 + ProtoSize::varint16(payload_size) + ProtoSize::varint16(message_type));
#endif
}
// Get the frame footer size required by this protocol
@@ -200,6 +205,12 @@ class APIFrameHelper {
// or track that they stopped early and retry without this check.
// See Socket::ready() for details.
bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); }
+#if defined(USE_API_NOISE) && defined(USE_API_PLAINTEXT)
+ // Move the socket out of this helper so a replacement helper can take it
+ // over (plaintext to Noise handoff on unprovisioned devices). The drained
+ // helper must be destroyed right after.
+ std::unique_ptr release_socket_for_switch() { return std::move(this->socket_); }
+#endif
// Release excess memory from internal buffers after initial sync
void release_buffers() {
// rx_buf_: Safe to clear only if no partial read in progress.
@@ -301,7 +312,7 @@ class APIFrameHelper {
// Values 1..LOG_NAGLE_COUNT count log messages in the current Nagle batch.
// After LOG_NAGLE_COUNT logs, we flush by re-enabling NODELAY and resetting to 0.
// ESP8266 has the tightest TCP send buffer (2×MSS) and needs conservative batching.
- // ESP32 (4×MSS+), RP2040 (8×MSS), and LibreTiny (4×MSS) can coalesce more.
+ // ESP32 (4×MSS+), RP2040 (4×MSS), and LibreTiny (4×MSS) can coalesce more.
#ifdef USE_ESP8266
static constexpr uint8_t LOG_NAGLE_COUNT = 2;
#else
diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp
index 6dba64a7f8..138dbdddba 100644
--- a/esphome/components/api/api_frame_helper_noise.cpp
+++ b/esphome/components/api/api_frame_helper_noise.cpp
@@ -2,9 +2,9 @@
#ifdef USE_API
#ifdef USE_API_NOISE
#include "api_connection.h" // For ClientInfo struct
+#include "esphome/components/noise/noise.h"
#include "esphome/core/application.h"
#include "esphome/core/entity_base.h"
-#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "proto.h"
@@ -17,6 +17,14 @@
namespace esphome::api {
+using noise::noise_err_to_logstr;
+
+// api_frame_helper.h keeps its own MAX_HANDSHAKE_SIZE because that header is
+// also compiled in plaintext-only builds without the noise component; keep
+// the two definitions from drifting apart.
+static_assert(MAX_HANDSHAKE_SIZE == noise::MAX_HANDSHAKE_SIZE,
+ "api and noise component handshake size limits must match");
+
static const char *const TAG = "api.noise";
#ifdef USE_ESP8266
static constexpr char PROLOGUE_INIT[] PROGMEM = "NoiseAPIInit";
@@ -51,45 +59,6 @@ static constexpr size_t API_MAX_LOG_BYTES = 168;
#define LOG_PACKET_RECEIVED(buffer) ((void) 0)
#endif
-/// Convert a noise error code to a readable error
-const LogString *noise_err_to_logstr(int err) {
- if (err == NOISE_ERROR_NO_MEMORY)
- return LOG_STR("NO_MEMORY");
- if (err == NOISE_ERROR_UNKNOWN_ID)
- return LOG_STR("UNKNOWN_ID");
- if (err == NOISE_ERROR_UNKNOWN_NAME)
- return LOG_STR("UNKNOWN_NAME");
- if (err == NOISE_ERROR_MAC_FAILURE)
- return LOG_STR("MAC_FAILURE");
- if (err == NOISE_ERROR_NOT_APPLICABLE)
- return LOG_STR("NOT_APPLICABLE");
- if (err == NOISE_ERROR_SYSTEM)
- return LOG_STR("SYSTEM");
- if (err == NOISE_ERROR_REMOTE_KEY_REQUIRED)
- return LOG_STR("REMOTE_KEY_REQUIRED");
- if (err == NOISE_ERROR_LOCAL_KEY_REQUIRED)
- return LOG_STR("LOCAL_KEY_REQUIRED");
- if (err == NOISE_ERROR_PSK_REQUIRED)
- return LOG_STR("PSK_REQUIRED");
- if (err == NOISE_ERROR_INVALID_LENGTH)
- return LOG_STR("INVALID_LENGTH");
- if (err == NOISE_ERROR_INVALID_PARAM)
- return LOG_STR("INVALID_PARAM");
- if (err == NOISE_ERROR_INVALID_STATE)
- return LOG_STR("INVALID_STATE");
- if (err == NOISE_ERROR_INVALID_NONCE)
- return LOG_STR("INVALID_NONCE");
- if (err == NOISE_ERROR_INVALID_PRIVATE_KEY)
- return LOG_STR("INVALID_PRIVATE_KEY");
- if (err == NOISE_ERROR_INVALID_PUBLIC_KEY)
- return LOG_STR("INVALID_PUBLIC_KEY");
- if (err == NOISE_ERROR_INVALID_FORMAT)
- return LOG_STR("INVALID_FORMAT");
- if (err == NOISE_ERROR_INVALID_SIGNATURE)
- return LOG_STR("INVALID_SIGNATURE");
- return LOG_STR("UNKNOWN");
-}
-
/// Initialize the frame helper, returns OK if successful.
APIError APINoiseFrameHelper::init() {
APIError err = init_common_();
@@ -99,7 +68,10 @@ APIError APINoiseFrameHelper::init() {
// init prologue
size_t old_size = prologue_.size();
- prologue_.resize(old_size + PROLOGUE_INIT_LEN);
+ if (!prologue_.resize(old_size + PROLOGUE_INIT_LEN)) [[unlikely]] {
+ state_ = State::FAILED;
+ return APIError::OUT_OF_MEMORY;
+ }
#ifdef USE_ESP8266
memcpy_P(prologue_.data() + old_size, PROLOGUE_INIT, PROLOGUE_INIT_LEN);
#else
@@ -109,6 +81,40 @@ APIError APINoiseFrameHelper::init() {
state_ = State::CLIENT_HELLO;
return APIError::OK;
}
+#ifdef USE_API_PLAINTEXT
+APIError APINoiseFrameHelper::init_from_handoff(const uint8_t *header, uint8_t header_len) {
+ APIError err = this->init();
+ if (err != APIError::OK) {
+ return err;
+ }
+ // Seed the header bytes the plaintext helper consumed before detecting the
+ // Noise indicator; try_read_frame_ resumes from rx_header_buf_len_.
+ std::memcpy(this->rx_header_buf_, header, header_len);
+ this->rx_header_buf_len_ = header_len;
+ // Pump the handshake without gating on socket_->ready(): on LWIP the
+ // plaintext helper's partial read can drain rcvevent while the rest of the
+ // client hello sits in the lastdata cache, so ready() may report false even
+ // though data is available.
+ return this->pump_handshake_();
+}
+#endif // USE_API_PLAINTEXT
+
+/// Drive the handshake state machine until DATA, WOULD_BLOCK, or a fatal
+/// error. WOULD_BLOCK is not an error: reads stop naturally on EWOULDBLOCK
+/// and resume on the next loop().
+APIError APINoiseFrameHelper::pump_handshake_() {
+ while (this->state_ != State::DATA) {
+ APIError err = this->state_action_();
+ if (err == APIError::WOULD_BLOCK) {
+ break;
+ }
+ if (err != APIError::OK) {
+ return err;
+ }
+ }
+ return APIError::OK;
+}
+
// Helper for handling handshake frame errors
APIError APINoiseFrameHelper::handle_handshake_frame_error_(APIError aerr) {
if (aerr == APIError::BAD_INDICATOR) {
@@ -131,16 +137,13 @@ APIError APINoiseFrameHelper::handle_noise_error_(int err, const LogString *func
/// Run through handshake messages (if in that phase)
APIError APINoiseFrameHelper::loop() {
- // Cache ready() outside the loop. On ESP8266 LWIP raw TCP, ready() returns false once
- // the rx buffer is consumed. Re-checking each iteration would block handshake writes
- // that must follow reads, deadlocking the handshake. state_action() will return
- // WOULD_BLOCK when no more data is available to read.
- bool socket_ready = this->socket_->ready();
- while (state_ != State::DATA && socket_ready) {
- APIError err = state_action_();
- if (err == APIError::WOULD_BLOCK) {
- break;
- }
+ // Check ready() once, not per state transition. On ESP8266 LWIP raw TCP,
+ // ready() returns false once the rx buffer is consumed. Re-checking each
+ // iteration would block handshake writes that must follow reads,
+ // deadlocking the handshake. pump_handshake_() stops on WOULD_BLOCK when
+ // no more data is available to read.
+ if (state_ != State::DATA && this->socket_->ready()) {
+ APIError err = this->pump_handshake_();
if (err != APIError::OK) {
return err;
}
@@ -163,9 +166,9 @@ APIError APINoiseFrameHelper::loop() {
*/
APIError APINoiseFrameHelper::try_read_frame_() {
// read header
- if (rx_header_buf_len_ < 3) {
+ if (rx_header_buf_len_ < noise::FRAME_HEADER_SIZE) {
// no header information yet
- uint8_t to_read = 3 - rx_header_buf_len_;
+ uint8_t to_read = static_cast(noise::FRAME_HEADER_SIZE) - rx_header_buf_len_;
ssize_t received = this->socket_->read(&rx_header_buf_[rx_header_buf_len_], to_read);
APIError err = handle_socket_read_result_(received);
if (err != APIError::OK) {
@@ -177,7 +180,7 @@ APIError APINoiseFrameHelper::try_read_frame_() {
return APIError::WOULD_BLOCK;
}
- if (rx_header_buf_[0] != 0x01) {
+ if (rx_header_buf_[0] != noise::FRAME_INDICATOR) {
state_ = State::FAILED;
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
return APIError::BAD_INDICATOR;
@@ -202,7 +205,10 @@ APIError APINoiseFrameHelper::try_read_frame_() {
// During handshake, rx_buf_.size() is used in prologue construction, so
// the buffer must be exactly msg_size to avoid prologue mismatch.)
uint16_t alloc_size = msg_size + (is_data ? RX_BUF_NULL_TERMINATOR : 0);
- this->rx_buf_.resize(alloc_size);
+ if (!this->rx_buf_.resize(alloc_size)) [[unlikely]] {
+ state_ = State::FAILED;
+ return APIError::OUT_OF_MEMORY;
+ }
if (rx_buf_len_ < msg_size) {
// more data to read
@@ -269,7 +275,10 @@ APIError APINoiseFrameHelper::state_action_client_hello_() {
// Resize for: existing prologue + 2 size bytes + frame data
size_t old_size = this->prologue_.size();
size_t rx_size = this->rx_buf_.size();
- this->prologue_.resize(old_size + 2 + rx_size);
+ if (!this->prologue_.resize(old_size + 2 + rx_size)) [[unlikely]] {
+ state_ = State::FAILED;
+ return APIError::OUT_OF_MEMORY;
+ }
this->prologue_[old_size] = (uint8_t) (rx_size >> 8);
this->prologue_[old_size + 1] = (uint8_t) rx_size;
if (rx_size > 0) {
@@ -317,15 +326,15 @@ APIError APINoiseFrameHelper::state_action_server_hello_() {
return APIError::OK;
}
APIError APINoiseFrameHelper::state_action_handshake_() {
- int action = noise_handshakestate_get_action(this->handshake_);
- if (action == NOISE_ACTION_READ_MESSAGE) {
+ noise::NoiseResponderHandshake::Action action = this->handshake_.action();
+ if (action == noise::NoiseResponderHandshake::Action::ACTION_READ) {
return this->state_action_handshake_read_();
- } else if (action == NOISE_ACTION_WRITE_MESSAGE) {
+ } else if (action == noise::NoiseResponderHandshake::Action::ACTION_WRITE) {
return this->state_action_handshake_write_();
}
// bad state for action
this->state_ = State::FAILED;
- HELPER_LOG("Bad action for handshake: %d", action);
+ HELPER_LOG("Bad action for handshake: %d", (int) action);
return APIError::HANDSHAKESTATE_BAD_STATE;
}
APIError APINoiseFrameHelper::state_action_handshake_read_() {
@@ -337,20 +346,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() {
if (this->rx_buf_.empty()) {
this->send_explicit_handshake_reject_(LOG_STR("Empty handshake message"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
- } else if (this->rx_buf_[0] != 0x00) {
+ } else if (this->rx_buf_[0] != noise::HANDSHAKE_STATUS_OK) {
HELPER_LOG("Bad handshake error byte: %u", this->rx_buf_[0]);
this->send_explicit_handshake_reject_(LOG_STR("Bad handshake error byte"));
return APIError::BAD_HANDSHAKE_ERROR_BYTE;
}
- NoiseBuffer mbuf;
- noise_buffer_init(mbuf);
- noise_buffer_set_input(mbuf, this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
- int err = noise_handshakestate_read_message(this->handshake_, &mbuf, nullptr);
+ int err = this->handshake_.read_message(this->rx_buf_.data() + 1, this->rx_buf_.size() - 1);
if (err != 0) {
// Special handling for MAC failure
- this->send_explicit_handshake_reject_(err == NOISE_ERROR_MAC_FAILURE ? LOG_STR("Handshake MAC failure")
- : LOG_STR("Handshake error"));
+ this->send_explicit_handshake_reject_(noise::reject_reason_for(err));
return this->handle_noise_error_(err, LOG_STR("noise_handshakestate_read_message"),
APIError::HANDSHAKESTATE_READ_FAILED);
}
@@ -359,18 +364,16 @@ APIError APINoiseFrameHelper::state_action_handshake_read_() {
}
APIError APINoiseFrameHelper::state_action_handshake_write_() {
uint8_t buffer[65];
- NoiseBuffer mbuf;
- noise_buffer_init(mbuf);
- noise_buffer_set_output(mbuf, buffer + 1, sizeof(buffer) - 1);
+ size_t msg_len = 0;
- int err = noise_handshakestate_write_message(this->handshake_, &mbuf, nullptr);
+ int err = this->handshake_.write_message(buffer + 1, sizeof(buffer) - 1, msg_len);
APIError aerr = this->handle_noise_error_(err, LOG_STR("noise_handshakestate_write_message"),
APIError::HANDSHAKESTATE_WRITE_FAILED);
if (aerr != APIError::OK)
return aerr;
- buffer[0] = 0x00; // success
+ buffer[0] = noise::HANDSHAKE_STATUS_OK;
- aerr = this->write_frame_(buffer, mbuf.size + 1);
+ aerr = this->write_frame_(buffer, msg_len + 1);
if (aerr != APIError::OK)
return aerr;
return this->check_handshake_finished_();
@@ -378,33 +381,22 @@ APIError APINoiseFrameHelper::state_action_handshake_write_() {
void APINoiseFrameHelper::send_explicit_handshake_reject_(const LogString *reason) {
// Max reject message: "Bad handshake packet len" (24) + 1 (failure byte) = 25 bytes
uint8_t data[32];
- data[0] = 0x01; // failure
-
-#ifdef USE_STORE_LOG_STR_IN_FLASH
- // On ESP8266 with flash strings, we need to use PROGMEM-aware functions
- size_t reason_len = strlen_P(reinterpret_cast(reason));
- reason_len = std::min(reason_len, sizeof(data) - 1);
- if (reason_len > 0) {
- memcpy_P(data + 1, reinterpret_cast(reason), reason_len);
- }
-#else
- // Normal memory access
- const char *reason_str = LOG_STR_ARG(reason);
- size_t reason_len = strlen(reason_str);
- reason_len = std::min(reason_len, sizeof(data) - 1);
- if (reason_len > 0) {
- // NOLINTNEXTLINE(bugprone-not-null-terminated-result) - binary protocol, not a C string
- std::memcpy(data + 1, reason_str, reason_len);
- }
-#endif
-
- size_t data_size = reason_len + 1;
+ static_assert(sizeof(data) >= noise::MAC_FAILURE_PAYLOAD_SIZE,
+ "reject buffer must fit the MAC failure wire contract");
+ size_t data_size = noise::format_reject_payload(data, sizeof(data), reason);
// temporarily remove failed state
auto orig_state = state_;
state_ = State::EXPLICIT_REJECT;
- write_frame_(data, data_size);
- state_ = orig_state;
+ APIError aerr = write_frame_(data, data_size);
+ if (aerr != APIError::OK) {
+ // Best effort; the reject reason is a diagnosis aid, not a protocol step
+ ESP_LOGW(TAG, "Sending handshake reject failed: %d", (int) aerr);
+ }
+ if (state_ == State::EXPLICIT_REJECT) {
+ // write_frame_ may have moved the state to FAILED; keep that decision
+ state_ = orig_state;
+ }
}
APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
APIError aerr = this->check_data_state_();
@@ -459,14 +451,12 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) {
}
// Encrypt a single noise message in place and return the encrypted frame length.
// Returns APIError::OK on success.
-APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
+APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type,
uint16_t &encrypted_len_out) {
- // Write noise header
- buf_start[0] = 0x01; // indicator
- // buf_start[1], buf_start[2] to be set after encryption
+ // The noise frame header is written after encryption, when the size is known
// Write message header (to be encrypted)
- constexpr uint8_t msg_offset = 3;
+ constexpr uint8_t msg_offset = noise::FRAME_HEADER_SIZE;
buf_start[msg_offset] = static_cast(message_type >> 8); // type high byte
buf_start[msg_offset + 1] = static_cast(message_type); // type low byte
buf_start[msg_offset + 2] = static_cast(payload_size >> 8); // data_len high byte
@@ -484,26 +474,27 @@ APIError APINoiseFrameHelper::encrypt_noise_message_(uint8_t *buf_start, uint16_
if (aerr != APIError::OK)
return aerr;
- // Fill in the encrypted size
- buf_start[1] = static_cast(mbuf.size >> 8);
- buf_start[2] = static_cast(mbuf.size);
+ // Fill in the frame header now that the encrypted size is known
+ noise::write_frame_header(buf_start, static_cast(mbuf.size));
- encrypted_len_out = static_cast(3 + mbuf.size); // indicator + size + encrypted data
+ encrypted_len_out = static_cast(noise::FRAME_HEADER_SIZE + mbuf.size);
return APIError::OK;
}
-APIError APINoiseFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
+APIError APINoiseFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) {
#ifdef ESPHOME_DEBUG_API
assert(this->state_ == State::DATA);
#endif
+ APIBuffer *buf = buffer.get_buffer();
// Resize buffer to include footer space for Noise MAC
- if (this->frame_footer_size_)
- buffer.get_buffer()->resize(buffer.get_buffer()->size() + this->frame_footer_size_);
+ if (this->frame_footer_size_ && !buf->resize(buf->size() + this->frame_footer_size_)) [[unlikely]] {
+ state_ = State::FAILED;
+ return APIError::OUT_OF_MEMORY;
+ }
- uint16_t payload_size =
- static_cast(buffer.get_buffer()->size() - HEADER_PADDING - this->frame_footer_size_);
- uint8_t *buf_start = buffer.get_buffer()->data();
+ uint16_t payload_size = static_cast(buf->size() - HEADER_PADDING - this->frame_footer_size_);
+ uint8_t *buf_start = buf->data();
uint16_t encrypted_len;
APIError aerr = this->encrypt_noise_message_(buf_start, payload_size, type, encrypted_len);
if (aerr != APIError::OK)
@@ -537,21 +528,19 @@ APIError APINoiseFrameHelper::write_protobuf_messages(ProtoWriteBuffer buffer, s
}
APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
- uint8_t header[3];
- header[0] = 0x01; // indicator
- header[1] = (uint8_t) (len >> 8);
- header[2] = (uint8_t) len;
+ uint8_t header[noise::FRAME_HEADER_SIZE];
+ noise::write_frame_header(header, len);
if (len == 0) {
- return this->write_raw_buf_(header, 3);
+ return this->write_raw_buf_(header, noise::FRAME_HEADER_SIZE);
}
struct iovec iov[2];
iov[0].iov_base = header;
- iov[0].iov_len = 3;
+ iov[0].iov_len = noise::FRAME_HEADER_SIZE;
iov[1].iov_base = const_cast(data);
iov[1].iov_len = len;
- return this->write_raw_iov_(iov, 2, 3 + len);
+ return this->write_raw_iov_(iov, 2, noise::FRAME_HEADER_SIZE + len);
}
/** Initiate the data structures for the handshake.
@@ -559,42 +548,12 @@ APIError APINoiseFrameHelper::write_frame_(const uint8_t *data, uint16_t len) {
* @return 0 on success, -1 on error (check errno)
*/
APIError APINoiseFrameHelper::init_handshake_() {
- int err;
- memset(&nid_, 0, sizeof(nid_));
- // const char *proto = "Noise_NNpsk0_25519_ChaChaPoly_SHA256";
- // err = noise_protocol_name_to_id(&nid_, proto, strlen(proto));
- nid_.pattern_id = NOISE_PATTERN_NN;
- nid_.cipher_id = NOISE_CIPHER_CHACHAPOLY;
- nid_.dh_id = NOISE_DH_CURVE25519;
- nid_.prefix_id = NOISE_PREFIX_STANDARD;
- nid_.hybrid_id = NOISE_DH_NONE;
- nid_.hash_id = NOISE_HASH_SHA256;
- nid_.modifier_ids[0] = NOISE_MODIFIER_PSK0;
-
- err = noise_handshakestate_new_by_id(&handshake_, &nid_, NOISE_ROLE_RESPONDER);
- APIError aerr =
- handle_noise_error_(err, LOG_STR("noise_handshakestate_new_by_id"), APIError::HANDSHAKESTATE_SETUP_FAILED);
+ int err = this->handshake_.init(this->ctx_.get_psk(), prologue_.data(), prologue_.size());
+ APIError aerr = handle_noise_error_(err, LOG_STR("noise_handshake_init"), APIError::HANDSHAKESTATE_SETUP_FAILED);
if (aerr != APIError::OK)
return aerr;
-
- const auto &psk = this->ctx_.get_psk();
- err = noise_handshakestate_set_pre_shared_key(handshake_, psk.data(), psk.size());
- aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_pre_shared_key"),
- APIError::HANDSHAKESTATE_SETUP_FAILED);
- if (aerr != APIError::OK)
- return aerr;
-
- err = noise_handshakestate_set_prologue(handshake_, prologue_.data(), prologue_.size());
- aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_prologue"), APIError::HANDSHAKESTATE_SETUP_FAILED);
- if (aerr != APIError::OK)
- return aerr;
- // set_prologue copies it into handshakestate, so we can get rid of it now
+ // init copies the prologue into the handshakestate, so we can get rid of it now
prologue_.release();
-
- err = noise_handshakestate_start(handshake_);
- aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED);
- if (aerr != APIError::OK)
- return aerr;
return APIError::OK;
}
@@ -603,15 +562,17 @@ APIError APINoiseFrameHelper::check_handshake_finished_() {
assert(state_ == State::HANDSHAKE);
#endif
- int action = noise_handshakestate_get_action(handshake_);
- if (action == NOISE_ACTION_READ_MESSAGE || action == NOISE_ACTION_WRITE_MESSAGE)
+ noise::NoiseResponderHandshake::Action action = this->handshake_.action();
+ if (action == noise::NoiseResponderHandshake::Action::ACTION_READ ||
+ action == noise::NoiseResponderHandshake::Action::ACTION_WRITE)
return APIError::OK;
- if (action != NOISE_ACTION_SPLIT) {
+ if (action != noise::NoiseResponderHandshake::Action::ACTION_SPLIT) {
state_ = State::FAILED;
- HELPER_LOG("Bad action for handshake: %d", action);
+ HELPER_LOG("Bad action for handshake: %d", (int) action);
return APIError::HANDSHAKESTATE_BAD_STATE;
}
- int err = noise_handshakestate_split(handshake_, &send_cipher_, &recv_cipher_);
+ // split() also frees the handshake state
+ int err = this->handshake_.split(send_cipher_, recv_cipher_);
APIError aerr =
handle_noise_error_(err, LOG_STR("noise_handshakestate_split"), APIError::HANDSHAKESTATE_SPLIT_FAILED);
if (aerr != APIError::OK)
@@ -620,17 +581,11 @@ APIError APINoiseFrameHelper::check_handshake_finished_() {
this->frame_footer_size_ = noise_cipherstate_get_mac_length(send_cipher_);
HELPER_LOG("Handshake complete!");
- noise_handshakestate_free(handshake_);
- handshake_ = nullptr;
state_ = State::DATA;
return APIError::OK;
}
APINoiseFrameHelper::~APINoiseFrameHelper() {
- if (handshake_ != nullptr) {
- noise_handshakestate_free(handshake_);
- handshake_ = nullptr;
- }
if (send_cipher_ != nullptr) {
noise_cipherstate_free(send_cipher_);
send_cipher_ = nullptr;
@@ -641,16 +596,6 @@ APINoiseFrameHelper::~APINoiseFrameHelper() {
}
}
-extern "C" {
-// declare how noise generates random bytes (here with a good HWRNG based on the RF system)
-void noise_rand_bytes(void *output, size_t len) {
- if (!esphome::random_bytes(reinterpret_cast(output), len)) {
- ESP_LOGE(TAG, "Acquiring random bytes failed; rebooting");
- arch_restart();
- }
-}
-}
-
} // namespace esphome::api
#endif // USE_API_NOISE
#endif // USE_API
diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h
index 0676eab78d..366751738e 100644
--- a/esphome/components/api/api_frame_helper_noise.h
+++ b/esphome/components/api/api_frame_helper_noise.h
@@ -3,7 +3,7 @@
#ifdef USE_API
#ifdef USE_API_NOISE
#include "noise/protocol.h"
-#include "api_noise_context.h"
+#include "esphome/components/noise/noise_handshake.h"
namespace esphome::api {
@@ -14,20 +14,28 @@ class APINoiseFrameHelper final : public APIFrameHelper {
// Pos 1-2: encrypted payload size (16-bit big-endian)
// Pos 3-6: encrypted type (16-bit) + data_len (16-bit)
// Pos 7+: actual payload data
- static constexpr uint8_t HEADER_PADDING = 1 + 2 + 2 + 2; // indicator + size + type + data_len
+ static constexpr uint8_t HEADER_PADDING = noise::FRAME_HEADER_SIZE + 2 + 2; // frame header + type + data_len
- APINoiseFrameHelper(std::unique_ptr socket, APINoiseContext &ctx)
+ APINoiseFrameHelper(std::unique_ptr socket, noise::NoiseContext &ctx)
: APIFrameHelper(std::move(socket)), ctx_(ctx) {
frame_header_padding_ = HEADER_PADDING;
}
~APINoiseFrameHelper() override;
APIError init() override;
+#ifdef USE_API_PLAINTEXT
+ // Take over a connection whose first bytes were consumed by a plaintext
+ // helper on an unprovisioned device (see APIError::PROTOCOL_SWITCH_TO_NOISE).
+ // Seeds the already-read header bytes and pumps the handshake state machine
+ // until it would block.
+ APIError init_from_handoff(const uint8_t *header, uint8_t header_len);
+#endif
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
- APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
+ APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override;
protected:
+ APIError pump_handshake_();
APIError state_action_();
APIError state_action_client_hello_();
APIError state_action_server_hello_();
@@ -36,7 +44,7 @@ class APINoiseFrameHelper final : public APIFrameHelper {
APIError state_action_handshake_write_();
APIError try_read_frame_();
APIError write_frame_(const uint8_t *data, uint16_t len);
- APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint8_t message_type,
+ APIError encrypt_noise_message_(uint8_t *buf_start, uint16_t payload_size, uint16_t message_type,
uint16_t &encrypted_len_out);
APIError init_handshake_();
APIError check_handshake_finished_();
@@ -44,25 +52,22 @@ class APINoiseFrameHelper final : public APIFrameHelper {
APIError handle_handshake_frame_error_(APIError aerr);
APIError handle_noise_error_(int err, const LogString *func_name, APIError api_err);
- // Pointers first (4 bytes each)
- NoiseHandshakeState *handshake_{nullptr};
+ // Pointers first (4 bytes each; the handshake wrapper holds one pointer)
+ noise::NoiseResponderHandshake handshake_;
NoiseCipherState *send_cipher_{nullptr};
NoiseCipherState *recv_cipher_{nullptr};
// Reference to noise context (4 bytes on 32-bit)
- APINoiseContext &ctx_;
+ noise::NoiseContext &ctx_;
// Buffer for noise handshake prologue (released after handshake)
APIBuffer prologue_;
- // NoiseProtocolId (size depends on implementation)
- NoiseProtocolId nid_;
-
// Group small types together
// Fixed-size header buffer for noise protocol:
// 1 byte for indicator + 2 bytes for message size (16-bit value, not varint)
// Note: Maximum message size is UINT16_MAX (65535), with a limit of 128 bytes during handshake phase
- uint8_t rx_header_buf_[3];
+ uint8_t rx_header_buf_[noise::FRAME_HEADER_SIZE];
uint8_t rx_header_buf_len_ = 0;
// 4 bytes total, no padding
};
diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp
index fa611a6e33..d4e3354fa0 100644
--- a/esphome/components/api/api_frame_helper_plaintext.cpp
+++ b/esphome/components/api/api_frame_helper_plaintext.cpp
@@ -5,6 +5,7 @@
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
+#include "api_pb2.h"
#include "proto.h"
#include
#include
@@ -89,6 +90,17 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
// If this was the first read, validate the indicator byte
if (rx_header_buf_pos_ == 0 && received > 0) {
if (rx_header_buf_[0] != 0x00) {
+#ifdef USE_API_NOISE
+ // Dual build (encryption supported but no key set): a 0x01 first byte
+ // is a Noise client hello. Hand the connection off to a Noise helper
+ // running the all-zeros provisioning PSK so the encryption key can be
+ // set without crossing the wire in plaintext. Preserve the bytes we
+ // already consumed; they are the start of the Noise 3-byte header.
+ if (rx_header_buf_[0] == 0x01) {
+ rx_header_buf_pos_ = static_cast(received);
+ return APIError::PROTOCOL_SWITCH_TO_NOISE;
+ }
+#endif
state_ = State::FAILED;
HELPER_LOG("Bad indicator byte %u", rx_header_buf_[0]);
return APIError::BAD_INDICATOR;
@@ -160,7 +172,10 @@ APIError APIPlaintextFrameHelper::try_read_frame_() {
// Reserve space for body (+ null terminator so protobuf StringRef fields
// can be safely null-terminated in-place after decode)
- this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR);
+ if (!this->rx_buf_.resize(this->rx_header_parsed_len_ + RX_BUF_NULL_TERMINATOR)) [[unlikely]] {
+ state_ = State::FAILED;
+ return APIError::OUT_OF_MEMORY;
+ }
if (rx_buf_len_ < rx_header_parsed_len_) {
// more data to read
@@ -241,24 +256,21 @@ ESPHOME_ALWAYS_INLINE static inline void encode_varint_16(uint16_t value, uint8_
*p = static_cast(value);
}
-// Encode an 8-bit varint (1-2 bytes) using pre-computed length.
-ESPHOME_ALWAYS_INLINE static inline void encode_varint_8(uint8_t value, uint8_t varint_len, uint8_t *p) {
- if (varint_len == 2) {
- *p++ = static_cast(value | 0x80);
- *p = static_cast(value >> 7);
- } else {
- *p = value;
- }
-}
+// The generator rejects message IDs above MAX_MESSAGE_TYPE, so the type varint
+// can never outgrow the 2 bytes HEADER_PADDING budgets for it. Without this
+// bound, write_plaintext_header's header_offset would underflow for the first
+// message in a batch and the header write would land outside the buffer.
+static_assert(1 + 3 + ProtoSize::varint16(MAX_MESSAGE_TYPE) <= APIPlaintextFrameHelper::HEADER_PADDING,
+ "HEADER_PADDING cannot fit the type varint of the largest message ID");
// Write plaintext header into pre-allocated padding before payload.
// padding_size: bytes reserved before payload (HEADER_PADDING for first/single msg,
// actual header size for contiguous batch messages).
// Returns the total header length (indicator + varints).
ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_start, uint16_t payload_size,
- uint8_t message_type, uint8_t padding_size) {
+ uint16_t message_type, uint8_t padding_size) {
uint8_t size_varint_len = ProtoSize::varint16(payload_size);
- uint8_t type_varint_len = ProtoSize::varint8(message_type);
+ uint8_t type_varint_len = ProtoSize::varint16(message_type);
uint8_t total_header_len = 1 + size_varint_len + type_varint_len;
// The header is right-justified within the padding so it sits immediately before payload.
@@ -281,12 +293,12 @@ ESPHOME_ALWAYS_INLINE static inline uint8_t write_plaintext_header(uint8_t *buf_
// Encode varints directly into buffer using pre-computed lengths
encode_varint_16(payload_size, size_varint_len, buf_start + header_offset + 1);
- encode_varint_8(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len);
+ encode_varint_16(message_type, type_varint_len, buf_start + header_offset + 1 + size_varint_len);
return total_header_len;
}
-APIError APIPlaintextFrameHelper::write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) {
+APIError APIPlaintextFrameHelper::write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) {
#ifdef ESPHOME_DEBUG_API
assert(this->state_ == State::DATA);
#endif
diff --git a/esphome/components/api/api_frame_helper_plaintext.h b/esphome/components/api/api_frame_helper_plaintext.h
index 8314754715..00e7c7b1bc 100644
--- a/esphome/components/api/api_frame_helper_plaintext.h
+++ b/esphome/components/api/api_frame_helper_plaintext.h
@@ -10,7 +10,8 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
// Plaintext header structure (worst case):
// Pos 0: indicator (0x00)
// Pos 1-3: payload size varint (up to 3 bytes)
- // Pos 4-5: message type varint (up to 2 bytes)
+ // Pos 4-5: message type varint (up to 2 bytes; covers message IDs up to
+ // 16383, enforced by the proto codegen)
// Pos 6+: actual payload data
static constexpr uint8_t HEADER_PADDING = 1 + 3 + 2; // indicator + size varint + type varint
@@ -21,8 +22,17 @@ class APIPlaintextFrameHelper final : public APIFrameHelper {
APIError init() override;
APIError loop() override;
APIError read_packet(ReadPacketBuffer *buffer) override;
- APIError write_protobuf_packet(uint8_t type, ProtoWriteBuffer buffer) override;
+ APIError write_protobuf_packet(uint16_t type, ProtoWriteBuffer buffer) override;
APIError write_protobuf_messages(ProtoWriteBuffer buffer, std::span messages) override;
+#ifdef USE_API_NOISE
+ // After try_read_frame_ returned PROTOCOL_SWITCH_TO_NOISE: copy out the
+ // header bytes already consumed from the socket (at most 3, the size of the
+ // Noise fixed header) so the replacement Noise helper can be seeded with them.
+ uint8_t get_consumed_header(uint8_t out[3]) const {
+ memcpy(out, this->rx_header_buf_, this->rx_header_buf_pos_);
+ return this->rx_header_buf_pos_;
+ }
+#endif
protected:
APIError try_read_frame_();
diff --git a/esphome/components/api/api_noise_context.h b/esphome/components/api/api_noise_context.h
deleted file mode 100644
index b5f7016689..0000000000
--- a/esphome/components/api/api_noise_context.h
+++ /dev/null
@@ -1,30 +0,0 @@
-#pragma once
-#include
-#include
-#include "esphome/core/defines.h"
-
-namespace esphome::api {
-
-#ifdef USE_API_NOISE
-using psk_t = std::array;
-
-class APINoiseContext {
- public:
- void set_psk(psk_t psk) {
- this->psk_ = psk;
- bool has_psk = false;
- for (auto i : psk) {
- has_psk |= i;
- }
- this->has_psk_ = has_psk;
- }
- const psk_t &get_psk() const { return this->psk_; }
- bool has_psk() const { return this->has_psk_; }
-
- protected:
- psk_t psk_{};
- bool has_psk_{false};
-};
-#endif // USE_API_NOISE
-
-} // namespace esphome::api
diff --git a/esphome/components/api/api_options.proto b/esphome/components/api/api_options.proto
index ac9c4e59cc..66295b3d53 100644
--- a/esphome/components/api/api_options.proto
+++ b/esphome/components/api/api_options.proto
@@ -116,4 +116,10 @@ extend google.protobuf.FieldOptions {
// the per-byte loop when the upper bits are non-zero (the common case
// for real MAC addresses, since OUIs occupy the top 24 bits).
optional bool mac_address = 50019 [default=false];
+
+ // track_presence: Track whether this message-typed field was present on the wire.
+ // Generates a `bool has_{false};` member on the decoding side that is set
+ // to true when the field arrives, so an all-default submessage can be told apart
+ // from an absent one (e.g. a UTC ParsedTimezone, which is all zeros).
+ optional bool track_presence = 50020 [default=false];
}
diff --git a/esphome/components/api/api_overflow_buffer.cpp b/esphome/components/api/api_overflow_buffer.cpp
index e242d4553e..48d8fe18ba 100644
--- a/esphome/components/api/api_overflow_buffer.cpp
+++ b/esphome/components/api/api_overflow_buffer.cpp
@@ -1,6 +1,7 @@
#include "api_overflow_buffer.h"
#ifdef USE_API
#include
+#include
namespace esphome::api {
@@ -12,6 +13,22 @@ APIOverflowBuffer::~APIOverflowBuffer() {
}
ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
+ // socket->write() can re-enter this function: a log message emitted from an
+ // lwip callback during the write goes out over the API and lands back in the
+ // frame helper's write/drain path. If a nested drain ran here it would send
+ // and free the entry the outer drain is still holding, causing a double free.
+ // Report "no progress" instead; the outer drain keeps draining, and the
+ // nested send is enqueued behind the existing backlog.
+ if (this->draining_)
+ return 0;
+
+ // RAII so the flag is cleared on every return path
+ struct DrainGuard {
+ explicit DrainGuard(bool &flag) : flag_(flag) { flag_ = true; }
+ ~DrainGuard() { this->flag_ = false; }
+ bool &flag_;
+ } guard(this->draining_);
+
while (this->count_ > 0) {
Entry *front = this->queue_[this->head_];
@@ -29,11 +46,12 @@ ssize_t APIOverflowBuffer::try_drain(socket::Socket *socket) {
return sent;
}
- // Entry fully sent — free it and advance
- Entry::destroy(front);
+ // Entry fully sent — unlink it before freeing so a freed pointer is never
+ // reachable from the queue
this->queue_[this->head_] = nullptr;
this->head_ = (this->head_ + 1) % API_MAX_SEND_QUEUE;
this->count_--;
+ Entry::destroy(front);
}
return 0; // All drained
@@ -44,9 +62,18 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_
return false;
uint16_t buffer_size = total_len - skip;
+ // nothrow: a failed allocation returns nullptr so the connection is dropped
+ // cleanly instead of plain new's crash or abort on OOM
// NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
- auto *entry = new Entry{new uint8_t[buffer_size], buffer_size, 0};
- this->queue_[this->tail_] = entry;
+ auto *data = new (std::nothrow) uint8_t[buffer_size];
+ if (data == nullptr)
+ return false;
+ // NOLINTNEXTLINE(cppcoreguidelines-owning-memory)
+ auto *entry = new (std::nothrow) Entry{data, buffer_size, 0};
+ if (entry == nullptr) {
+ delete[] data;
+ return false;
+ }
uint16_t to_skip = skip;
uint16_t write_pos = 0;
@@ -63,6 +90,8 @@ bool APIOverflowBuffer::enqueue_iov(const struct iovec *iov, int iovcnt, uint16_
}
}
+ // Publish only after the copy completes so a half-built entry is never reachable
+ this->queue_[this->tail_] = entry;
this->tail_ = (this->tail_ + 1) % API_MAX_SEND_QUEUE;
this->count_++;
return true;
diff --git a/esphome/components/api/api_overflow_buffer.h b/esphome/components/api/api_overflow_buffer.h
index 19aae680f0..03a334b281 100644
--- a/esphome/components/api/api_overflow_buffer.h
+++ b/esphome/components/api/api_overflow_buffer.h
@@ -61,7 +61,7 @@ class APIOverflowBuffer {
/// Enqueue unsent IOV data into the backlog.
/// Copies iov data starting at byte offset `skip` into a new entry.
- /// Returns false if the queue is full (caller should fail the connection).
+ /// Returns false if the queue is full or allocation fails (caller should fail the connection).
bool enqueue_iov(const struct iovec *iov, int iovcnt, uint16_t total_len, uint16_t skip);
protected:
@@ -69,6 +69,10 @@ class APIOverflowBuffer {
uint8_t head_{0};
uint8_t tail_{0};
uint8_t count_{0};
+ // Guards against re-entrant drains: socket->write() can re-enter the API
+ // send path (e.g. a log message emitted from an lwip callback), and a nested
+ // drain would free the entry the outer drain is still holding.
+ bool draining_{false};
};
} // namespace esphome::api
diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp
index c711ef167c..f56d791b67 100644
--- a/esphome/components/api/api_pb2.cpp
+++ b/esphome/components/api/api_pb2.cpp
@@ -47,6 +47,26 @@ uint32_t HelloResponse::calculate_size() const {
size += 2 + this->name.size();
return size;
}
+bool DisconnectRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
+ switch (field_id) {
+ case 1:
+ this->reason = static_cast(value);
+ break;
+ default:
+ return false;
+ }
+ return true;
+}
+uint8_t *DisconnectRequest::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+ uint8_t *__restrict__ pos = buffer.get_pos();
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->reason));
+ return pos;
+}
+uint32_t DisconnectRequest::calculate_size() const {
+ uint32_t size = 0;
+ size += this->reason ? 2 : 0;
+ return size;
+}
#ifdef USE_AREAS
uint8_t *AreaInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
@@ -82,12 +102,14 @@ uint8_t *SerialProxyInfo::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PAR
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_string(pos PROTO_ENCODE_DEBUG_ARG, 1, this->name);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->port_type));
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, this->configured_line_states);
return pos;
}
uint32_t SerialProxyInfo::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_length(1, this->name.size());
size += this->port_type ? 2 : 0;
+ size += ProtoSize::calc_uint32(1, this->configured_line_states);
return size;
}
#endif
@@ -150,6 +172,9 @@ uint8_t *DeviceInfoResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_
for (const auto &it : this->serial_proxies) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 25, it);
}
+#endif
+#ifdef USE_API_NOISE
+ ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 26, this->api_encryption_provisionable);
#endif
return pos;
}
@@ -212,6 +237,85 @@ uint32_t DeviceInfoResponse::calculate_size() const {
for (const auto &it : this->serial_proxies) {
size += ProtoSize::calc_message_force(2, it.calculate_size());
}
+#endif
+#ifdef USE_API_NOISE
+ size += ProtoSize::calc_bool(2, this->api_encryption_provisionable);
+#endif
+ return size;
+}
+#ifdef USE_BLUETOOTH_PROXY
+uint8_t *BluetoothProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+ uint8_t *__restrict__ pos = buffer.get_pos();
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
+ ProtoEncode::encode_short_string_force(pos PROTO_ENCODE_DEBUG_ARG, 18, this->mac_address);
+ return pos;
+}
+uint32_t BluetoothProxyCapabilities::calculate_size() const {
+ uint32_t size = 0;
+ size += ProtoSize::calc_uint32(1, this->feature_flags);
+ size += 2 + this->mac_address.size();
+ return size;
+}
+#endif
+#ifdef USE_VOICE_ASSISTANT
+uint8_t *VoiceAssistantCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+ uint8_t *__restrict__ pos = buffer.get_pos();
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
+ return pos;
+}
+uint32_t VoiceAssistantCapabilities::calculate_size() const {
+ uint32_t size = 0;
+ size += ProtoSize::calc_uint32(1, this->feature_flags);
+ return size;
+}
+#endif
+#ifdef USE_ZWAVE_PROXY
+uint8_t *ZWaveProxyCapabilities::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+ uint8_t *__restrict__ pos = buffer.get_pos();
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->feature_flags);
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->home_id);
+ return pos;
+}
+uint32_t ZWaveProxyCapabilities::calculate_size() const {
+ uint32_t size = 0;
+ size += ProtoSize::calc_uint32(1, this->feature_flags);
+ size += ProtoSize::calc_uint32(1, this->home_id);
+ return size;
+}
+#endif
+uint8_t *DeviceCapabilitiesResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+ uint8_t *__restrict__ pos = buffer.get_pos();
+#ifdef USE_BLUETOOTH_PROXY
+ ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 1, this->bluetooth_proxy);
+#endif
+#ifdef USE_VOICE_ASSISTANT
+ ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 2, this->voice_assistant);
+#endif
+#ifdef USE_ZWAVE_PROXY
+ ProtoEncode::encode_optional_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 3, this->zwave_proxy);
+#endif
+#ifdef USE_SERIAL_PROXY
+ for (const auto &it : this->serial_proxies) {
+ ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 4, it);
+ }
+#endif
+ return pos;
+}
+uint32_t DeviceCapabilitiesResponse::calculate_size() const {
+ uint32_t size = 0;
+#ifdef USE_BLUETOOTH_PROXY
+ size += ProtoSize::calc_message(1, this->bluetooth_proxy.calculate_size());
+#endif
+#ifdef USE_VOICE_ASSISTANT
+ size += ProtoSize::calc_message(1, this->voice_assistant.calculate_size());
+#endif
+#ifdef USE_ZWAVE_PROXY
+ size += ProtoSize::calc_message(1, this->zwave_proxy.calculate_size());
+#endif
+#ifdef USE_SERIAL_PROXY
+ for (const auto &it : this->serial_proxies) {
+ size += ProtoSize::calc_message_force(1, it.calculate_size());
+ }
#endif
return size;
}
@@ -1147,12 +1251,9 @@ bool ParsedTimezone::decode_length(uint32_t field_id, ProtoLengthDelimited value
}
bool GetTimeResponse::decode_length(uint32_t field_id, ProtoLengthDelimited value) {
switch (field_id) {
- case 2: {
- this->timezone = StringRef(reinterpret_cast(value.data()), value.size());
- break;
- }
case 3:
value.decode_to_message(this->parsed_timezone);
+ this->has_parsed_timezone = true;
break;
default:
return false;
@@ -2222,7 +2323,6 @@ uint8_t *ListEntitiesMediaPlayerResponse::encode(ProtoWriteBuffer &buffer PROTO_
#endif
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 6, this->disabled_by_default);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 7, static_cast(this->entity_category));
- ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 8, this->supports_pause);
for (auto &it : this->supported_formats) {
ProtoEncode::encode_sub_message(pos PROTO_ENCODE_DEBUG_ARG, buffer, 9, it);
}
@@ -2242,7 +2342,6 @@ uint32_t ListEntitiesMediaPlayerResponse::calculate_size() const {
#endif
size += ProtoSize::calc_bool(1, this->disabled_by_default);
size += this->entity_category ? 2 : 0;
- size += ProtoSize::calc_bool(1, this->supports_pause);
if (!this->supported_formats.empty()) {
for (const auto &it : this->supported_formats) {
size += ProtoSize::calc_message_force(1, it.calculate_size());
@@ -2380,6 +2479,8 @@ BluetoothLERawAdvertisementsResponse::calculate_size() const {
}
return size;
}
+#endif
+#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool BluetoothDeviceRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
@@ -2756,6 +2857,8 @@ uint32_t BluetoothDeviceClearCacheResponse::calculate_size() const {
size += ProtoSize::calc_int32(1, this->error);
return size;
}
+#endif
+#ifdef USE_BLUETOOTH_PROXY
uint8_t *BluetoothScannerStateResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->state));
@@ -3839,6 +3942,18 @@ uint32_t ZWaveProxyRequest::calculate_size() const {
size += ProtoSize::calc_length(1, this->data_len);
return size;
}
+uint8_t *ZWaveProxyRequestResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
+ uint8_t *__restrict__ pos = buffer.get_pos();
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, static_cast(this->type));
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, static_cast(this->status));
+ return pos;
+}
+uint32_t ZWaveProxyRequestResponse::calculate_size() const {
+ uint32_t size = 0;
+ size += this->type ? 2 : 0;
+ size += this->status ? 2 : 0;
+ return size;
+}
#endif
#ifdef USE_INFRARED
uint8_t *ListEntitiesInfraredResponse::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
@@ -4081,12 +4196,14 @@ uint8_t *SerialProxyGetModemPinsResponse::encode(ProtoWriteBuffer &buffer PROTO_
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance);
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 2, this->line_states);
+ ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 3, static_cast(this->status));
return pos;
}
uint32_t SerialProxyGetModemPinsResponse::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_uint32(1, this->instance);
size += ProtoSize::calc_uint32(1, this->line_states);
+ size += this->status ? 2 : 0;
return size;
}
bool SerialProxyRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
@@ -4119,7 +4236,7 @@ uint32_t SerialProxyRequestResponse::calculate_size() const {
return size;
}
#endif
-#ifdef USE_BLUETOOTH_PROXY
+#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
bool BluetoothSetConnectionParamsRequest::decode_varint(uint32_t field_id, proto_varint_value_t value) {
switch (field_id) {
case 1:
diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h
index 7e926ee0d4..bed28d2956 100644
--- a/esphome/components/api/api_pb2.h
+++ b/esphome/components/api/api_pb2.h
@@ -9,8 +9,16 @@
namespace esphome::api {
+// Upper bound on message IDs, enforced by the code generator: the plaintext
+// frame header budgets 2 varint bytes for the type (HEADER_PADDING).
+static constexpr uint16_t MAX_MESSAGE_TYPE = 16383;
+
namespace enums {
+enum DisconnectReason : uint32_t {
+ DISCONNECT_REASON_UNSPECIFIED = 0,
+ DISCONNECT_REASON_PROVISIONING_CLOSED = 1,
+};
enum SerialProxyPortType : uint32_t {
SERIAL_PROXY_PORT_TYPE_TTL = 0,
SERIAL_PROXY_PORT_TYPE_RS232 = 1,
@@ -221,7 +229,7 @@ enum MediaPlayerFormatPurpose : uint32_t {
MEDIA_PLAYER_FORMAT_PURPOSE_ANNOUNCEMENT = 1,
};
#endif
-#ifdef USE_BLUETOOTH_PROXY
+#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
enum BluetoothDeviceRequestType : uint32_t {
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT = 0,
BLUETOOTH_DEVICE_REQUEST_TYPE_DISCONNECT = 1,
@@ -231,6 +239,8 @@ enum BluetoothDeviceRequestType : uint32_t {
BLUETOOTH_DEVICE_REQUEST_TYPE_CONNECT_V3_WITHOUT_CACHE = 5,
BLUETOOTH_DEVICE_REQUEST_TYPE_CLEAR_CACHE = 6,
};
+#endif
+#ifdef USE_BLUETOOTH_PROXY
enum BluetoothScannerState : uint32_t {
BLUETOOTH_SCANNER_STATE_IDLE = 0,
BLUETOOTH_SCANNER_STATE_STARTING = 1,
@@ -328,6 +338,11 @@ enum ZWaveProxyRequestType : uint32_t {
ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1,
ZWAVE_PROXY_REQUEST_TYPE_HOME_ID_CHANGE = 2,
};
+enum ZWaveProxyStatus : uint32_t {
+ ZWAVE_PROXY_STATUS_OK = 0,
+ ZWAVE_PROXY_STATUS_IN_USE = 1,
+ ZWAVE_PROXY_STATUS_NOT_SUPPORTED = 2,
+};
#endif
#ifdef USE_SERIAL_PROXY
enum SerialProxyParity : uint32_t {
@@ -339,6 +354,8 @@ enum SerialProxyRequestType : uint32_t {
SERIAL_PROXY_REQUEST_TYPE_SUBSCRIBE = 0,
SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE = 1,
SERIAL_PROXY_REQUEST_TYPE_FLUSH = 2,
+ SERIAL_PROXY_REQUEST_TYPE_CONFIGURE = 3,
+ SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS = 4,
};
enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_OK = 0,
@@ -346,6 +363,8 @@ enum SerialProxyStatus : uint32_t {
SERIAL_PROXY_STATUS_ERROR = 2,
SERIAL_PROXY_STATUS_TIMEOUT = 3,
SERIAL_PROXY_STATUS_NOT_SUPPORTED = 4,
+ SERIAL_PROXY_STATUS_PORT_IN_USE = 5,
+ SERIAL_PROXY_STATUS_INVALID_ARGUMENT = 6,
};
#endif
@@ -392,7 +411,7 @@ class CommandProtoMessage : public ProtoDecodableMessage {
};
class HelloRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 1;
+ static constexpr uint16_t MESSAGE_TYPE = 1;
static constexpr uint8_t ESTIMATED_SIZE = 17;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("hello_request"); }
@@ -410,7 +429,7 @@ class HelloRequest final : public ProtoDecodableMessage {
};
class HelloResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 2;
+ static constexpr uint16_t MESSAGE_TYPE = 2;
static constexpr uint8_t ESTIMATED_SIZE = 26;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("hello_response"); }
@@ -427,22 +446,26 @@ class HelloResponse final : public ProtoMessage {
protected:
};
-class DisconnectRequest final : public ProtoMessage {
+class DisconnectRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 5;
- static constexpr uint8_t ESTIMATED_SIZE = 0;
+ static constexpr uint16_t MESSAGE_TYPE = 5;
+ static constexpr uint8_t ESTIMATED_SIZE = 2;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("disconnect_request"); }
#endif
+ enums::DisconnectReason reason{};
+ uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
+ uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
protected:
+ bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
class DisconnectResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 6;
+ static constexpr uint16_t MESSAGE_TYPE = 6;
static constexpr uint8_t ESTIMATED_SIZE = 0;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("disconnect_response"); }
@@ -455,7 +478,7 @@ class DisconnectResponse final : public ProtoMessage {
};
class PingRequest final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 7;
+ static constexpr uint16_t MESSAGE_TYPE = 7;
static constexpr uint8_t ESTIMATED_SIZE = 0;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("ping_request"); }
@@ -468,7 +491,7 @@ class PingRequest final : public ProtoMessage {
};
class PingResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 8;
+ static constexpr uint16_t MESSAGE_TYPE = 8;
static constexpr uint8_t ESTIMATED_SIZE = 0;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("ping_response"); }
@@ -513,6 +536,7 @@ class SerialProxyInfo final : public ProtoMessage {
public:
StringRef name{};
enums::SerialProxyPortType port_type{};
+ uint32_t configured_line_states{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
@@ -524,8 +548,8 @@ class SerialProxyInfo final : public ProtoMessage {
#endif
class DeviceInfoResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 10;
- static constexpr uint16_t ESTIMATED_SIZE = 309;
+ static constexpr uint16_t MESSAGE_TYPE = 10;
+ static constexpr uint16_t ESTIMATED_SIZE = 312;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("device_info_response"); }
#endif
@@ -578,6 +602,77 @@ class DeviceInfoResponse final : public ProtoMessage {
#ifdef USE_ZWAVE_PROXY
uint32_t zwave_home_id{0};
#endif
+#ifdef USE_SERIAL_PROXY
+ std::array serial_proxies{};
+#endif
+#ifdef USE_API_NOISE
+ bool api_encryption_provisionable{false};
+#endif
+ uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
+ uint32_t calculate_size() const;
+#ifdef HAS_PROTO_MESSAGE_DUMP
+ const char *dump_to(DumpBuffer &out) const override;
+#endif
+
+ protected:
+};
+#ifdef USE_BLUETOOTH_PROXY
+class BluetoothProxyCapabilities final : public ProtoMessage {
+ public:
+ uint32_t feature_flags{0};
+ StringRef mac_address{};
+ uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
+ uint32_t calculate_size() const;
+#ifdef HAS_PROTO_MESSAGE_DUMP
+ const char *dump_to(DumpBuffer &out) const override;
+#endif
+
+ protected:
+};
+#endif
+#ifdef USE_VOICE_ASSISTANT
+class VoiceAssistantCapabilities final : public ProtoMessage {
+ public:
+ uint32_t feature_flags{0};
+ uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
+ uint32_t calculate_size() const;
+#ifdef HAS_PROTO_MESSAGE_DUMP
+ const char *dump_to(DumpBuffer &out) const override;
+#endif
+
+ protected:
+};
+#endif
+#ifdef USE_ZWAVE_PROXY
+class ZWaveProxyCapabilities final : public ProtoMessage {
+ public:
+ uint32_t feature_flags{0};
+ uint32_t home_id{0};
+ uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
+ uint32_t calculate_size() const;
+#ifdef HAS_PROTO_MESSAGE_DUMP
+ const char *dump_to(DumpBuffer &out) const override;
+#endif
+
+ protected:
+};
+#endif
+class DeviceCapabilitiesResponse final : public ProtoMessage {
+ public:
+ static constexpr uint16_t MESSAGE_TYPE = 150;
+ static constexpr uint8_t ESTIMATED_SIZE = 102;
+#ifdef HAS_PROTO_MESSAGE_DUMP
+ const LogString *message_name() const override { return LOG_STR("device_capabilities_response"); }
+#endif
+#ifdef USE_BLUETOOTH_PROXY
+ BluetoothProxyCapabilities bluetooth_proxy{};
+#endif
+#ifdef USE_VOICE_ASSISTANT
+ VoiceAssistantCapabilities voice_assistant{};
+#endif
+#ifdef USE_ZWAVE_PROXY
+ ZWaveProxyCapabilities zwave_proxy{};
+#endif
#ifdef USE_SERIAL_PROXY
std::array serial_proxies{};
#endif
@@ -591,7 +686,7 @@ class DeviceInfoResponse final : public ProtoMessage {
};
class ListEntitiesDoneResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 19;
+ static constexpr uint16_t MESSAGE_TYPE = 19;
static constexpr uint8_t ESTIMATED_SIZE = 0;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_done_response"); }
@@ -605,7 +700,7 @@ class ListEntitiesDoneResponse final : public ProtoMessage {
#ifdef USE_BINARY_SENSOR
class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 12;
+ static constexpr uint16_t MESSAGE_TYPE = 12;
static constexpr uint8_t ESTIMATED_SIZE = 51;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_binary_sensor_response"); }
@@ -622,7 +717,7 @@ class ListEntitiesBinarySensorResponse final : public InfoResponseProtoMessage {
};
class BinarySensorStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 21;
+ static constexpr uint16_t MESSAGE_TYPE = 21;
static constexpr uint8_t ESTIMATED_SIZE = 13;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("binary_sensor_state_response"); }
@@ -641,7 +736,7 @@ class BinarySensorStateResponse final : public StateResponseProtoMessage {
#ifdef USE_COVER
class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 13;
+ static constexpr uint16_t MESSAGE_TYPE = 13;
static constexpr uint8_t ESTIMATED_SIZE = 57;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_cover_response"); }
@@ -661,7 +756,7 @@ class ListEntitiesCoverResponse final : public InfoResponseProtoMessage {
};
class CoverStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 22;
+ static constexpr uint16_t MESSAGE_TYPE = 22;
static constexpr uint8_t ESTIMATED_SIZE = 21;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("cover_state_response"); }
@@ -679,7 +774,7 @@ class CoverStateResponse final : public StateResponseProtoMessage {
};
class CoverCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 30;
+ static constexpr uint16_t MESSAGE_TYPE = 30;
static constexpr uint8_t ESTIMATED_SIZE = 25;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("cover_command_request"); }
@@ -701,7 +796,7 @@ class CoverCommandRequest final : public CommandProtoMessage {
#ifdef USE_FAN
class ListEntitiesFanResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 14;
+ static constexpr uint16_t MESSAGE_TYPE = 14;
static constexpr uint8_t ESTIMATED_SIZE = 68;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_fan_response"); }
@@ -721,7 +816,7 @@ class ListEntitiesFanResponse final : public InfoResponseProtoMessage {
};
class FanStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 23;
+ static constexpr uint16_t MESSAGE_TYPE = 23;
static constexpr uint8_t ESTIMATED_SIZE = 28;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("fan_state_response"); }
@@ -741,7 +836,7 @@ class FanStateResponse final : public StateResponseProtoMessage {
};
class FanCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 31;
+ static constexpr uint16_t MESSAGE_TYPE = 31;
static constexpr uint8_t ESTIMATED_SIZE = 38;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("fan_command_request"); }
@@ -769,7 +864,7 @@ class FanCommandRequest final : public CommandProtoMessage {
#ifdef USE_LIGHT
class ListEntitiesLightResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 15;
+ static constexpr uint16_t MESSAGE_TYPE = 15;
static constexpr uint8_t ESTIMATED_SIZE = 73;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_light_response"); }
@@ -788,7 +883,7 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage {
};
class LightStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 24;
+ static constexpr uint16_t MESSAGE_TYPE = 24;
static constexpr uint8_t ESTIMATED_SIZE = 67;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("light_state_response"); }
@@ -815,7 +910,7 @@ class LightStateResponse final : public StateResponseProtoMessage {
};
class LightCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 32;
+ static constexpr uint16_t MESSAGE_TYPE = 32;
static constexpr uint8_t ESTIMATED_SIZE = 112;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("light_command_request"); }
@@ -859,7 +954,7 @@ class LightCommandRequest final : public CommandProtoMessage {
#ifdef USE_SENSOR
class ListEntitiesSensorResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 16;
+ static constexpr uint16_t MESSAGE_TYPE = 16;
static constexpr uint8_t ESTIMATED_SIZE = 66;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_sensor_response"); }
@@ -879,7 +974,7 @@ class ListEntitiesSensorResponse final : public InfoResponseProtoMessage {
};
class SensorStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 25;
+ static constexpr uint16_t MESSAGE_TYPE = 25;
static constexpr uint8_t ESTIMATED_SIZE = 16;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("sensor_state_response"); }
@@ -898,7 +993,7 @@ class SensorStateResponse final : public StateResponseProtoMessage {
#ifdef USE_SWITCH
class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 17;
+ static constexpr uint16_t MESSAGE_TYPE = 17;
static constexpr uint8_t ESTIMATED_SIZE = 51;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_switch_response"); }
@@ -915,7 +1010,7 @@ class ListEntitiesSwitchResponse final : public InfoResponseProtoMessage {
};
class SwitchStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 26;
+ static constexpr uint16_t MESSAGE_TYPE = 26;
static constexpr uint8_t ESTIMATED_SIZE = 11;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("switch_state_response"); }
@@ -931,7 +1026,7 @@ class SwitchStateResponse final : public StateResponseProtoMessage {
};
class SwitchCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 33;
+ static constexpr uint16_t MESSAGE_TYPE = 33;
static constexpr uint8_t ESTIMATED_SIZE = 11;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("switch_command_request"); }
@@ -949,7 +1044,7 @@ class SwitchCommandRequest final : public CommandProtoMessage {
#ifdef USE_TEXT_SENSOR
class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 18;
+ static constexpr uint16_t MESSAGE_TYPE = 18;
static constexpr uint8_t ESTIMATED_SIZE = 49;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_text_sensor_response"); }
@@ -965,7 +1060,7 @@ class ListEntitiesTextSensorResponse final : public InfoResponseProtoMessage {
};
class TextSensorStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 27;
+ static constexpr uint16_t MESSAGE_TYPE = 27;
static constexpr uint8_t ESTIMATED_SIZE = 20;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("text_sensor_state_response"); }
@@ -983,7 +1078,7 @@ class TextSensorStateResponse final : public StateResponseProtoMessage {
#endif
class SubscribeLogsRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 28;
+ static constexpr uint16_t MESSAGE_TYPE = 28;
static constexpr uint8_t ESTIMATED_SIZE = 4;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("subscribe_logs_request"); }
@@ -999,7 +1094,7 @@ class SubscribeLogsRequest final : public ProtoDecodableMessage {
};
class SubscribeLogsResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 29;
+ static constexpr uint16_t MESSAGE_TYPE = 29;
static constexpr uint8_t ESTIMATED_SIZE = 21;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("subscribe_logs_response"); }
@@ -1022,7 +1117,7 @@ class SubscribeLogsResponse final : public ProtoMessage {
#ifdef USE_API_NOISE
class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 124;
+ static constexpr uint16_t MESSAGE_TYPE = 124;
static constexpr uint8_t ESTIMATED_SIZE = 19;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("noise_encryption_set_key_request"); }
@@ -1038,7 +1133,7 @@ class NoiseEncryptionSetKeyRequest final : public ProtoDecodableMessage {
};
class NoiseEncryptionSetKeyResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 125;
+ static constexpr uint16_t MESSAGE_TYPE = 125;
static constexpr uint8_t ESTIMATED_SIZE = 2;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("noise_encryption_set_key_response"); }
@@ -1068,7 +1163,7 @@ class HomeassistantServiceMap final : public ProtoMessage {
};
class HomeassistantActionRequest final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 35;
+ static constexpr uint16_t MESSAGE_TYPE = 35;
static constexpr uint8_t ESTIMATED_SIZE = 128;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("homeassistant_action_request"); }
@@ -1099,7 +1194,7 @@ class HomeassistantActionRequest final : public ProtoMessage {
#ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES
class HomeassistantActionResponse final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 130;
+ static constexpr uint16_t MESSAGE_TYPE = 130;
static constexpr uint8_t ESTIMATED_SIZE = 34;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("homeassistant_action_response"); }
@@ -1123,7 +1218,7 @@ class HomeassistantActionResponse final : public ProtoDecodableMessage {
#ifdef USE_API_HOMEASSISTANT_STATES
class SubscribeHomeAssistantStateResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 39;
+ static constexpr uint16_t MESSAGE_TYPE = 39;
static constexpr uint8_t ESTIMATED_SIZE = 20;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("subscribe_home_assistant_state_response"); }
@@ -1141,7 +1236,7 @@ class SubscribeHomeAssistantStateResponse final : public ProtoMessage {
};
class HomeAssistantStateResponse final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 40;
+ static constexpr uint16_t MESSAGE_TYPE = 40;
static constexpr uint8_t ESTIMATED_SIZE = 27;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("home_assistant_state_response"); }
@@ -1159,7 +1254,7 @@ class HomeAssistantStateResponse final : public ProtoDecodableMessage {
#endif
class GetTimeRequest final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 36;
+ static constexpr uint16_t MESSAGE_TYPE = 36;
static constexpr uint8_t ESTIMATED_SIZE = 0;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("get_time_request"); }
@@ -1201,14 +1296,14 @@ class ParsedTimezone final : public ProtoDecodableMessage {
};
class GetTimeResponse final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 37;
- static constexpr uint8_t ESTIMATED_SIZE = 31;
+ static constexpr uint16_t MESSAGE_TYPE = 37;
+ static constexpr uint8_t ESTIMATED_SIZE = 22;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("get_time_response"); }
#endif
uint32_t epoch_seconds{0};
- StringRef timezone{};
ParsedTimezone parsed_timezone{};
+ bool has_parsed_timezone{false};
#ifdef HAS_PROTO_MESSAGE_DUMP
const char *dump_to(DumpBuffer &out) const override;
#endif
@@ -1232,7 +1327,7 @@ class ListEntitiesServicesArgument final : public ProtoMessage {
};
class ListEntitiesServicesResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 41;
+ static constexpr uint16_t MESSAGE_TYPE = 41;
static constexpr uint8_t ESTIMATED_SIZE = 50;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_services_response"); }
@@ -1272,7 +1367,7 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage {
};
class ExecuteServiceRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 42;
+ static constexpr uint16_t MESSAGE_TYPE = 42;
static constexpr uint8_t ESTIMATED_SIZE = 45;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("execute_service_request"); }
@@ -1299,7 +1394,7 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage {
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES
class ExecuteServiceResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 131;
+ static constexpr uint16_t MESSAGE_TYPE = 131;
static constexpr uint8_t ESTIMATED_SIZE = 34;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("execute_service_response"); }
@@ -1323,7 +1418,7 @@ class ExecuteServiceResponse final : public ProtoMessage {
#ifdef USE_CAMERA
class ListEntitiesCameraResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 43;
+ static constexpr uint16_t MESSAGE_TYPE = 43;
static constexpr uint8_t ESTIMATED_SIZE = 40;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_camera_response"); }
@@ -1338,7 +1433,7 @@ class ListEntitiesCameraResponse final : public InfoResponseProtoMessage {
};
class CameraImageResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 44;
+ static constexpr uint16_t MESSAGE_TYPE = 44;
static constexpr uint8_t ESTIMATED_SIZE = 30;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("camera_image_response"); }
@@ -1360,7 +1455,7 @@ class CameraImageResponse final : public StateResponseProtoMessage {
};
class CameraImageRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 45;
+ static constexpr uint16_t MESSAGE_TYPE = 45;
static constexpr uint8_t ESTIMATED_SIZE = 4;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("camera_image_request"); }
@@ -1378,7 +1473,7 @@ class CameraImageRequest final : public ProtoDecodableMessage {
#ifdef USE_CLIMATE
class ListEntitiesClimateResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 46;
+ static constexpr uint16_t MESSAGE_TYPE = 46;
static constexpr uint8_t ESTIMATED_SIZE = 153;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_climate_response"); }
@@ -1412,7 +1507,7 @@ class ListEntitiesClimateResponse final : public InfoResponseProtoMessage {
};
class ClimateStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 47;
+ static constexpr uint16_t MESSAGE_TYPE = 47;
static constexpr uint8_t ESTIMATED_SIZE = 68;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("climate_state_response"); }
@@ -1440,7 +1535,7 @@ class ClimateStateResponse final : public StateResponseProtoMessage {
};
class ClimateCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 48;
+ static constexpr uint16_t MESSAGE_TYPE = 48;
static constexpr uint8_t ESTIMATED_SIZE = 84;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("climate_command_request"); }
@@ -1478,7 +1573,7 @@ class ClimateCommandRequest final : public CommandProtoMessage {
#ifdef USE_WATER_HEATER
class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 132;
+ static constexpr uint16_t MESSAGE_TYPE = 132;
static constexpr uint8_t ESTIMATED_SIZE = 65;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_water_heater_response"); }
@@ -1499,7 +1594,7 @@ class ListEntitiesWaterHeaterResponse final : public InfoResponseProtoMessage {
};
class WaterHeaterStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 133;
+ static constexpr uint16_t MESSAGE_TYPE = 133;
static constexpr uint8_t ESTIMATED_SIZE = 35;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("water_heater_state_response"); }
@@ -1520,7 +1615,7 @@ class WaterHeaterStateResponse final : public StateResponseProtoMessage {
};
class WaterHeaterCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 134;
+ static constexpr uint16_t MESSAGE_TYPE = 134;
static constexpr uint8_t ESTIMATED_SIZE = 34;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("water_heater_command_request"); }
@@ -1543,7 +1638,7 @@ class WaterHeaterCommandRequest final : public CommandProtoMessage {
#ifdef USE_NUMBER
class ListEntitiesNumberResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 49;
+ static constexpr uint16_t MESSAGE_TYPE = 49;
static constexpr uint8_t ESTIMATED_SIZE = 75;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_number_response"); }
@@ -1564,7 +1659,7 @@ class ListEntitiesNumberResponse final : public InfoResponseProtoMessage {
};
class NumberStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 50;
+ static constexpr uint16_t MESSAGE_TYPE = 50;
static constexpr uint8_t ESTIMATED_SIZE = 16;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("number_state_response"); }
@@ -1581,7 +1676,7 @@ class NumberStateResponse final : public StateResponseProtoMessage {
};
class NumberCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 51;
+ static constexpr uint16_t MESSAGE_TYPE = 51;
static constexpr uint8_t ESTIMATED_SIZE = 14;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("number_command_request"); }
@@ -1599,7 +1694,7 @@ class NumberCommandRequest final : public CommandProtoMessage {
#ifdef USE_SELECT
class ListEntitiesSelectResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 52;
+ static constexpr uint16_t MESSAGE_TYPE = 52;
static constexpr uint8_t ESTIMATED_SIZE = 58;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_select_response"); }
@@ -1615,7 +1710,7 @@ class ListEntitiesSelectResponse final : public InfoResponseProtoMessage {
};
class SelectStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 53;
+ static constexpr uint16_t MESSAGE_TYPE = 53;
static constexpr uint8_t ESTIMATED_SIZE = 20;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("select_state_response"); }
@@ -1632,7 +1727,7 @@ class SelectStateResponse final : public StateResponseProtoMessage {
};
class SelectCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 54;
+ static constexpr uint16_t MESSAGE_TYPE = 54;
static constexpr uint8_t ESTIMATED_SIZE = 18;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("select_command_request"); }
@@ -1651,7 +1746,7 @@ class SelectCommandRequest final : public CommandProtoMessage {
#ifdef USE_SIREN
class ListEntitiesSirenResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 55;
+ static constexpr uint16_t MESSAGE_TYPE = 55;
static constexpr uint8_t ESTIMATED_SIZE = 62;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_siren_response"); }
@@ -1669,7 +1764,7 @@ class ListEntitiesSirenResponse final : public InfoResponseProtoMessage {
};
class SirenStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 56;
+ static constexpr uint16_t MESSAGE_TYPE = 56;
static constexpr uint8_t ESTIMATED_SIZE = 11;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("siren_state_response"); }
@@ -1685,7 +1780,7 @@ class SirenStateResponse final : public StateResponseProtoMessage {
};
class SirenCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 57;
+ static constexpr uint16_t MESSAGE_TYPE = 57;
static constexpr uint8_t ESTIMATED_SIZE = 37;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("siren_command_request"); }
@@ -1711,7 +1806,7 @@ class SirenCommandRequest final : public CommandProtoMessage {
#ifdef USE_LOCK
class ListEntitiesLockResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 58;
+ static constexpr uint16_t MESSAGE_TYPE = 58;
static constexpr uint8_t ESTIMATED_SIZE = 55;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_lock_response"); }
@@ -1730,7 +1825,7 @@ class ListEntitiesLockResponse final : public InfoResponseProtoMessage {
};
class LockStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 59;
+ static constexpr uint16_t MESSAGE_TYPE = 59;
static constexpr uint8_t ESTIMATED_SIZE = 11;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("lock_state_response"); }
@@ -1746,7 +1841,7 @@ class LockStateResponse final : public StateResponseProtoMessage {
};
class LockCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 60;
+ static constexpr uint16_t MESSAGE_TYPE = 60;
static constexpr uint8_t ESTIMATED_SIZE = 22;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("lock_command_request"); }
@@ -1767,7 +1862,7 @@ class LockCommandRequest final : public CommandProtoMessage {
#ifdef USE_BUTTON
class ListEntitiesButtonResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 61;
+ static constexpr uint16_t MESSAGE_TYPE = 61;
static constexpr uint8_t ESTIMATED_SIZE = 49;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_button_response"); }
@@ -1783,7 +1878,7 @@ class ListEntitiesButtonResponse final : public InfoResponseProtoMessage {
};
class ButtonCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 62;
+ static constexpr uint16_t MESSAGE_TYPE = 62;
static constexpr uint8_t ESTIMATED_SIZE = 9;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("button_command_request"); }
@@ -1815,12 +1910,11 @@ class MediaPlayerSupportedFormat final : public ProtoMessage {
};
class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 63;
- static constexpr uint8_t ESTIMATED_SIZE = 80;
+ static constexpr uint16_t MESSAGE_TYPE = 63;
+ static constexpr uint8_t ESTIMATED_SIZE = 78;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_media_player_response"); }
#endif
- bool supports_pause{false};
std::vector supported_formats{};
uint32_t feature_flags{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
@@ -1833,7 +1927,7 @@ class ListEntitiesMediaPlayerResponse final : public InfoResponseProtoMessage {
};
class MediaPlayerStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 64;
+ static constexpr uint16_t MESSAGE_TYPE = 64;
static constexpr uint8_t ESTIMATED_SIZE = 18;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("media_player_state_response"); }
@@ -1851,7 +1945,7 @@ class MediaPlayerStateResponse final : public StateResponseProtoMessage {
};
class MediaPlayerCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 65;
+ static constexpr uint16_t MESSAGE_TYPE = 65;
static constexpr uint8_t ESTIMATED_SIZE = 35;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("media_player_command_request"); }
@@ -1877,7 +1971,7 @@ class MediaPlayerCommandRequest final : public CommandProtoMessage {
#ifdef USE_BLUETOOTH_PROXY
class SubscribeBluetoothLEAdvertisementsRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 66;
+ static constexpr uint16_t MESSAGE_TYPE = 66;
static constexpr uint8_t ESTIMATED_SIZE = 4;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("subscribe_bluetooth_le_advertisements_request"); }
@@ -1905,7 +1999,7 @@ class BluetoothLERawAdvertisement final : public ProtoMessage {
};
class BluetoothLERawAdvertisementsResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 93;
+ static constexpr uint16_t MESSAGE_TYPE = 93;
static constexpr uint8_t ESTIMATED_SIZE = 136;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_le_raw_advertisements_response"); }
@@ -1920,9 +2014,11 @@ class BluetoothLERawAdvertisementsResponse final : public ProtoMessage {
protected:
};
+#endif
+#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothDeviceRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 68;
+ static constexpr uint16_t MESSAGE_TYPE = 68;
static constexpr uint8_t ESTIMATED_SIZE = 12;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_device_request"); }
@@ -1940,7 +2036,7 @@ class BluetoothDeviceRequest final : public ProtoDecodableMessage {
};
class BluetoothDeviceConnectionResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 69;
+ static constexpr uint16_t MESSAGE_TYPE = 69;
static constexpr uint8_t ESTIMATED_SIZE = 14;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_device_connection_response"); }
@@ -1959,7 +2055,7 @@ class BluetoothDeviceConnectionResponse final : public ProtoMessage {
};
class BluetoothGATTGetServicesRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 70;
+ static constexpr uint16_t MESSAGE_TYPE = 70;
static constexpr uint8_t ESTIMATED_SIZE = 4;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_request"); }
@@ -2016,7 +2112,7 @@ class BluetoothGATTService final : public ProtoMessage {
};
class BluetoothGATTGetServicesResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 71;
+ static constexpr uint16_t MESSAGE_TYPE = 71;
static constexpr uint8_t ESTIMATED_SIZE = 38;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_response"); }
@@ -2033,7 +2129,7 @@ class BluetoothGATTGetServicesResponse final : public ProtoMessage {
};
class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 72;
+ static constexpr uint16_t MESSAGE_TYPE = 72;
static constexpr uint8_t ESTIMATED_SIZE = 4;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_get_services_done_response"); }
@@ -2049,7 +2145,7 @@ class BluetoothGATTGetServicesDoneResponse final : public ProtoMessage {
};
class BluetoothGATTReadRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 73;
+ static constexpr uint16_t MESSAGE_TYPE = 73;
static constexpr uint8_t ESTIMATED_SIZE = 8;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_read_request"); }
@@ -2065,7 +2161,7 @@ class BluetoothGATTReadRequest final : public ProtoDecodableMessage {
};
class BluetoothGATTReadResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 74;
+ static constexpr uint16_t MESSAGE_TYPE = 74;
static constexpr uint8_t ESTIMATED_SIZE = 27;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_read_response"); }
@@ -2088,7 +2184,7 @@ class BluetoothGATTReadResponse final : public ProtoMessage {
};
class BluetoothGATTWriteRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 75;
+ static constexpr uint16_t MESSAGE_TYPE = 75;
static constexpr uint8_t ESTIMATED_SIZE = 29;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_write_request"); }
@@ -2108,7 +2204,7 @@ class BluetoothGATTWriteRequest final : public ProtoDecodableMessage {
};
class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 76;
+ static constexpr uint16_t MESSAGE_TYPE = 76;
static constexpr uint8_t ESTIMATED_SIZE = 8;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_read_descriptor_request"); }
@@ -2124,7 +2220,7 @@ class BluetoothGATTReadDescriptorRequest final : public ProtoDecodableMessage {
};
class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 77;
+ static constexpr uint16_t MESSAGE_TYPE = 77;
static constexpr uint8_t ESTIMATED_SIZE = 27;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_write_descriptor_request"); }
@@ -2143,7 +2239,7 @@ class BluetoothGATTWriteDescriptorRequest final : public ProtoDecodableMessage {
};
class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 78;
+ static constexpr uint16_t MESSAGE_TYPE = 78;
static constexpr uint8_t ESTIMATED_SIZE = 10;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_notify_request"); }
@@ -2160,7 +2256,7 @@ class BluetoothGATTNotifyRequest final : public ProtoDecodableMessage {
};
class BluetoothGATTNotifyDataResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 79;
+ static constexpr uint16_t MESSAGE_TYPE = 79;
static constexpr uint8_t ESTIMATED_SIZE = 27;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_notify_data_response"); }
@@ -2183,7 +2279,7 @@ class BluetoothGATTNotifyDataResponse final : public ProtoMessage {
};
class BluetoothConnectionsFreeResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 81;
+ static constexpr uint16_t MESSAGE_TYPE = 81;
static constexpr uint8_t ESTIMATED_SIZE = 20;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_connections_free_response"); }
@@ -2201,7 +2297,7 @@ class BluetoothConnectionsFreeResponse final : public ProtoMessage {
};
class BluetoothGATTErrorResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 82;
+ static constexpr uint16_t MESSAGE_TYPE = 82;
static constexpr uint8_t ESTIMATED_SIZE = 12;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_error_response"); }
@@ -2219,7 +2315,7 @@ class BluetoothGATTErrorResponse final : public ProtoMessage {
};
class BluetoothGATTWriteResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 83;
+ static constexpr uint16_t MESSAGE_TYPE = 83;
static constexpr uint8_t ESTIMATED_SIZE = 8;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_write_response"); }
@@ -2236,7 +2332,7 @@ class BluetoothGATTWriteResponse final : public ProtoMessage {
};
class BluetoothGATTNotifyResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 84;
+ static constexpr uint16_t MESSAGE_TYPE = 84;
static constexpr uint8_t ESTIMATED_SIZE = 8;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_gatt_notify_response"); }
@@ -2253,7 +2349,7 @@ class BluetoothGATTNotifyResponse final : public ProtoMessage {
};
class BluetoothDevicePairingResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 85;
+ static constexpr uint16_t MESSAGE_TYPE = 85;
static constexpr uint8_t ESTIMATED_SIZE = 10;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_device_pairing_response"); }
@@ -2271,7 +2367,7 @@ class BluetoothDevicePairingResponse final : public ProtoMessage {
};
class BluetoothDeviceUnpairingResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 86;
+ static constexpr uint16_t MESSAGE_TYPE = 86;
static constexpr uint8_t ESTIMATED_SIZE = 10;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_device_unpairing_response"); }
@@ -2289,7 +2385,7 @@ class BluetoothDeviceUnpairingResponse final : public ProtoMessage {
};
class BluetoothDeviceClearCacheResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 88;
+ static constexpr uint16_t MESSAGE_TYPE = 88;
static constexpr uint8_t ESTIMATED_SIZE = 10;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_device_clear_cache_response"); }
@@ -2305,9 +2401,11 @@ class BluetoothDeviceClearCacheResponse final : public ProtoMessage {
protected:
};
+#endif
+#ifdef USE_BLUETOOTH_PROXY
class BluetoothScannerStateResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 126;
+ static constexpr uint16_t MESSAGE_TYPE = 126;
static constexpr uint8_t ESTIMATED_SIZE = 6;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_scanner_state_response"); }
@@ -2325,7 +2423,7 @@ class BluetoothScannerStateResponse final : public ProtoMessage {
};
class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 127;
+ static constexpr uint16_t MESSAGE_TYPE = 127;
static constexpr uint8_t ESTIMATED_SIZE = 2;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_scanner_set_mode_request"); }
@@ -2342,7 +2440,7 @@ class BluetoothScannerSetModeRequest final : public ProtoDecodableMessage {
#ifdef USE_VOICE_ASSISTANT
class SubscribeVoiceAssistantRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 89;
+ static constexpr uint16_t MESSAGE_TYPE = 89;
static constexpr uint8_t ESTIMATED_SIZE = 6;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("subscribe_voice_assistant_request"); }
@@ -2371,7 +2469,7 @@ class VoiceAssistantAudioSettings final : public ProtoMessage {
};
class VoiceAssistantRequest final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 90;
+ static constexpr uint16_t MESSAGE_TYPE = 90;
static constexpr uint8_t ESTIMATED_SIZE = 41;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_request"); }
@@ -2391,7 +2489,7 @@ class VoiceAssistantRequest final : public ProtoMessage {
};
class VoiceAssistantResponse final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 91;
+ static constexpr uint16_t MESSAGE_TYPE = 91;
static constexpr uint8_t ESTIMATED_SIZE = 6;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_response"); }
@@ -2418,7 +2516,7 @@ class VoiceAssistantEventData final : public ProtoDecodableMessage {
};
class VoiceAssistantEventResponse final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 92;
+ static constexpr uint16_t MESSAGE_TYPE = 92;
static constexpr uint8_t ESTIMATED_SIZE = 36;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_event_response"); }
@@ -2435,7 +2533,7 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage {
};
class VoiceAssistantAudio final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 106;
+ static constexpr uint16_t MESSAGE_TYPE = 106;
static constexpr uint8_t ESTIMATED_SIZE = 40;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_audio"); }
@@ -2457,7 +2555,7 @@ class VoiceAssistantAudio final : public ProtoDecodableMessage {
};
class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 115;
+ static constexpr uint16_t MESSAGE_TYPE = 115;
static constexpr uint8_t ESTIMATED_SIZE = 30;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_timer_event_response"); }
@@ -2478,7 +2576,7 @@ class VoiceAssistantTimerEventResponse final : public ProtoDecodableMessage {
};
class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 119;
+ static constexpr uint16_t MESSAGE_TYPE = 119;
static constexpr uint8_t ESTIMATED_SIZE = 29;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_announce_request"); }
@@ -2497,7 +2595,7 @@ class VoiceAssistantAnnounceRequest final : public ProtoDecodableMessage {
};
class VoiceAssistantAnnounceFinished final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 120;
+ static constexpr uint16_t MESSAGE_TYPE = 120;
static constexpr uint8_t ESTIMATED_SIZE = 2;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_announce_finished"); }
@@ -2543,7 +2641,7 @@ class VoiceAssistantExternalWakeWord final : public ProtoDecodableMessage {
};
class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 121;
+ static constexpr uint16_t MESSAGE_TYPE = 121;
static constexpr uint8_t ESTIMATED_SIZE = 34;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_configuration_request"); }
@@ -2558,7 +2656,7 @@ class VoiceAssistantConfigurationRequest final : public ProtoDecodableMessage {
};
class VoiceAssistantConfigurationResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 122;
+ static constexpr uint16_t MESSAGE_TYPE = 122;
static constexpr uint8_t ESTIMATED_SIZE = 56;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_configuration_response"); }
@@ -2576,7 +2674,7 @@ class VoiceAssistantConfigurationResponse final : public ProtoMessage {
};
class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 123;
+ static constexpr uint16_t MESSAGE_TYPE = 123;
static constexpr uint8_t ESTIMATED_SIZE = 18;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_set_configuration"); }
@@ -2593,7 +2691,7 @@ class VoiceAssistantSetConfiguration final : public ProtoDecodableMessage {
#ifdef USE_ALARM_CONTROL_PANEL
class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 94;
+ static constexpr uint16_t MESSAGE_TYPE = 94;
static constexpr uint8_t ESTIMATED_SIZE = 48;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_alarm_control_panel_response"); }
@@ -2611,7 +2709,7 @@ class ListEntitiesAlarmControlPanelResponse final : public InfoResponseProtoMess
};
class AlarmControlPanelStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 95;
+ static constexpr uint16_t MESSAGE_TYPE = 95;
static constexpr uint8_t ESTIMATED_SIZE = 11;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("alarm_control_panel_state_response"); }
@@ -2627,7 +2725,7 @@ class AlarmControlPanelStateResponse final : public StateResponseProtoMessage {
};
class AlarmControlPanelCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 96;
+ static constexpr uint16_t MESSAGE_TYPE = 96;
static constexpr uint8_t ESTIMATED_SIZE = 20;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("alarm_control_panel_command_request"); }
@@ -2647,7 +2745,7 @@ class AlarmControlPanelCommandRequest final : public CommandProtoMessage {
#ifdef USE_TEXT
class ListEntitiesTextResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 97;
+ static constexpr uint16_t MESSAGE_TYPE = 97;
static constexpr uint8_t ESTIMATED_SIZE = 59;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_text_response"); }
@@ -2666,7 +2764,7 @@ class ListEntitiesTextResponse final : public InfoResponseProtoMessage {
};
class TextStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 98;
+ static constexpr uint16_t MESSAGE_TYPE = 98;
static constexpr uint8_t ESTIMATED_SIZE = 20;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("text_state_response"); }
@@ -2683,7 +2781,7 @@ class TextStateResponse final : public StateResponseProtoMessage {
};
class TextCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 99;
+ static constexpr uint16_t MESSAGE_TYPE = 99;
static constexpr uint8_t ESTIMATED_SIZE = 18;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("text_command_request"); }
@@ -2702,7 +2800,7 @@ class TextCommandRequest final : public CommandProtoMessage {
#ifdef USE_DATETIME_DATE
class ListEntitiesDateResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 100;
+ static constexpr uint16_t MESSAGE_TYPE = 100;
static constexpr uint8_t ESTIMATED_SIZE = 40;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_date_response"); }
@@ -2717,7 +2815,7 @@ class ListEntitiesDateResponse final : public InfoResponseProtoMessage {
};
class DateStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 101;
+ static constexpr uint16_t MESSAGE_TYPE = 101;
static constexpr uint8_t ESTIMATED_SIZE = 23;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("date_state_response"); }
@@ -2736,7 +2834,7 @@ class DateStateResponse final : public StateResponseProtoMessage {
};
class DateCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 102;
+ static constexpr uint16_t MESSAGE_TYPE = 102;
static constexpr uint8_t ESTIMATED_SIZE = 21;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("date_command_request"); }
@@ -2756,7 +2854,7 @@ class DateCommandRequest final : public CommandProtoMessage {
#ifdef USE_DATETIME_TIME
class ListEntitiesTimeResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 103;
+ static constexpr uint16_t MESSAGE_TYPE = 103;
static constexpr uint8_t ESTIMATED_SIZE = 40;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_time_response"); }
@@ -2771,7 +2869,7 @@ class ListEntitiesTimeResponse final : public InfoResponseProtoMessage {
};
class TimeStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 104;
+ static constexpr uint16_t MESSAGE_TYPE = 104;
static constexpr uint8_t ESTIMATED_SIZE = 23;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("time_state_response"); }
@@ -2790,7 +2888,7 @@ class TimeStateResponse final : public StateResponseProtoMessage {
};
class TimeCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 105;
+ static constexpr uint16_t MESSAGE_TYPE = 105;
static constexpr uint8_t ESTIMATED_SIZE = 21;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("time_command_request"); }
@@ -2810,7 +2908,7 @@ class TimeCommandRequest final : public CommandProtoMessage {
#ifdef USE_EVENT
class ListEntitiesEventResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 107;
+ static constexpr uint16_t MESSAGE_TYPE = 107;
static constexpr uint8_t ESTIMATED_SIZE = 67;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_event_response"); }
@@ -2827,7 +2925,7 @@ class ListEntitiesEventResponse final : public InfoResponseProtoMessage {
};
class EventResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 108;
+ static constexpr uint16_t MESSAGE_TYPE = 108;
static constexpr uint8_t ESTIMATED_SIZE = 18;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("event_response"); }
@@ -2845,7 +2943,7 @@ class EventResponse final : public StateResponseProtoMessage {
#ifdef USE_VALVE
class ListEntitiesValveResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 109;
+ static constexpr uint16_t MESSAGE_TYPE = 109;
static constexpr uint8_t ESTIMATED_SIZE = 55;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_valve_response"); }
@@ -2864,7 +2962,7 @@ class ListEntitiesValveResponse final : public InfoResponseProtoMessage {
};
class ValveStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 110;
+ static constexpr uint16_t MESSAGE_TYPE = 110;
static constexpr uint8_t ESTIMATED_SIZE = 16;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("valve_state_response"); }
@@ -2881,7 +2979,7 @@ class ValveStateResponse final : public StateResponseProtoMessage {
};
class ValveCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 111;
+ static constexpr uint16_t MESSAGE_TYPE = 111;
static constexpr uint8_t ESTIMATED_SIZE = 18;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("valve_command_request"); }
@@ -2901,7 +2999,7 @@ class ValveCommandRequest final : public CommandProtoMessage {
#ifdef USE_DATETIME_DATETIME
class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 112;
+ static constexpr uint16_t MESSAGE_TYPE = 112;
static constexpr uint8_t ESTIMATED_SIZE = 40;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_date_time_response"); }
@@ -2916,7 +3014,7 @@ class ListEntitiesDateTimeResponse final : public InfoResponseProtoMessage {
};
class DateTimeStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 113;
+ static constexpr uint16_t MESSAGE_TYPE = 113;
static constexpr uint8_t ESTIMATED_SIZE = 16;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("date_time_state_response"); }
@@ -2933,7 +3031,7 @@ class DateTimeStateResponse final : public StateResponseProtoMessage {
};
class DateTimeCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 114;
+ static constexpr uint16_t MESSAGE_TYPE = 114;
static constexpr uint8_t ESTIMATED_SIZE = 14;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("date_time_command_request"); }
@@ -2951,7 +3049,7 @@ class DateTimeCommandRequest final : public CommandProtoMessage {
#ifdef USE_UPDATE
class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 116;
+ static constexpr uint16_t MESSAGE_TYPE = 116;
static constexpr uint8_t ESTIMATED_SIZE = 49;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_update_response"); }
@@ -2967,7 +3065,7 @@ class ListEntitiesUpdateResponse final : public InfoResponseProtoMessage {
};
class UpdateStateResponse final : public StateResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 117;
+ static constexpr uint16_t MESSAGE_TYPE = 117;
static constexpr uint8_t ESTIMATED_SIZE = 65;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("update_state_response"); }
@@ -2991,7 +3089,7 @@ class UpdateStateResponse final : public StateResponseProtoMessage {
};
class UpdateCommandRequest final : public CommandProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 118;
+ static constexpr uint16_t MESSAGE_TYPE = 118;
static constexpr uint8_t ESTIMATED_SIZE = 11;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("update_command_request"); }
@@ -3009,7 +3107,7 @@ class UpdateCommandRequest final : public CommandProtoMessage {
#ifdef USE_ZWAVE_PROXY
class ZWaveProxyFrame final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 128;
+ static constexpr uint16_t MESSAGE_TYPE = 128;
static constexpr uint8_t ESTIMATED_SIZE = 19;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("z_wave_proxy_frame"); }
@@ -3027,7 +3125,7 @@ class ZWaveProxyFrame final : public ProtoDecodableMessage {
};
class ZWaveProxyRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 129;
+ static constexpr uint16_t MESSAGE_TYPE = 129;
static constexpr uint8_t ESTIMATED_SIZE = 21;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("z_wave_proxy_request"); }
@@ -3045,11 +3143,28 @@ class ZWaveProxyRequest final : public ProtoDecodableMessage {
bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override;
bool decode_varint(uint32_t field_id, proto_varint_value_t value) override;
};
+class ZWaveProxyRequestResponse final : public ProtoMessage {
+ public:
+ static constexpr uint16_t MESSAGE_TYPE = 151;
+ static constexpr uint8_t ESTIMATED_SIZE = 4;
+#ifdef HAS_PROTO_MESSAGE_DUMP
+ const LogString *message_name() const override { return LOG_STR("z_wave_proxy_request_response"); }
+#endif
+ enums::ZWaveProxyRequestType type{};
+ enums::ZWaveProxyStatus status{};
+ uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
+ uint32_t calculate_size() const;
+#ifdef HAS_PROTO_MESSAGE_DUMP
+ const char *dump_to(DumpBuffer &out) const override;
+#endif
+
+ protected:
+};
#endif
#ifdef USE_INFRARED
class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 135;
+ static constexpr uint16_t MESSAGE_TYPE = 135;
static constexpr uint8_t ESTIMATED_SIZE = 48;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_infrared_response"); }
@@ -3068,7 +3183,7 @@ class ListEntitiesInfraredResponse final : public InfoResponseProtoMessage {
#if defined(USE_IR_RF) || defined(USE_RADIO_FREQUENCY)
class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 136;
+ static constexpr uint16_t MESSAGE_TYPE = 136;
static constexpr uint8_t ESTIMATED_SIZE = 224;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("infrared_rf_transmit_raw_timings_request"); }
@@ -3094,7 +3209,7 @@ class InfraredRFTransmitRawTimingsRequest final : public ProtoDecodableMessage {
};
class InfraredRFReceiveEvent final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 137;
+ static constexpr uint16_t MESSAGE_TYPE = 137;
static constexpr uint8_t ESTIMATED_SIZE = 17;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("infrared_rf_receive_event"); }
@@ -3116,7 +3231,7 @@ class InfraredRFReceiveEvent final : public ProtoMessage {
#ifdef USE_RADIO_FREQUENCY
class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 148;
+ static constexpr uint16_t MESSAGE_TYPE = 148;
static constexpr uint8_t ESTIMATED_SIZE = 56;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("list_entities_radio_frequency_response"); }
@@ -3137,7 +3252,7 @@ class ListEntitiesRadioFrequencyResponse final : public InfoResponseProtoMessage
#ifdef USE_SERIAL_PROXY
class SerialProxyConfigureRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 138;
+ static constexpr uint16_t MESSAGE_TYPE = 138;
static constexpr uint8_t ESTIMATED_SIZE = 20;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_configure_request"); }
@@ -3157,7 +3272,7 @@ class SerialProxyConfigureRequest final : public ProtoDecodableMessage {
};
class SerialProxyDataReceived final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 139;
+ static constexpr uint16_t MESSAGE_TYPE = 139;
static constexpr uint8_t ESTIMATED_SIZE = 23;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_data_received"); }
@@ -3179,7 +3294,7 @@ class SerialProxyDataReceived final : public ProtoMessage {
};
class SerialProxyWriteRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 140;
+ static constexpr uint16_t MESSAGE_TYPE = 140;
static constexpr uint8_t ESTIMATED_SIZE = 23;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_write_request"); }
@@ -3197,7 +3312,7 @@ class SerialProxyWriteRequest final : public ProtoDecodableMessage {
};
class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 141;
+ static constexpr uint16_t MESSAGE_TYPE = 141;
static constexpr uint8_t ESTIMATED_SIZE = 8;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_set_modem_pins_request"); }
@@ -3213,7 +3328,7 @@ class SerialProxySetModemPinsRequest final : public ProtoDecodableMessage {
};
class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 142;
+ static constexpr uint16_t MESSAGE_TYPE = 142;
static constexpr uint8_t ESTIMATED_SIZE = 4;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_request"); }
@@ -3228,13 +3343,14 @@ class SerialProxyGetModemPinsRequest final : public ProtoDecodableMessage {
};
class SerialProxyGetModemPinsResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 143;
- static constexpr uint8_t ESTIMATED_SIZE = 8;
+ static constexpr uint16_t MESSAGE_TYPE = 143;
+ static constexpr uint8_t ESTIMATED_SIZE = 10;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_get_modem_pins_response"); }
#endif
uint32_t instance{0};
uint32_t line_states{0};
+ enums::SerialProxyStatus status{};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
@@ -3245,7 +3361,7 @@ class SerialProxyGetModemPinsResponse final : public ProtoMessage {
};
class SerialProxyRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 144;
+ static constexpr uint16_t MESSAGE_TYPE = 144;
static constexpr uint8_t ESTIMATED_SIZE = 6;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_request"); }
@@ -3261,7 +3377,7 @@ class SerialProxyRequest final : public ProtoDecodableMessage {
};
class SerialProxyRequestResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 147;
+ static constexpr uint16_t MESSAGE_TYPE = 147;
static constexpr uint8_t ESTIMATED_SIZE = 17;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("serial_proxy_request_response"); }
@@ -3279,10 +3395,10 @@ class SerialProxyRequestResponse final : public ProtoMessage {
protected:
};
#endif
-#ifdef USE_BLUETOOTH_PROXY
+#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 145;
+ static constexpr uint16_t MESSAGE_TYPE = 145;
static constexpr uint8_t ESTIMATED_SIZE = 20;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_set_connection_params_request"); }
@@ -3301,7 +3417,7 @@ class BluetoothSetConnectionParamsRequest final : public ProtoDecodableMessage {
};
class BluetoothSetConnectionParamsResponse final : public ProtoMessage {
public:
- static constexpr uint8_t MESSAGE_TYPE = 146;
+ static constexpr uint16_t MESSAGE_TYPE = 146;
static constexpr uint8_t ESTIMATED_SIZE = 8;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("bluetooth_set_connection_params_response"); }
diff --git a/esphome/components/api/api_pb2_defines.h b/esphome/components/api/api_pb2_defines.h
index 8ebd60fb5d..3603fac6d7 100644
--- a/esphome/components/api/api_pb2_defines.h
+++ b/esphome/components/api/api_pb2_defines.h
@@ -3,7 +3,7 @@
#pragma once
#include "esphome/core/defines.h"
-#ifdef USE_BLUETOOTH_PROXY
+#if defined(USE_BLUETOOTH_PROXY) || defined(USE_BLUETOOTH_PROXY_CONNECTIONS)
#ifndef USE_API_VARINT64
#define USE_API_VARINT64
#endif
diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp
index 850ad37bc9..846c0ad652 100644
--- a/esphome/components/api/api_pb2_dump.cpp
+++ b/esphome/components/api/api_pb2_dump.cpp
@@ -125,6 +125,16 @@ static void dump_bytes_field(DumpBuffer &out, const char *field_name, const uint
}
#pragma GCC diagnostic pop
+template<> const char *proto_enum_to_string(enums::DisconnectReason value) {
+ switch (value) {
+ case enums::DISCONNECT_REASON_UNSPECIFIED:
+ return ESPHOME_PSTR("DISCONNECT_REASON_UNSPECIFIED");
+ case enums::DISCONNECT_REASON_PROVISIONING_CLOSED:
+ return ESPHOME_PSTR("DISCONNECT_REASON_PROVISIONING_CLOSED");
+ default:
+ return ESPHOME_PSTR("UNKNOWN");
+ }
+}
template<> const char *proto_enum_to_string(enums::SerialProxyPortType value) {
switch (value) {
case enums::SERIAL_PROXY_PORT_TYPE_TTL:
@@ -574,7 +584,7 @@ template<> const char *proto_enum_to_string(enu
}
}
#endif
-#ifdef USE_BLUETOOTH_PROXY
+#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
template<>
const char *proto_enum_to_string(enums::BluetoothDeviceRequestType value) {
switch (value) {
@@ -596,6 +606,8 @@ const char *proto_enum_to_string(enums::Bluet
return ESPHOME_PSTR("UNKNOWN");
}
}
+#endif
+#ifdef USE_BLUETOOTH_PROXY
template<> const char *proto_enum_to_string(enums::BluetoothScannerState value) {
switch (value) {
case enums::BLUETOOTH_SCANNER_STATE_IDLE:
@@ -804,6 +816,18 @@ template<> const char *proto_enum_to_string(enums:
return ESPHOME_PSTR("UNKNOWN");
}
}
+template<> const char *proto_enum_to_string(enums::ZWaveProxyStatus value) {
+ switch (value) {
+ case enums::ZWAVE_PROXY_STATUS_OK:
+ return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_OK");
+ case enums::ZWAVE_PROXY_STATUS_IN_USE:
+ return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_IN_USE");
+ case enums::ZWAVE_PROXY_STATUS_NOT_SUPPORTED:
+ return ESPHOME_PSTR("ZWAVE_PROXY_STATUS_NOT_SUPPORTED");
+ default:
+ return ESPHOME_PSTR("UNKNOWN");
+ }
+}
#endif
#ifdef USE_SERIAL_PROXY
template<> const char *proto_enum_to_string(enums::SerialProxyParity value) {
@@ -826,6 +850,10 @@ template<> const char *proto_enum_to_string(enums
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_UNSUBSCRIBE");
case enums::SERIAL_PROXY_REQUEST_TYPE_FLUSH:
return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_FLUSH");
+ case enums::SERIAL_PROXY_REQUEST_TYPE_CONFIGURE:
+ return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_CONFIGURE");
+ case enums::SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS:
+ return ESPHOME_PSTR("SERIAL_PROXY_REQUEST_TYPE_SET_MODEM_PINS");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -842,6 +870,10 @@ template<> const char *proto_enum_to_string(enums::Ser
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_TIMEOUT");
case enums::SERIAL_PROXY_STATUS_NOT_SUPPORTED:
return ESPHOME_PSTR("SERIAL_PROXY_STATUS_NOT_SUPPORTED");
+ case enums::SERIAL_PROXY_STATUS_PORT_IN_USE:
+ return ESPHOME_PSTR("SERIAL_PROXY_STATUS_PORT_IN_USE");
+ case enums::SERIAL_PROXY_STATUS_INVALID_ARGUMENT:
+ return ESPHOME_PSTR("SERIAL_PROXY_STATUS_INVALID_ARGUMENT");
default:
return ESPHOME_PSTR("UNKNOWN");
}
@@ -864,7 +896,8 @@ const char *HelloResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
const char *DisconnectRequest::dump_to(DumpBuffer &out) const {
- out.append_p(ESPHOME_PSTR("DisconnectRequest {}"));
+ MessageDumpHelper helper(out, ESPHOME_PSTR("DisconnectRequest"));
+ dump_field(out, ESPHOME_PSTR("reason"), static_cast(this->reason));
return out.c_str();
}
const char *DisconnectResponse::dump_to(DumpBuffer &out) const {
@@ -901,6 +934,7 @@ const char *SerialProxyInfo::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyInfo"));
dump_field(out, ESPHOME_PSTR("name"), this->name);
dump_field(out, ESPHOME_PSTR("port_type"), static_cast(this->port_type));
+ dump_field(out, ESPHOME_PSTR("configured_line_states"), this->configured_line_states);
return out.c_str();
}
#endif
@@ -965,6 +999,58 @@ const char *DeviceInfoResponse::dump_to(DumpBuffer &out) const {
#ifdef USE_ZWAVE_PROXY
dump_field(out, ESPHOME_PSTR("zwave_home_id"), this->zwave_home_id);
#endif
+#ifdef USE_SERIAL_PROXY
+ for (const auto &it : this->serial_proxies) {
+ out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": ");
+ it.dump_to(out);
+ out.append("\n");
+ }
+#endif
+#ifdef USE_API_NOISE
+ dump_field(out, ESPHOME_PSTR("api_encryption_provisionable"), this->api_encryption_provisionable);
+#endif
+ return out.c_str();
+}
+#ifdef USE_BLUETOOTH_PROXY
+const char *BluetoothProxyCapabilities::dump_to(DumpBuffer &out) const {
+ MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothProxyCapabilities"));
+ dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
+ dump_field(out, ESPHOME_PSTR("mac_address"), this->mac_address);
+ return out.c_str();
+}
+#endif
+#ifdef USE_VOICE_ASSISTANT
+const char *VoiceAssistantCapabilities::dump_to(DumpBuffer &out) const {
+ MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantCapabilities"));
+ dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
+ return out.c_str();
+}
+#endif
+#ifdef USE_ZWAVE_PROXY
+const char *ZWaveProxyCapabilities::dump_to(DumpBuffer &out) const {
+ MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyCapabilities"));
+ dump_field(out, ESPHOME_PSTR("feature_flags"), this->feature_flags);
+ dump_field(out, ESPHOME_PSTR("home_id"), this->home_id);
+ return out.c_str();
+}
+#endif
+const char *DeviceCapabilitiesResponse::dump_to(DumpBuffer &out) const {
+ MessageDumpHelper helper(out, ESPHOME_PSTR("DeviceCapabilitiesResponse"));
+#ifdef USE_BLUETOOTH_PROXY
+ out.append(2, ' ').append_p(ESPHOME_PSTR("bluetooth_proxy")).append(": ");
+ this->bluetooth_proxy.dump_to(out);
+ out.append("\n");
+#endif
+#ifdef USE_VOICE_ASSISTANT
+ out.append(2, ' ').append_p(ESPHOME_PSTR("voice_assistant")).append(": ");
+ this->voice_assistant.dump_to(out);
+ out.append("\n");
+#endif
+#ifdef USE_ZWAVE_PROXY
+ out.append(2, ' ').append_p(ESPHOME_PSTR("zwave_proxy")).append(": ");
+ this->zwave_proxy.dump_to(out);
+ out.append("\n");
+#endif
#ifdef USE_SERIAL_PROXY
for (const auto &it : this->serial_proxies) {
out.append(4, ' ').append_p(ESPHOME_PSTR("serial_proxies")).append(": ");
@@ -1403,7 +1489,7 @@ const char *ParsedTimezone::dump_to(DumpBuffer &out) const {
const char *GetTimeResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("GetTimeResponse"));
dump_field(out, ESPHOME_PSTR("epoch_seconds"), this->epoch_seconds);
- dump_field(out, ESPHOME_PSTR("timezone"), this->timezone);
+ dump_field(out, ESPHOME_PSTR("has_parsed_timezone"), this->has_parsed_timezone);
out.append(2, ' ').append_p(ESPHOME_PSTR("parsed_timezone")).append(": ");
this->parsed_timezone.dump_to(out);
out.append("\n");
@@ -1876,7 +1962,6 @@ const char *ListEntitiesMediaPlayerResponse::dump_to(DumpBuffer &out) const {
#endif
dump_field(out, ESPHOME_PSTR("disabled_by_default"), this->disabled_by_default);
dump_field(out, ESPHOME_PSTR("entity_category"), static_cast(this->entity_category));
- dump_field(out, ESPHOME_PSTR("supports_pause"), this->supports_pause);
for (const auto &it : this->supported_formats) {
out.append(4, ' ').append_p(ESPHOME_PSTR("supported_formats")).append(": ");
it.dump_to(out);
@@ -1939,6 +2024,8 @@ const char *BluetoothLERawAdvertisementsResponse::dump_to(DumpBuffer &out) const
}
return out.c_str();
}
+#endif
+#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothDeviceRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothDeviceRequest"));
dump_field(out, ESPHOME_PSTR("address"), this->address);
@@ -2110,6 +2197,8 @@ const char *BluetoothDeviceClearCacheResponse::dump_to(DumpBuffer &out) const {
dump_field(out, ESPHOME_PSTR("error"), this->error);
return out.c_str();
}
+#endif
+#ifdef USE_BLUETOOTH_PROXY
const char *BluetoothScannerStateResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothScannerStateResponse"));
dump_field(out, ESPHOME_PSTR("state"), static_cast(this->state));
@@ -2575,6 +2664,12 @@ const char *ZWaveProxyRequest::dump_to(DumpBuffer &out) const {
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
return out.c_str();
}
+const char *ZWaveProxyRequestResponse::dump_to(DumpBuffer &out) const {
+ MessageDumpHelper helper(out, ESPHOME_PSTR("ZWaveProxyRequestResponse"));
+ dump_field(out, ESPHOME_PSTR("type"), static_cast(this->type));
+ dump_field(out, ESPHOME_PSTR("status"), static_cast(this->status));
+ return out.c_str();
+}
#endif
#ifdef USE_INFRARED
const char *ListEntitiesInfraredResponse::dump_to(DumpBuffer &out) const {
@@ -2684,6 +2779,7 @@ const char *SerialProxyGetModemPinsResponse::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("SerialProxyGetModemPinsResponse"));
dump_field(out, ESPHOME_PSTR("instance"), this->instance);
dump_field(out, ESPHOME_PSTR("line_states"), this->line_states);
+ dump_field(out, ESPHOME_PSTR("status"), static_cast(this->status));
return out.c_str();
}
const char *SerialProxyRequest::dump_to(DumpBuffer &out) const {
@@ -2701,7 +2797,7 @@ const char *SerialProxyRequestResponse::dump_to(DumpBuffer &out) const {
return out.c_str();
}
#endif
-#ifdef USE_BLUETOOTH_PROXY
+#ifdef USE_BLUETOOTH_PROXY_CONNECTIONS
const char *BluetoothSetConnectionParamsRequest::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("BluetoothSetConnectionParamsRequest"));
dump_field(out, ESPHOME_PSTR("address"), this->address);
diff --git a/esphome/components/api/api_pb2_includes.h b/esphome/components/api/api_pb2_includes.h
index f45e091c6f..70ba579fcc 100644
--- a/esphome/components/api/api_pb2_includes.h
+++ b/esphome/components/api/api_pb2_includes.h
@@ -31,6 +31,13 @@
#include