Compare commits

..
Author SHA1 Message Date
J. Nick Koston 3a4f67def8 [core] Fix delay on failed component being dropped; DRY the is_failed check
The is_failed() skip exists in two execution paths: the heap loop in call()
and should_skip_item_() (defer queue / delay:0). The previous commit only
exempted SELF_POINTER items from the heap-path check, so on multi-threaded
platforms a delay:0 continuation whose host component had failed would still
be silently dropped.

Extract a single is_item_failed_() helper (with the SELF_POINTER exemption)
and use it from both paths so they cannot drift again.

Add an integration test that schedules a delay from a component that marks
itself failed and asserts the continuation still fires (verified to fail
without the exemption).
2026-06-02 13:54:17 -05:00
J. Nick Koston 5b728f19c3 [core] Attribute "took a long time" blocking warning to its source
A blocking operation that runs inside a deferred scheduler continuation
(e.g. after a delay in a script/automation) was reported as:

    <null> took a long time for an operation (83 ms), max is 30 ms

Two problems:

* The DelayAction continuation carries no component (since #16129 dropped
  Component inheritance), so the warning had nothing to name and printed
  "<null>". Telling the user an anonymous delay action is blocking is not
  useful; naming the component that hosts the automation is.
* The threshold was hardcoded to "30 ms" but the real default is 50 ms
  (WARN_IF_BLOCKING_OVER_CS) and is adaptive per component.

DelayAction now records App.get_current_component() on the scheduler item,
so the warning names the component whose automation chain hit the delay
(falling back to "a scheduled task" when there is genuinely no current
component). This propagates across chained delays because the scheduler
restores the item's component as the current component before each callback.

For SELF_POINTER items the stored component is log-attribution only: the
key (the caller's `this`) is globally unique, so matches_item_locked_
ignores the component when matching and the is_failed() skip is bypassed.
This keeps delay cancellation (restart/parallel/stop) and always-fire
semantics unchanged.

The warning now reports the real (pre-ratchet) threshold instead of the
stale "30 ms".

Adds an integration test reproducing the deferred-block path via an
interval + delay + busy lambda and asserting the warning names a component
and reports "max is 50 ms".
2026-06-02 13:29:27 -05:00
2256 changed files with 25134 additions and 59311 deletions
+1
View File
@@ -0,0 +1 @@
27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f
+1 -1
View File
@@ -1,4 +1,4 @@
ARG BUILD_BASE_VERSION=2026.06.1 ARG BUILD_BASE_VERSION=2025.04.0
FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base
+2 -5
View File
@@ -15,6 +15,7 @@
// uncomment and edit the path in order to pass through local USB serial to the container // uncomment and edit the path in order to pass through local USB serial to the container
// , "--device=/dev/ttyACM0" // , "--device=/dev/ttyACM0"
], ],
"appPort": 6052,
// if you are using avahi in the host device, uncomment these to allow the // if you are using avahi in the host device, uncomment these to allow the
// devcontainer to find devices via mdns // devcontainer to find devices via mdns
//"mounts": [ //"mounts": [
@@ -40,11 +41,7 @@
], ],
"settings": { "settings": {
"python.languageServer": "Pylance", "python.languageServer": "Pylance",
// Use the container's pre-provisioned venv (built by the Dockerfile, outside the "python.pythonPath": "/usr/bin/python3",
// 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": [ "pylint.args": [
"--rcfile=${workspaceFolder}/pyproject.toml" "--rcfile=${workspaceFolder}/pyproject.toml"
], ],
-2
View File
@@ -1,5 +1,3 @@
# Normalize line endings to LF in the repository # Normalize line endings to LF in the repository
* text eol=lf * text eol=lf
*.png binary *.png binary
*.gif binary
*.apng binary
+9 -2
View File
@@ -15,6 +15,11 @@ inputs:
description: "Version to build" description: "Version to build"
required: true required: true
example: "2023.12.0" example: "2023.12.0"
base_os:
description: "Base OS to use"
required: false
default: "debian"
example: "debian"
runs: runs:
using: "composite" using: "composite"
steps: steps:
@@ -42,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest - name: Build and push to ghcr by digest
id: build-ghcr id: build-ghcr
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
env: env:
DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false DOCKER_BUILD_RECORD_UPLOAD: false
@@ -55,6 +60,7 @@ runs:
build-args: | build-args: |
BUILD_TYPE=${{ inputs.build_type }} BUILD_TYPE=${{ inputs.build_type }}
BUILD_VERSION=${{ inputs.version }} BUILD_VERSION=${{ inputs.version }}
BUILD_OS=${{ inputs.base_os }}
outputs: | outputs: |
type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
@@ -67,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest - name: Build and push to dockerhub by digest
id: build-dockerhub id: build-dockerhub
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
env: env:
DOCKER_BUILD_SUMMARY: false DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false DOCKER_BUILD_RECORD_UPLOAD: false
@@ -80,6 +86,7 @@ runs:
build-args: | build-args: |
BUILD_TYPE=${{ inputs.build_type }} BUILD_TYPE=${{ inputs.build_type }}
BUILD_VERSION=${{ inputs.version }} BUILD_VERSION=${{ inputs.version }}
BUILD_OS=${{ inputs.base_os }}
outputs: | outputs: |
type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
-52
View File
@@ -1,52 +0,0 @@
name: Cache ESP-IDF
description: >
Resolve the pinned ESP-IDF version and cache the native ESP-IDF install
(toolchains + source) at ~/.esphome-idf. Every job that installs ESP-IDF
natively (clang-tidy for IDF/Arduino and the component test batches) shares
one cache, since the install is identical (ESPHOME_IDF_DEFAULT_TARGETS
defaults to "all", so all toolchains are present regardless of the chip).
Callers must set env ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf and have the
Python venv already restored.
inputs:
framework:
description: 'Which pinned IDF version to key on: "espidf" (recommended) or "arduino".'
default: espidf
restore-only:
description: >
When "true", only restore -- never save the cache, even on dev. Use from
jobs that may not produce an ESP-IDF install (e.g. a component batch with
no esp32 target), so a partial/empty install is never written to the key.
default: "false"
runs:
using: composite
steps:
- name: Resolve ESP-IDF version for cache key
# The native-IDF version is pinned in code, not in any file that feeds the
# other cache keys, so resolve it explicitly. Keying on it means the cache
# invalidates on a version bump (actions/cache never overwrites a key).
id: version
shell: bash
run: |
. venv/bin/activate
if [ "${{ inputs.framework }}" = "arduino" ]; then
version=$(python -c 'from esphome.components.esp32 import ARDUINO_FRAMEWORK_VERSION_LOOKUP as A, ARDUINO_IDF_VERSION_LOOKUP as L; print(L[A["recommended"]])')
else
version=$(python -c 'from esphome.components.esp32 import ESP_IDF_FRAMEWORK_VERSION_LOOKUP as L; print(L["recommended"])')
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
# Mirror the adjacent PlatformIO cache: only dev-branch runs write the
# shared cache (so it lives in the default-branch scope readable by all
# PRs), and PRs are restore-only -- they never push multi-GB artifacts into
# their own scope / the repo quota (e.g. on a version-bump PR).
- name: Cache ESP-IDF install (write on dev)
if: github.ref == 'refs/heads/dev' && inputs.restore-only != 'true'
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.esphome-idf
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
- name: Cache ESP-IDF install (restore-only off dev)
if: github.ref != 'refs/heads/dev' || inputs.restore-only == 'true'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.esphome-idf
key: ${{ runner.os }}-esphome-idf-${{ steps.version.outputs.version }}
-49
View File
@@ -1,49 +0,0 @@
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') }}
+3 -6
View File
@@ -17,12 +17,12 @@ runs:
steps: steps:
- name: Set up Python ${{ inputs.python-version }} - name: Set up Python ${{ inputs.python-version }}
id: python id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: ${{ inputs.python-version }} python-version: ${{ inputs.python-version }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: venv path: venv
# yamllint disable-line rule:line-length # yamllint disable-line rule:line-length
@@ -32,12 +32,9 @@ runs:
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
# downstream jobs rely on is preserved. # downstream jobs rely on is preserved.
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
enable-cache: true 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 # Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache # manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners. # miss; that fetch flakes on Windows runners.
+12 -19
View File
@@ -147,9 +147,19 @@ async function detectCoreChanges(changedFiles) {
} }
// Strategy: PR size detection // Strategy: PR size detection
async function detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) { async function detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD) {
const labels = new Set(); 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 const testAdditions = prFiles
.filter(file => file.filename.startsWith('tests/')) .filter(file => file.filename.startsWith('tests/'))
.reduce((sum, file) => sum + (file.additions || 0), 0); .reduce((sum, file) => sum + (file.additions || 0), 0);
@@ -157,24 +167,7 @@ async function detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, S
.filter(file => file.filename.startsWith('tests/')) .filter(file => file.filename.startsWith('tests/'))
.reduce((sum, file) => sum + (file.deletions || 0), 0); .reduce((sum, file) => sum + (file.deletions || 0), 0);
const nonTestAdditions = totalAdditions - testAdditions; const nonTestChanges = (totalAdditions - testAdditions) - (totalDeletions - testDeletions);
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 // Don't add too-big if mega-pr label is already present
if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) { if (nonTestChanges > TOO_BIG_THRESHOLD && !isMegaPR) {
+1 -1
View File
@@ -123,7 +123,7 @@ module.exports = async ({ github, context }) => {
detectNewComponents(github, context, prFiles), detectNewComponents(github, context, prFiles),
detectNewPlatforms(github, context, prFiles, apiData), detectNewPlatforms(github, context, prFiles, apiData),
detectCoreChanges(changedFiles), detectCoreChanges(changedFiles),
detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD), detectPRSize(prFiles, totalAdditions, totalDeletions, totalChanges, isMegaPR, SMALL_PR_THRESHOLD, MEDIUM_PR_THRESHOLD, TOO_BIG_THRESHOLD),
detectDashboardChanges(changedFiles), detectDashboardChanges(changedFiles),
detectGitHubActionsChanges(changedFiles), detectGitHubActionsChanges(changedFiles),
detectCodeOwner(github, context, changedFiles), detectCodeOwner(github, context, changedFiles),
@@ -1,6 +1,6 @@
const { describe, it } = require('node:test'); const { describe, it } = require('node:test');
const assert = require('node:assert/strict'); const assert = require('node:assert/strict');
const { detectNewPlatforms, detectNewComponents, detectPRSize } = require('../detectors'); const { detectNewPlatforms, detectNewComponents } = require('../detectors');
// Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents // Minimal GitHub API mock — only repos.getContent is called by detectNewPlatforms/detectNewComponents
// to check for CONFIG_SCHEMA in newly added files. // to check for CONFIG_SCHEMA in newly added files.
@@ -145,79 +145,3 @@ describe('detectNewComponents', () => {
assert.equal(result.labels.size, 0); assert.equal(result.labels.size, 0);
}); });
}); });
// ---------------------------------------------------------------------------
// detectPRSize
// ---------------------------------------------------------------------------
describe('detectPRSize', () => {
const SMALL = 30;
const MEDIUM = 100;
const TOO_BIG = 1000;
function size(prFiles, isMegaPR = false) {
const totalAdditions = prFiles.reduce((sum, file) => sum + (file.additions || 0), 0);
const totalDeletions = prFiles.reduce((sum, file) => sum + (file.deletions || 0), 0);
return detectPRSize(prFiles, totalAdditions, totalDeletions, isMegaPR, SMALL, MEDIUM, TOO_BIG);
}
it('counts only non-test changes toward small-pr', async () => {
// 10 source + 5000 test lines -> non-test churn of 10 is still small.
const labels = await size([
{ filename: 'esphome/components/foo/foo.cpp', additions: 10, deletions: 0 },
{ filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 },
]);
assert.ok(labels.has('small-pr'));
assert.equal(labels.size, 1);
});
it('counts additions and deletions as churn (not net delta)', async () => {
// A balanced refactor (40 added, 40 removed) is 80 lines of churn -> medium, not small.
const labels = await size([
{ filename: 'esphome/components/foo/foo.cpp', additions: 40, deletions: 40 },
]);
assert.ok(labels.has('medium-pr'));
assert.equal(labels.size, 1);
});
it('labels medium-pr when non-test changes exceed small threshold', async () => {
const labels = await size([
{ filename: 'esphome/components/foo/foo.cpp', additions: 60, deletions: 0 },
{ filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 },
]);
assert.ok(labels.has('medium-pr'));
assert.equal(labels.size, 1);
});
it('uses net delta (not churn) for too-big', async () => {
// 600 added + 600 removed: 1200 churn (above too-big) but 0 net delta -> not too-big.
const labels = await size([
{ filename: 'esphome/components/foo/foo.cpp', additions: 600, deletions: 600 },
]);
assert.equal(labels.size, 0);
});
it('labels too-big when non-test changes exceed the big threshold', async () => {
const labels = await size([
{ filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 },
{ filename: 'tests/components/foo/test.esp32-idf.yaml', additions: 5000, deletions: 0 },
]);
assert.ok(labels.has('too-big'));
assert.equal(labels.size, 1);
});
it('does not label too-big when mega-pr is set', async () => {
const labels = await size([
{ filename: 'esphome/components/foo/foo.cpp', additions: 2000, deletions: 0 },
], true);
assert.equal(labels.size, 0);
});
it('produces no size label for a large mega-pr in the gap above medium', async () => {
// Non-test changes land between MEDIUM and TOO_BIG: not small/medium, and mega-pr suppresses too-big.
const labels = await size([
{ filename: 'esphome/components/foo/foo.cpp', additions: 500, deletions: 0 },
], true);
assert.equal(labels.size, 0);
});
});
+1
View File
@@ -41,6 +41,7 @@ function hasCoreChanges(changedFiles) {
*/ */
function hasDashboardChanges(changedFiles) { function hasDashboardChanges(changedFiles) {
return changedFiles.some(file => return changedFiles.some(file =>
file.startsWith('esphome/dashboard/') ||
file.startsWith('esphome/components/dashboard_import/') file.startsWith('esphome/components/dashboard_import/')
); );
} }
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot') if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate a token - name: Generate a token
id: generate-token id: generate-token
+4 -7
View File
@@ -21,20 +21,17 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python - name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.12" python-version: "3.11"
- name: Set up uv - name: Set up uv
# ``--system`` (below) installs into the setup-python interpreter; # ``--system`` (below) installs into the setup-python interpreter;
# no venv is created or restored by this workflow. # no venv is created or restored by this workflow.
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
enable-cache: true 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 # Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache # manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners. # miss; that fetch flakes on Windows runners.
+76
View File
@@ -0,0 +1,76 @@
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.'
});
}
}
+19 -173
View File
@@ -1,41 +1,28 @@
--- ---
name: CI for docker images name: CI for docker images
# Only run on PRs that touch the docker image, its build inputs, or any code # Only run when docker paths change
# whose toolchain the compile smoke test exercises (core + target platforms).
on: on:
push:
branches: [dev, beta, release]
paths:
- "docker/**"
- ".github/workflows/ci-docker.yml"
- "requirements*.txt"
- "platformio.ini"
- "script/platformio_install_deps.py"
pull_request: pull_request:
paths: paths:
# Docker image and its build inputs.
- "docker/**" - "docker/**"
- ".github/workflows/ci-docker.yml" - ".github/workflows/ci-docker.yml"
- "requirements*.txt" - "requirements*.txt"
- "pyproject.toml"
- "platformio.ini" - "platformio.ini"
- "esphome/idf_component.yml"
- "script/platformio_install_deps.py" - "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: permissions:
contents: read # actions/checkout only contents: read # actions/checkout only; the build does not push images
concurrency: concurrency:
# yamllint disable-line rule:line-length # yamllint disable-line rule:line-length
@@ -46,9 +33,6 @@ jobs:
check-docker: check-docker:
name: Build docker containers name: Build docker containers
runs-on: ${{ matrix.os }} 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: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -57,161 +41,23 @@ jobs:
- "ha-addon" - "ha-addon"
- "docker" - "docker"
# - "lint" # - "lint"
outputs:
tag: ${{ steps.tag.outputs.tag }}
push: ${{ steps.tag.outputs.push }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python - name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.12" python-version: "3.11"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Determine tag and whether to push - name: Set TAG
id: tag
run: | run: |
# Sanitize the branch name into a valid docker tag: replace invalid echo "TAG=check" >> $GITHUB_ENV
# characters, ensure the first character is valid (tags must start
# with [A-Za-z0-9_]), and cap the length at 128 characters.
branch="${{ github.head_ref || github.ref_name }}"
tag="${branch//[^a-zA-Z0-9_.-]/-}"
case "$tag" in
[a-zA-Z0-9_]*) ;;
*) tag="pr-${tag}" ;;
esac
tag="${tag:0:128}"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
# Only push branch images for same-repo pull requests. Push events
# only fire for dev/beta/release, whose images are owned by the
# release pipeline -- never overwrite those from here.
if [ "${{ github.event_name }}" = "pull_request" ] \
&& [ "${{ github.repository }}" = "esphome/esphome" ] \
&& [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then
echo "push=true" >> "$GITHUB_OUTPUT"
else
echo "push=false" >> "$GITHUB_OUTPUT"
fi
- name: Log in to the GitHub container registry
if: steps.tag.outputs.push == 'true'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run build - name: Run build
run: | run: |
docker/build.py \ docker/build.py \
--tag "${{ steps.tag.outputs.tag }}" \ --tag "${TAG}" \
--arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \ --arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \
--build-type "${{ matrix.build_type }}" \ --build-type "${{ matrix.build_type }}" \
--registry ghcr \ build
build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }} ${{ (matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker') && '--load' || '' }}
# The amd64 "docker" image is also loaded locally (above) and handed to
# compile-test as an artifact, so the smoke test reuses this build instead
# of building the image a second time. Using an artifact (rather than the
# pushed image) keeps it working for fork PRs, which never push to ghcr.io.
- name: Export image for compile-test
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
run: docker save "ghcr.io/esphome/esphome-amd64:${{ steps.tag.outputs.tag }}" | gzip > compile-test-image.tar.gz
- name: Upload compile-test image artifact
if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
# The tar is already gzipped, so upload it as-is. archive: false skips
# the redundant zip and makes the file name the artifact name (the
# `name` input is ignored in that mode).
path: compile-test-image.tar.gz
retention-days: 1
archive: false
manifest:
name: Push ${{ matrix.build_type }} manifest to ghcr.io
needs: [check-docker]
if: needs.check-docker.outputs.push == 'true'
runs-on: ubuntu-24.04
permissions:
contents: read # actions/checkout to run docker/build.py
packages: write # buildx imagetools writes the multi-arch tag to ghcr.io
strategy:
fail-fast: false
matrix:
build_type:
- "ha-addon"
- "docker"
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.12"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Log in to the GitHub container registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push manifest
run: |
docker/build.py \
--tag "${{ needs.check-docker.outputs.tag }}" \
--build-type "${{ matrix.build_type }}" \
--registry ghcr \
manifest
# Smoke-test the built image by compiling one minimal config per target
# platform / toolchain. This catches missing system dependencies in the image
# that only surface when a given toolchain is downloaded and run. The image is
# the amd64 "docker" build produced by check-docker (shared as an artifact).
compile-test:
name: Compile ${{ matrix.id }}
needs: check-docker
runs-on: ubuntu-24.04
permissions:
contents: read # actions/checkout to load the test configs
strategy:
fail-fast: false
# Cap concurrency so this smoke test doesn't hog all the shared runners.
max-parallel: 2
matrix:
# One entry per distinct toolchain. ESP32 variants (c3/c6/s2/s3/p4)
# share a toolchain bundle, so esp32 is exercised on the base variant
# across the full framework x toolchain cross-product (arduino/esp-idf
# framework, each built with the platformio and native esp-idf
# toolchains) so both toolchains stay covered regardless of which one is
# the default.
id:
- esp8266-arduino
- esp32-arduino-platformio
- esp32-arduino-esp-idf
- esp32-idf-platformio
- esp32-idf-esp-idf
- rp2040-arduino
- bk72xx-arduino
- rtl87xx-arduino
- ln882x-arduino
- nrf52
- host
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download image artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: compile-test-image.tar.gz
- name: Load image
run: docker load --input compile-test-image.tar.gz
- name: Compile ${{ matrix.id }}
run: |
docker run --rm \
-v "${{ github.workspace }}/docker/test_configs:/config" \
"ghcr.io/esphome/esphome-amd64:${{ needs.check-docker.outputs.tag }}" \
compile "${{ matrix.id }}.yaml"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run tests - name: Run tests
working-directory: .github/scripts/auto-label-pr working-directory: .github/scripts/auto-label-pr
@@ -49,7 +49,7 @@ jobs:
- name: Check out code from base repository - name: Check out code from base repository
if: steps.pr.outputs.skip != 'true' if: steps.pr.outputs.skip != 'true'
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
# Always check out from the base repository (esphome/esphome), never from forks # 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 # 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' if: steps.pr.outputs.skip != 'true'
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
with: with:
python-version: "3.12" python-version: "3.11"
cache-key: ${{ hashFiles('.cache-key') }} cache-key: ${{ hashFiles('.cache-key') }}
- name: Download memory analysis artifacts - name: Download memory analysis artifacts
+197 -319
View File
@@ -12,8 +12,8 @@ permissions:
contents: read # actions/checkout for all jobs; individual jobs add their own scopes when they need to write contents: read # actions/checkout for all jobs; individual jobs add their own scopes when they need to write
env: env:
DEFAULT_PYTHON: "3.12" DEFAULT_PYTHON: "3.11"
PYUPGRADE_TARGET: "--py312-plus" PYUPGRADE_TARGET: "--py311-plus"
concurrency: concurrency:
# yamllint disable-line rule:line-length # yamllint disable-line rule:line-length
@@ -28,18 +28,18 @@ jobs:
cache-key: ${{ steps.cache-key.outputs.key }} cache-key: ${{ steps.cache-key.outputs.key }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate cache-key - name: Generate cache-key
id: cache-key id: cache-key
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Set up Python ${{ env.DEFAULT_PYTHON }} - name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: venv path: venv
# yamllint disable-line rule:line-length # yamllint disable-line rule:line-length
@@ -49,12 +49,9 @@ jobs:
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
# that ``. venv/bin/activate`` see an identical layout. # that ``. venv/bin/activate`` see an identical layout.
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
enable-cache: true 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 # Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache # manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners. # miss; that fetch flakes on Windows runners.
@@ -77,7 +74,7 @@ jobs:
if: needs.determine-jobs.outputs.python-linters == 'true' if: needs.determine-jobs.outputs.python-linters == 'true'
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
with: with:
@@ -100,7 +97,7 @@ jobs:
if: needs.determine-jobs.outputs.core-ci == 'true' if: needs.determine-jobs.outputs.core-ci == 'true'
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
with: with:
@@ -115,8 +112,7 @@ jobs:
script/build_codeowners.py --check script/build_codeowners.py --check
script/build_language_schema.py --check script/build_language_schema.py --check
script/generate-esp32-boards.py --check script/generate-esp32-boards.py --check
script/generate-rp2-boards.py --check script/generate-rp2040-boards.py --check
script/ci_check_duplicate_test_ids.py
import-time: import-time:
name: Check import esphome.__main__ time name: Check import esphome.__main__ time
@@ -127,7 +123,7 @@ jobs:
if: needs.determine-jobs.outputs.import-time == 'true' if: needs.determine-jobs.outputs.import-time == 'true'
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
with: with:
@@ -155,17 +151,17 @@ jobs:
if: needs.determine-jobs.outputs.device-builder == 'true' if: needs.determine-jobs.outputs.device-builder == 'true'
steps: steps:
- name: Check out esphome (this PR) - name: Check out esphome (this PR)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
path: esphome path: esphome
- name: Check out esphome/device-builder - name: Check out esphome/device-builder
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
repository: esphome/device-builder repository: esphome/device-builder
ref: main ref: main
path: device-builder path: device-builder
- name: Set up Python - name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.13" python-version: "3.13"
- name: Set up uv - name: Set up uv
@@ -174,12 +170,9 @@ jobs:
# install step (order-of-magnitude faster on cold boots, # install step (order-of-magnitude faster on cold boots,
# with its own wheel cache). actions/setup-python still # with its own wheel cache). actions/setup-python still
# provides the interpreter. # provides the interpreter.
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
enable-cache: true 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 # Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache # manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners. # miss; that fetch flakes on Windows runners.
@@ -196,12 +189,9 @@ jobs:
- name: Run device-builder pytest - name: Run device-builder pytest
# ``-n auto`` runs under pytest-xdist (matches device-builder's # ``-n auto`` runs under pytest-xdist (matches device-builder's
# own CI). No ``--cov`` here -- this is purely a downstream # own CI). No ``--cov`` here -- this is purely a downstream
# smoke check against this PR's esphome code. ``tests/e2e/slow`` # smoke check against this PR's esphome code.
# 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 working-directory: device-builder
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks --ignore=tests/e2e/slow run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks
pytest: pytest:
name: Run pytest name: Run pytest
@@ -209,7 +199,7 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
python-version: python-version:
- "3.12" - "3.11"
- "3.13" - "3.13"
- "3.14" - "3.14"
os: os:
@@ -231,7 +221,7 @@ jobs:
if: needs.determine-jobs.outputs.core-ci == 'true' if: needs.determine-jobs.outputs.core-ci == 'true'
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python - name: Restore Python
id: restore-python id: restore-python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
@@ -251,12 +241,12 @@ jobs:
. venv/bin/activate . venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/ pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache - name: Save Python virtual environment cache
if: github.ref == 'refs/heads/dev' if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: venv path: venv
key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
@@ -276,8 +266,8 @@ jobs:
python-linters: ${{ steps.determine.outputs.python-linters }} python-linters: ${{ steps.determine.outputs.python-linters }}
import-time: ${{ steps.determine.outputs.import-time }} import-time: ${{ steps.determine.outputs.import-time }}
device-builder: ${{ steps.determine.outputs.device-builder }} device-builder: ${{ steps.determine.outputs.device-builder }}
esp32-platformio: ${{ steps.determine.outputs.esp32-platformio }} native-idf: ${{ steps.determine.outputs.native-idf }}
esp32-platformio-components: ${{ steps.determine.outputs.esp32-platformio-components }} native-idf-components: ${{ steps.determine.outputs.native-idf-components }}
changed-components: ${{ steps.determine.outputs.changed-components }} changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }} changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }} directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
@@ -291,7 +281,7 @@ jobs:
benchmarks: ${{ steps.determine.outputs.benchmarks }} benchmarks: ${{ steps.determine.outputs.benchmarks }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
# Fetch enough history to find the merge base # Fetch enough history to find the merge base
fetch-depth: 2 fetch-depth: 2
@@ -301,7 +291,7 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }} cache-key: ${{ needs.common.outputs.cache-key }}
- name: Restore components graph cache - name: Restore components graph cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: .temp/components_graph.json path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -330,8 +320,8 @@ jobs:
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $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 "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $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 "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT
echo "esp32-platformio-components=$(echo "$output" | jq -r '.esp32_platformio_components')" >> $GITHUB_OUTPUT echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $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 "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
@@ -345,7 +335,7 @@ jobs:
echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
- name: Save components graph cache - name: Save components graph cache
if: github.ref == 'refs/heads/dev' if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: .temp/components_graph.json path: .temp/components_graph.json
key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} key: components-graph-${{ hashFiles('esphome/components/**/*.py') }}
@@ -363,27 +353,24 @@ jobs:
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }} bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python 3.13 - name: Set up Python 3.13
id: python id: python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.13" python-version: "3.13"
- name: Restore Python virtual environment - name: Restore Python virtual environment
id: cache-venv id: cache-venv
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: venv path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
- name: Set up uv - name: Set up uv
# Only needed on cache miss to populate the venv. # Only needed on cache miss to populate the venv.
if: steps.cache-venv.outputs.cache-hit != 'true' if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
enable-cache: true 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 # Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache # manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners. # miss; that fetch flakes on Windows runners.
@@ -418,7 +405,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 != '[]') 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: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
@@ -447,7 +434,7 @@ jobs:
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true') (github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
@@ -465,7 +452,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks - name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@f99becdce5e5d51fd556489ebef684f4ecfd6286 # v4.18.5 uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0
with: with:
run: | run: |
. venv/bin/activate . venv/bin/activate
@@ -482,10 +469,6 @@ jobs:
if: needs.determine-jobs.outputs.clang-tidy == 'true' if: needs.determine-jobs.outputs.clang-tidy == 'true'
env: env:
GH_TOKEN: ${{ github.token }} 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: strategy:
fail-fast: false fail-fast: false
max-parallel: 2 max-parallel: 2
@@ -496,27 +479,18 @@ jobs:
options: --environment esp8266-arduino-tidy --grep USE_ESP8266 options: --environment esp8266-arduino-tidy --grep USE_ESP8266
pio_cache_key: tidyesp8266 pio_cache_key: tidyesp8266
- id: clang-tidy - id: clang-tidy
name: Run script/clang-tidy for ESP32 Arduino name: Run script/clang-tidy for ESP32 IDF
options: --environment esp32-arduino-tidy --grep USE_ARDUINO options: --environment esp32-idf-tidy --grep USE_ESP_IDF
cache_idf: true pio_cache_key: tidyesp32-idf
- id: clang-tidy - id: clang-tidy
name: Run script/clang-tidy for ZEPHYR name: Run script/clang-tidy for ZEPHYR
options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52 options: --environment nrf52-tidy --grep USE_ZEPHYR --grep USE_NRF52
cache_sdk_nrf: true pio_cache_key: tidy-zephyr
ignore_errors: false 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: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
# Need history for HEAD~1 to work for checking changed files # Need history for HEAD~1 to work for checking changed files
fetch-depth: 2 fetch-depth: 2
@@ -528,44 +502,44 @@ jobs:
cache-key: ${{ needs.common.outputs.cache-key }} cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio - name: Cache platformio
if: github.ref == 'refs/heads/dev' && matrix.pio_cache_key if: github.ref == 'refs/heads/dev'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.platformio path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache platformio - name: Cache platformio
if: github.ref != 'refs/heads/dev' && matrix.pio_cache_key if: github.ref != 'refs/heads/dev'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.platformio path: ~/.platformio
key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }}
- name: Cache ESP-IDF install
if: matrix.cache_idf
uses: ./.github/actions/cache-esp-idf
with:
framework: arduino
- name: Cache nRF Connect SDK install
if: matrix.cache_sdk_nrf
uses: ./.github/actions/cache-sdk-nrf
- name: Register problem matchers - name: Register problem matchers
run: | run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json" echo "::add-matcher::.github/workflows/matchers/gcc.json"
echo "::add-matcher::.github/workflows/matchers/clang-tidy.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 - name: Check if full clang-tidy scan needed
id: check_full_scan id: check_full_scan
run: | run: |
. venv/bin/activate . venv/bin/activate
# determine-jobs.clang-tidy-full-scan is true when core C++ or a # determine-jobs.clang-tidy-full-scan is true when core C++ changed
# clang-tidy-relevant config file changed, or the ci-run-all label # OR the ci-run-all label forced --force-all. Independent of the
# forced --force-all. # hash check, both must produce a full scan in the job itself.
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
echo "full_scan=true" >> $GITHUB_OUTPUT echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=determine_jobs" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT
elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else else
echo "full_scan=false" >> $GITHUB_OUTPUT echo "full_scan=false" >> $GITHUB_OUTPUT
echo "reason=normal" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT
@@ -576,21 +550,10 @@ jobs:
. venv/bin/activate . venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
changed="" script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
else else
echo "Running clang-tidy on changed files only" echo "Running clang-tidy on changed files only"
changed="--changed" script/clang-tidy --all-headers --fix --changed ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
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 fi
env: env:
# Also cache libdeps, store them in a ~/.platformio subfolder # Also cache libdeps, store them in a ~/.platformio subfolder
@@ -602,7 +565,7 @@ jobs:
if: always() if: always()
clang-tidy-nosplit: clang-tidy-nosplit:
name: Run script/clang-tidy for ESP32 IDF name: Run script/clang-tidy for ESP32 Arduino
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs:
- common - common
@@ -610,11 +573,9 @@ jobs:
if: needs.determine-jobs.outputs.clang-tidy-mode == 'nosplit' if: needs.determine-jobs.outputs.clang-tidy-mode == 'nosplit'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
# esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache.
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
# Need history for HEAD~1 to work for checking changed files # Need history for HEAD~1 to work for checking changed files
fetch-depth: 2 fetch-depth: 2
@@ -625,8 +586,19 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }} cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache ESP-IDF install - name: Cache platformio
uses: ./.github/actions/cache-esp-idf 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: Register problem matchers - name: Register problem matchers
run: | run: |
@@ -637,12 +609,15 @@ jobs:
id: check_full_scan id: check_full_scan
run: | run: |
. venv/bin/activate . venv/bin/activate
# determine-jobs.clang-tidy-full-scan is true when core C++ or a # determine-jobs.clang-tidy-full-scan is true when core C++ changed
# clang-tidy-relevant config file changed, or the ci-run-all label # OR the ci-run-all label forced --force-all. Independent of the
# forced --force-all. # hash check, both must produce a full scan in the job itself.
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
echo "full_scan=true" >> $GITHUB_OUTPUT echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=determine_jobs" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT
elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else else
echo "full_scan=false" >> $GITHUB_OUTPUT echo "full_scan=false" >> $GITHUB_OUTPUT
echo "reason=normal" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT
@@ -653,10 +628,10 @@ jobs:
. venv/bin/activate . venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})" echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --all-headers --fix --environment esp32-idf-tidy script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy
else else
echo "Running clang-tidy on changed files only" echo "Running clang-tidy on changed files only"
script/clang-tidy --all-headers --fix --changed --environment esp32-idf-tidy script/clang-tidy --all-headers --fix --changed --environment esp32-arduino-tidy
fi fi
env: env:
# Also cache libdeps, store them in a ~/.platformio subfolder # Also cache libdeps, store them in a ~/.platformio subfolder
@@ -675,26 +650,27 @@ jobs:
if: needs.determine-jobs.outputs.clang-tidy-mode == 'split' if: needs.determine-jobs.outputs.clang-tidy-mode == 'split'
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
# esp32-idf-tidy installs ESP-IDF natively; share the native IDF cache.
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy: strategy:
fail-fast: false fail-fast: false
max-parallel: 3 max-parallel: 2
matrix: matrix:
include: include:
- id: clang-tidy - id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 1/3 name: Run script/clang-tidy for ESP32 Arduino 1/4
options: --environment esp32-idf-tidy --split-num 3 --split-at 1 options: --environment esp32-arduino-tidy --split-num 4 --split-at 1
- id: clang-tidy - id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 2/3 name: Run script/clang-tidy for ESP32 Arduino 2/4
options: --environment esp32-idf-tidy --split-num 3 --split-at 2 options: --environment esp32-arduino-tidy --split-num 4 --split-at 2
- id: clang-tidy - id: clang-tidy
name: Run script/clang-tidy for ESP32 IDF 3/3 name: Run script/clang-tidy for ESP32 Arduino 3/4
options: --environment esp32-idf-tidy --split-num 3 --split-at 3 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
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
# Need history for HEAD~1 to work for checking changed files # Need history for HEAD~1 to work for checking changed files
fetch-depth: 2 fetch-depth: 2
@@ -705,8 +681,19 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }} cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache ESP-IDF install - name: Cache platformio
uses: ./.github/actions/cache-esp-idf 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: Register problem matchers - name: Register problem matchers
run: | run: |
@@ -717,12 +704,15 @@ jobs:
id: check_full_scan id: check_full_scan
run: | run: |
. venv/bin/activate . venv/bin/activate
# determine-jobs.clang-tidy-full-scan is true when core C++ or a # determine-jobs.clang-tidy-full-scan is true when core C++ changed
# clang-tidy-relevant config file changed, or the ci-run-all label # OR the ci-run-all label forced --force-all. Independent of the
# forced --force-all. # hash check, both must produce a full scan in the job itself.
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
echo "full_scan=true" >> $GITHUB_OUTPUT echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=determine_jobs" >> $GITHUB_OUTPUT echo "reason=determine_jobs" >> $GITHUB_OUTPUT
elif python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else else
echo "full_scan=false" >> $GITHUB_OUTPUT echo "full_scan=false" >> $GITHUB_OUTPUT
echo "reason=normal" >> $GITHUB_OUTPUT echo "reason=normal" >> $GITHUB_OUTPUT
@@ -746,109 +736,18 @@ jobs:
run: script/ci-suggest-changes run: script/ci-suggest-changes
if: always() if: always()
clang-tidy-esp32-variants:
name: ${{ matrix.name }}
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.clang-tidy == 'true'
env:
GH_TOKEN: ${{ github.token }}
# The variant tidy envs install ESP-IDF natively; share the native IDF cache.
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
strategy:
fail-fast: false
max-parallel: 3
matrix:
include:
- id: clang-tidy
name: Run script/clang-tidy for ESP32 S3
# yamllint disable-line rule:line-length
options: --environment esp32s3-idf-tidy --grep SOC_TEMP_SENSOR_SUPPORTED --grep USE_ESP32_VARIANT_ESP32S3 --grep USE_LOGGER_USB_CDC
- id: clang-tidy
name: Run script/clang-tidy for ESP32 P4
# P4 has no native Wi-Fi/BLE; those run over the hosted co-processor,
# so their code paths differ -- lint them under the P4 build too.
# yamllint disable-line rule:line-length
options: --environment esp32p4-idf-tidy --grep USE_ESP32_VARIANT_ESP32P4 --grep USE_ESP32_HOSTED --grep USE_WIFI --grep USE_BLE
- id: clang-tidy
name: Run script/clang-tidy for ESP32 C6
# yamllint disable-line rule:line-length
options: --environment esp32c6-idf-tidy --grep SOC_LP_I2C_SUPPORTED --grep USE_ESP32_VARIANT_ESP32C6 --grep USE_OPENTHREAD --grep USE_ZIGBEE
steps:
- name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
- name: Restore Python
uses: ./.github/actions/restore-python
with:
python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache ESP-IDF install
uses: ./.github/actions/cache-esp-idf
- name: Register problem matchers
run: |
echo "::add-matcher::.github/workflows/matchers/gcc.json"
echo "::add-matcher::.github/workflows/matchers/clang-tidy.json"
- name: Check if full clang-tidy scan needed
id: check_full_scan
run: |
. venv/bin/activate
# determine-jobs.clang-tidy-full-scan is true when core C++ or a
# clang-tidy-relevant config file changed, or the ci-run-all label
# forced --force-all.
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=determine_jobs" >> $GITHUB_OUTPUT
else
echo "full_scan=false" >> $GITHUB_OUTPUT
echo "reason=normal" >> $GITHUB_OUTPUT
fi
- name: Run clang-tidy
# Limited variant scan: only the files carrying that variant's code paths
# (no --all-headers; the comprehensive esp32-idf pass covers the shared tree).
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
script/clang-tidy --fix ${{ matrix.options }}
else
echo "Running clang-tidy on changed files only"
script/clang-tidy --fix --changed ${{ matrix.options }}
fi
- name: Suggested changes
run: script/ci-suggest-changes
if: always()
test-build-components-split: test-build-components-split:
name: Test components batch (${{ matrix.batch.components }}) name: Test components batch (${{ matrix.components }})
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
needs: needs:
- common - common
- determine-jobs - determine-jobs
if: github.event_name == 'pull_request' && fromJSON(needs.determine-jobs.outputs.component-test-count) > 0 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: strategy:
fail-fast: false fail-fast: false
max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }} max-parallel: ${{ (startsWith(github.base_ref, 'beta') || startsWith(github.base_ref, 'release')) && 8 || 4 }}
matrix: matrix:
batch: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }} components: ${{ fromJson(needs.determine-jobs.outputs.component-test-batches) }}
steps: steps:
- name: Show disk space - name: Show disk space
run: | run: |
@@ -856,38 +755,21 @@ jobs:
df -h df -h
- name: List components - name: List components
run: echo ${{ matrix.batch.components }} run: echo ${{ matrix.components }}
- name: Install apt packages - name: Cache apt packages
# Not cached: this job is pull-request-only, so a cache save could uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.5.3
# never be shared and would only consume quota. with:
run: | packages: libsdl2-dev
sudo apt-get update -qq version: 1.0
sudo apt-get install -y --no-install-recommends libsdl2-dev ccache
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }} 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 - name: Validate and compile components with intelligent grouping
run: | run: |
. venv/bin/activate . venv/bin/activate
@@ -918,7 +800,7 @@ jobs:
fi fi
# Convert space-separated components to comma-separated for Python script # Convert space-separated components to comma-separated for Python script
components_csv=$(echo "${{ matrix.batch.components }}" | tr ' ' ',') components_csv=$(echo "${{ matrix.components }}" | tr ' ' ',')
# Only isolate directly changed components when targeting dev branch # Only isolate directly changed components when targeting dev branch
# For beta/release branches, group everything for faster CI # For beta/release branches, group everything for faster CI
@@ -939,7 +821,7 @@ jobs:
fi fi
echo "" echo ""
# Show disk space before validation # Show disk space before validation (after bind mounts setup)
echo "Disk space before config validation:" echo "Disk space before config validation:"
df -h df -h
echo "" echo ""
@@ -991,27 +873,23 @@ jobs:
echo "All components in this batch are validate-only -- skipping compile stage." echo "All components in this batch are validate-only -- skipping compile stage."
fi fi
- name: Print ccache statistics test-native-idf:
# esphome stores the cache under the IDF tools path; expand the leading name: Test components with native ESP-IDF
# ~ 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 runs-on: ubuntu-24.04
needs: needs:
- common - common
- determine-jobs - determine-jobs
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.esp32-platformio == 'true' if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true'
env: env:
# Comma-joined subset of the esp32 PlatformIO representative component list, ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
# computed by script/determine-jobs.py (esp32_platformio_components_to_test). # Comma-joined subset of the native-IDF representative component list,
# computed by script/determine-jobs.py (native_idf_components_to_test).
# Single source of truth -- the full list lives in # Single source of truth -- the full list lives in
# script/determine-jobs.py::ESP32_PLATFORMIO_TEST_COMPONENTS. # script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS.
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.esp32-platformio-components }} TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }}
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
@@ -1019,53 +897,70 @@ jobs:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }} cache-key: ${{ needs.common.outputs.cache-key }}
- name: Run PlatformIO compile test - name: Cache ESPHome
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: ~/.esphome-idf
key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
- name: Run native ESP-IDF compile test
run: | run: |
. venv/bin/activate . 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 "Testing components: $TEST_COMPONENTS"
echo "" echo ""
# compile validates config first, so a separate config pass is # Show disk space before validation (after bind mounts setup)
# redundant for this smoke test. ESP-IDF framework via PlatformIO: echo "Disk space before config validation:"
python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain platformio df -h
echo ""
echo "ESP-IDF-via-PlatformIO build passed! Starting Arduino smoke test..."
echo "" echo ""
# Arduino framework via PlatformIO (only components with an esp32-ard test are built): # Run config validation (auto-grouped by test_build_components.py)
python3 script/test_build_components.py -e compile -t esp32-ard -c "$TEST_COMPONENTS" -f --toolchain platformio python3 script/test_build_components.py -e config -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf
pre-commit-seed-cache: echo ""
name: Seed pre-commit cache echo "Config validation passed! Starting compilation..."
runs-on: ubuntu-latest echo ""
needs:
- common # Show disk space before compilation
# Saves a dev-scoped pre-commit cache that pull request runs can echo "Disk space before compilation:"
# restore, since pre-commit.ci lite itself never runs on dev pushes. df -h
if: github.event_name == 'push' && github.ref == 'refs/heads/dev' echo ""
steps:
- name: Check out code from GitHub # Run compilation (auto-grouped by test_build_components.py)
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 python3 script/test_build_components.py -e compile -t esp32-idf -c "$TEST_COMPONENTS" -f --toolchain esp-idf
- name: Restore Python
uses: ./.github/actions/restore-python - name: Save ESPHome cache
if: github.ref == 'refs/heads/dev'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} path: ~/.esphome-idf
cache-key: ${{ needs.common.outputs.cache-key }} key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
- name: Cache pre-commit environments
id: cache-pre-commit
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the restore key in pre-commit-ci-lite
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Install pre-commit hook environments
if: steps.cache-pre-commit.outputs.cache-hit != 'true'
run: |
python -m pip install pre-commit
pre-commit install-hooks
pre-commit-ci-lite: pre-commit-ci-lite:
name: pre-commit.ci lite name: pre-commit.ci lite
@@ -1076,28 +971,15 @@ jobs:
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true' if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
steps: steps:
- name: Check out code from GitHub - name: Check out code from GitHub
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }} cache-key: ${{ needs.common.outputs.cache-key }}
# Inlined from esphome/pre-commit-action with a restore-only cache - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache
# step: the pre-commit-seed-cache job owns saving this cache, so
# pull request runs never write per-PR copies.
- name: Restore pre-commit cache
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/pre-commit
# Must match the key pre-commit-seed-cache saves
# yamllint disable-line rule:line-length
key: pre-commit-3|${{ env.pythonLocation }}|${{ hashFiles('.pre-commit-config.yaml') }}
- name: Run pre-commit
env: env:
SKIP: pylint,ci-custom SKIP: pylint,clang-tidy-hash,ci-custom
run: |
python -m pip install pre-commit
pre-commit run --show-diff-on-failure --color=always --all-files
- uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0
if: always() if: always()
@@ -1115,7 +997,7 @@ jobs:
skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }} skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }}
steps: steps:
- name: Check out target branch - name: Check out target branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.base_ref }} ref: ${{ github.base_ref }}
@@ -1191,7 +1073,7 @@ jobs:
- name: Restore cached memory analysis - name: Restore cached memory analysis
id: cache-memory-analysis id: cache-memory-analysis
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true'
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: memory-analysis-target.json path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }} key: ${{ steps.cache-key.outputs.cache-key }}
@@ -1215,7 +1097,7 @@ jobs:
- name: Cache platformio - name: Cache platformio
if: steps.check-script.outputs.skip != 'true' && steps.check-tests.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.platformio path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -1257,7 +1139,7 @@ jobs:
- name: Save memory analysis to cache - 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' 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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: memory-analysis-target.json path: memory-analysis-target.json
key: ${{ steps.cache-key.outputs.cache-key }} key: ${{ steps.cache-key.outputs.cache-key }}
@@ -1297,14 +1179,14 @@ jobs:
flash_usage: ${{ steps.extract.outputs.flash_usage }} flash_usage: ${{ steps.extract.outputs.flash_usage }}
steps: steps:
- name: Check out PR branch - name: Check out PR branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
with: with:
python-version: ${{ env.DEFAULT_PYTHON }} python-version: ${{ env.DEFAULT_PYTHON }}
cache-key: ${{ needs.common.outputs.cache-key }} cache-key: ${{ needs.common.outputs.cache-key }}
- name: Cache platformio - name: Cache platformio
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: ~/.platformio path: ~/.platformio
key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }}
@@ -1366,7 +1248,7 @@ jobs:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Restore Python - name: Restore Python
uses: ./.github/actions/restore-python uses: ./.github/actions/restore-python
with: with:
@@ -1409,11 +1291,10 @@ jobs:
- clang-tidy-single - clang-tidy-single
- clang-tidy-nosplit - clang-tidy-nosplit
- clang-tidy-split - clang-tidy-split
- clang-tidy-esp32-variants
- determine-jobs - determine-jobs
- device-builder - device-builder
- test-build-components-split - test-build-components-split
- test-esp32-platformio - test-native-idf
- pre-commit-ci-lite - pre-commit-ci-lite
- memory-impact-target-branch - memory-impact-target-branch
- memory-impact-pr-branch - memory-impact-pr-branch
@@ -1429,7 +1310,4 @@ jobs:
# 1. The target branch has a build issue independent of this PR # 1. The target branch has a build issue independent of this PR
# 2. This PR fixes a build issue on the target branch # 2. This PR fixes a build issue on the target branch
# In either case, we only care that the PR branch builds successfully. # In either case, we only care that the PR branch builds successfully.
# Every other job must have succeeded or been skipped; a "cancelled" or echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result != "failure")'
# "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")'
@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout base branch - name: Checkout base branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.base.sha }} ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: | sparse-checkout: |
@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout base branch - name: Checkout base branch
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: ${{ github.event.pull_request.base.sha }} ref: ${{ github.event.pull_request.base.sha }}
+3 -3
View File
@@ -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 # 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: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }} build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1 exit 1
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1 uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with: with:
category: "/language:${{matrix.language}}" category: "/language:${{matrix.language}}"
@@ -0,0 +1,119 @@
name: Add Dashboard Deprecation Comment
on:
pull_request_target:
types: [opened, synchronize]
# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with
# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes.
permissions: {}
jobs:
dashboard-deprecation-comment:
name: Dashboard deprecation comment
runs-on: ubuntu-latest
# Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably
# roll up everything merged into dev since the last cut, which can
# include dashboard changes that have already been reviewed once.
# The bot's purpose is to warn new contributors before they invest
# time -- that only applies to PRs entering dev.
if: github.event.pull_request.base.ref == 'dev'
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
# pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources
# the issues.*Comment APIs require the pull-requests scope, not issues.
permission-pull-requests: write
- name: Add dashboard deprecation comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const commentMarker = "<!-- This comment was generated automatically by the dashboard-deprecation-comment workflow. -->";
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,
});
}
+1 -1
View File
@@ -14,4 +14,4 @@ jobs:
permissions: permissions:
issues: write # issues.lock on closed issues issues: write # issues.lock on closed issues
pull-requests: write # issues.lock on closed pull requests pull-requests: write # issues.lock on closed pull requests
uses: esphome/workflows/.github/workflows/lock.yml@9f6577fd37b5cf773ab1b9be929714a0dcd15661 # 2026.7.0 uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
name: Validate PR title name: Validate PR title
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with: with:
+13 -13
View File
@@ -20,7 +20,7 @@ jobs:
branch_build: ${{ steps.tag.outputs.branch_build }} branch_build: ${{ steps.tag.outputs.branch_build }}
deploy_env: ${{ steps.tag.outputs.deploy_env }} deploy_env: ${{ steps.tag.outputs.deploy_env }}
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get tag - name: Get tag
id: tag id: tag
# yamllint disable rule:line-length # yamllint disable rule:line-length
@@ -60,9 +60,9 @@ jobs:
contents: read # actions/checkout to build the sdist/wheel contents: read # actions/checkout to build the sdist/wheel
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish) id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python - name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.x" python-version: "3.x"
- name: Build - name: Build
@@ -92,22 +92,22 @@ jobs:
os: "ubuntu-24.04-arm" os: "ubuntu-24.04-arm"
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python - name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.12" python-version: "3.11"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to docker hub - name: Log in to docker hub
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
username: ${{ secrets.DOCKER_USER }} username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry - name: Log in to the GitHub container registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -168,7 +168,7 @@ jobs:
- ghcr - ghcr
- dockerhub - dockerhub
steps: steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download digests - name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@@ -178,17 +178,17 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to docker hub - name: Log in to docker hub
if: matrix.registry == 'dockerhub' if: matrix.registry == 'dockerhub'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
username: ${{ secrets.DOCKER_USER }} username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry - name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr' if: matrix.registry == 'ghcr'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Stale - name: Stale
uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with: with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true remove-stale-when-updated: true
+4 -4
View File
@@ -28,16 +28,16 @@ jobs:
permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Checkout Home Assistant - name: Checkout Home Assistant
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
repository: home-assistant/core repository: home-assistant/core
path: lib/home-assistant path: lib/home-assistant
- name: Setup Python - name: Setup Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.14" python-version: "3.14"
@@ -47,7 +47,7 @@ jobs:
# setup-python interpreter so subsequent ``pre-commit`` / # setup-python interpreter so subsequent ``pre-commit`` /
# ``script/run-in-env.py`` steps find the deps without a # ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix. # ``uv run`` prefix.
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
enable-cache: true enable-cache: true
# Pin uv version so the action does not have to fetch the # Pin uv version so the action does not have to fetch the
-1
View File
@@ -141,7 +141,6 @@ tests/.esphome/
sdkconfig.* sdkconfig.*
!sdkconfig.defaults !sdkconfig.defaults
!sdkconfig.defaults.*
.tests/ .tests/
+9 -2
View File
@@ -6,7 +6,7 @@ ci:
autoupdate_commit_msg: 'pre-commit: autoupdate' autoupdate_commit_msg: 'pre-commit: autoupdate'
autoupdate_schedule: off # Disabled until ruff versions are synced between deps and pre-commit 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 hooks that have issues in pre-commit CI environment
skip: [pylint] skip: [pylint, clang-tidy-hash]
repos: repos:
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
@@ -40,7 +40,7 @@ repos:
rev: v3.21.2 rev: v3.21.2
hooks: hooks:
- id: pyupgrade - id: pyupgrade
args: [--py312-plus] args: [--py311-plus]
- repo: https://github.com/adrienverge/yamllint.git - repo: https://github.com/adrienverge/yamllint.git
rev: v1.37.1 rev: v1.37.1
hooks: hooks:
@@ -59,6 +59,13 @@ repos:
language: system language: system
types: [python] types: [python]
files: ^esphome/.+\.py$ 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 - id: ci-custom
name: ci-custom name: ci-custom
entry: python script/run-in-env.py script/ci-custom.py entry: python script/run-in-env.py script/ci-custom.py
+9 -27
View File
@@ -9,12 +9,12 @@ This document provides essential context for AI models interacting with this pro
## 2. Core Technologies & Stack ## 2. Core Technologies & Stack
* **Languages:** Python (>=3.12), C++ (gnu++20) * **Languages:** Python (>=3.11), C++ (gnu++20)
* **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF. * **Frameworks & Runtimes:** PlatformIO, Arduino, ESP-IDF.
* **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative. * **Build Systems:** PlatformIO is the primary build system. CMake is used as an alternative.
* **Configuration:** YAML. * **Configuration:** YAML.
* **Key Libraries/Dependencies:** * **Key Libraries/Dependencies:**
* **Python:** `voluptuous` (for configuration validation), `PyYAML` (for parsing configuration files), `paho-mqtt` (for MQTT communication), `aioesphomeapi` (for the native API). * **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).
* **C++:** `ArduinoJson` (for JSON serialization/deserialization), `AsyncMqttClient-esphome` (for MQTT), `ESPAsyncWebServer` (for the web server). * **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). * **Package Manager(s):** `pip` (for Python dependencies), `platformio` (for C++/PlatformIO dependencies).
* **Communication Protocols:** Protobuf (for native API), MQTT, HTTP. * **Communication Protocols:** Protobuf (for native API), MQTT, HTTP.
@@ -35,6 +35,7 @@ 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. 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. 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. 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:** * **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). 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,19 +59,6 @@ This document provides essential context for AI models interacting with this pro
- Protected/private fields: `lower_snake_case_with_trailing_underscore_` - Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations - Favor descriptive names over abbreviations
* **Python Idioms:**
* **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
```python
# Bad - looks up CONF_BLAH twice
if CONF_BLAH in config:
cg.add(var.set_blah(config[CONF_BLAH]))
# Good - single lookup, value bound inline
if (blah := config.get(CONF_BLAH)) is not None:
cg.add(var.set_blah(blah))
```
The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line.
* **C++ Field Visibility:** * **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_`. * **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`.
* **Use `private` for safety-critical cases:** Use `private` visibility when direct field access could introduce bugs or violate invariants: * **Use `private` for safety-critical cases:** Use `private` visibility when direct field access could introduce bugs or violate invariants:
@@ -427,14 +415,13 @@ 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. 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 ```yaml
# test.esp32-idf.yaml — everything included via named packages # test.esp32-idf.yaml — use packages for buses
packages: packages:
uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml uart: !include ../../test_build_components/common/uart_115200/esp32-idf.yaml
my_component: !include common.yaml
<<: !include common.yaml
``` ```
```yaml ```yaml
# common.yaml — component config only, NO bus definitions # common.yaml — component config only, NO bus definitions
@@ -456,6 +443,7 @@ This document provides essential context for AI models interacting with this pro
* **Debug Tools:** * **Debug Tools:**
- `esphome config <file>.yaml` to validate configuration. - `esphome config <file>.yaml` to validate configuration.
- `esphome compile <file>.yaml` to compile without uploading. - `esphome compile <file>.yaml` to compile without uploading.
- Check the Dashboard for real-time logs.
- Use component-specific debug logging. - Use component-specific debug logging.
* **Common Issues:** * **Common Issues:**
- **Import Errors**: Check component dependencies and `PYTHONPATH`. - **Import Errors**: Check component dependencies and `PYTHONPATH`.
@@ -657,7 +645,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. 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:** **Why this matters:**
- Module-level globals persist between compilation runs if the host process (e.g. device-builder) doesn't fork/exec - Module-level globals persist between compilation runs if the dashboard doesn't fork/exec
- `CORE.data` automatically clears between runs - `CORE.data` automatically clears between runs
- Namespacing under `DOMAIN` prevents key collisions between components - Namespacing under `DOMAIN` prevents key collisions between components
- `@dataclass` provides type safety and cleaner attribute access - `@dataclass` provides type safety and cleaner attribute access
@@ -710,9 +698,3 @@ This document provides essential context for AI models interacting with this pro
_LOGGER.warning(f"'{CONF_OLD_KEY}' deprecated, use '{CONF_NEW_KEY}'. Removed in 2026.6.0") _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 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.
+5 -21
View File
@@ -19,6 +19,7 @@ esphome/components/ac_dimmer/* @glmnet
esphome/components/adc/* @esphome/core esphome/components/adc/* @esphome/core
esphome/components/adc128s102/* @DeerMaximum esphome/components/adc128s102/* @DeerMaximum
esphome/components/addressable_light/* @justfalter esphome/components/addressable_light/* @justfalter
esphome/components/ade7880/* @kpfleming
esphome/components/ade7953/* @angelnu esphome/components/ade7953/* @angelnu
esphome/components/ade7953_base/* @angelnu esphome/components/ade7953_base/* @angelnu
esphome/components/ade7953_i2c/* @angelnu esphome/components/ade7953_i2c/* @angelnu
@@ -27,7 +28,7 @@ esphome/components/ads1118/* @solomondg1
esphome/components/ags10/* @mak-42 esphome/components/ags10/* @mak-42
esphome/components/aic3204/* @kbx81 esphome/components/aic3204/* @kbx81
esphome/components/airthings_ble/* @jeromelaban esphome/components/airthings_ble/* @jeromelaban
esphome/components/airthings_wave_base/* @jeromelaban @ncareau esphome/components/airthings_wave_base/* @jeromelaban @kpfleming @ncareau
esphome/components/airthings_wave_mini/* @ncareau esphome/components/airthings_wave_mini/* @ncareau
esphome/components/airthings_wave_plus/* @jeromelaban @precurse esphome/components/airthings_wave_plus/* @jeromelaban @precurse
esphome/components/alarm_control_panel/* @grahambrown11 @hwstar esphome/components/alarm_control_panel/* @grahambrown11 @hwstar
@@ -83,7 +84,6 @@ esphome/components/bme680_bsec/* @trvrnrth
esphome/components/bme68x_bsec2/* @kbx81 @neffs esphome/components/bme68x_bsec2/* @kbx81 @neffs
esphome/components/bme68x_bsec2_i2c/* @kbx81 @neffs esphome/components/bme68x_bsec2_i2c/* @kbx81 @neffs
esphome/components/bmi160/* @flaviut esphome/components/bmi160/* @flaviut
esphome/components/bmi270/* @clydebarrow
esphome/components/bmp280_base/* @ademuri esphome/components/bmp280_base/* @ademuri
esphome/components/bmp280_i2c/* @ademuri esphome/components/bmp280_i2c/* @ademuri
esphome/components/bmp280_spi/* @ademuri esphome/components/bmp280_spi/* @ademuri
@@ -122,9 +122,7 @@ esphome/components/cover/* @esphome/core
esphome/components/cs5460a/* @balrog-kun esphome/components/cs5460a/* @balrog-kun
esphome/components/cse7761/* @berfenger esphome/components/cse7761/* @berfenger
esphome/components/cst226/* @clydebarrow esphome/components/cst226/* @clydebarrow
esphome/components/cst328/* @latonita
esphome/components/cst816/* @clydebarrow esphome/components/cst816/* @clydebarrow
esphome/components/cst9220/* @clydebarrow
esphome/components/ct_clamp/* @jesserockz esphome/components/ct_clamp/* @jesserockz
esphome/components/current_based/* @djwmarcx esphome/components/current_based/* @djwmarcx
esphome/components/dac7678/* @NickB1 esphome/components/dac7678/* @NickB1
@@ -141,11 +139,10 @@ esphome/components/dfplayer/* @glmnet
esphome/components/dfrobot_sen0395/* @niklasweber esphome/components/dfrobot_sen0395/* @niklasweber
esphome/components/dht/* @OttoWinter esphome/components/dht/* @OttoWinter
esphome/components/display_menu_base/* @numo68 esphome/components/display_menu_base/* @numo68
esphome/components/dlms_meter/* @latonita @PolarGoose @SimonFischer04 @Tomer27cz esphome/components/dlms_meter/* @SimonFischer04
esphome/components/dps310/* @kbx81 esphome/components/dps310/* @kbx81
esphome/components/ds1307/* @badbadc0ffee esphome/components/ds1307/* @badbadc0ffee
esphome/components/ds2484/* @mrk-its esphome/components/ds2484/* @mrk-its
esphome/components/ds248x/* @tomwellnitz
esphome/components/dsmr/* @glmnet @PolarGoose esphome/components/dsmr/* @glmnet @PolarGoose
esphome/components/duty_time/* @dudanov esphome/components/duty_time/* @dudanov
esphome/components/ee895/* @Stock-M esphome/components/ee895/* @Stock-M
@@ -188,7 +185,6 @@ esphome/components/ezo_pmp/* @carlos-sarmiento
esphome/components/factory_reset/* @anatoly-savchenkov esphome/components/factory_reset/* @anatoly-savchenkov
esphome/components/fastled_base/* @OttoWinter esphome/components/fastled_base/* @OttoWinter
esphome/components/feedback/* @ianchi esphome/components/feedback/* @ianchi
esphome/components/file/* @esphome/core
esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund esphome/components/fingerprint_grow/* @alexborro @loongyh @OnFreund
esphome/components/font/* @clydebarrow @esphome/core esphome/components/font/* @clydebarrow @esphome/core
esphome/components/fs3000/* @kahrendt esphome/components/fs3000/* @kahrendt
@@ -210,7 +206,6 @@ esphome/components/gree/switch/* @nagyrobi
esphome/components/grove_gas_mc_v2/* @YorkshireIoT esphome/components/grove_gas_mc_v2/* @YorkshireIoT
esphome/components/grove_tb6612fng/* @max246 esphome/components/grove_tb6612fng/* @max246
esphome/components/growatt_solar/* @leeuwte esphome/components/growatt_solar/* @leeuwte
esphome/components/gsl3670/* @clydebarrow
esphome/components/gt911/* @clydebarrow @jesserockz esphome/components/gt911/* @clydebarrow @jesserockz
esphome/components/haier/* @paveldn esphome/components/haier/* @paveldn
esphome/components/haier/binary_sensor/* @paveldn esphome/components/haier/binary_sensor/* @paveldn
@@ -271,7 +266,6 @@ esphome/components/integration/* @OttoWinter
esphome/components/internal_temperature/* @Mat931 esphome/components/internal_temperature/* @Mat931
esphome/components/interval/* @esphome/core esphome/components/interval/* @esphome/core
esphome/components/ir_rf_proxy/* @kbx81 esphome/components/ir_rf_proxy/* @kbx81
esphome/components/it8951/* @koosoli @limengdu @Passific
esphome/components/jsn_sr04t/* @Mafus1 esphome/components/jsn_sr04t/* @Mafus1
esphome/components/json/* @esphome/core esphome/components/json/* @esphome/core
esphome/components/kamstrup_kmp/* @cfeenstra1024 esphome/components/kamstrup_kmp/* @cfeenstra1024
@@ -297,7 +291,6 @@ esphome/components/lock/* @esphome/core
esphome/components/logger/* @esphome/core esphome/components/logger/* @esphome/core
esphome/components/logger/select/* @clydebarrow esphome/components/logger/select/* @clydebarrow
esphome/components/lps22/* @nagisa esphome/components/lps22/* @nagisa
esphome/components/lsm6ds/* @clydebarrow
esphome/components/ltr390/* @latonita @sjtrny esphome/components/ltr390/* @latonita @sjtrny
esphome/components/ltr501/* @latonita esphome/components/ltr501/* @latonita
esphome/components/ltr_als_ps/* @latonita esphome/components/ltr_als_ps/* @latonita
@@ -358,7 +351,6 @@ esphome/components/modbus_server/* @exciton
esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan esphome/components/mopeka_ble/* @Fabian-Schmidt @spbrogan
esphome/components/mopeka_pro_check/* @spbrogan esphome/components/mopeka_pro_check/* @spbrogan
esphome/components/mopeka_std_check/* @Fabian-Schmidt esphome/components/mopeka_std_check/* @Fabian-Schmidt
esphome/components/motion/* @esphome/core
esphome/components/mpl3115a2/* @kbickar esphome/components/mpl3115a2/* @kbickar
esphome/components/mpu6886/* @fabaff esphome/components/mpu6886/* @fabaff
esphome/components/ms8607/* @e28eta esphome/components/ms8607/* @e28eta
@@ -387,11 +379,9 @@ esphome/components/pca6416a/* @Mat931
esphome/components/pca9554/* @bdraco @clydebarrow @hwstar esphome/components/pca9554/* @bdraco @clydebarrow @hwstar
esphome/components/pcf85063/* @brogon esphome/components/pcf85063/* @brogon
esphome/components/pcf8563/* @KoenBreeman esphome/components/pcf8563/* @KoenBreeman
esphome/components/pcm5122/* @remcom
esphome/components/pi4ioe5v6408/* @jesserockz esphome/components/pi4ioe5v6408/* @jesserockz
esphome/components/pid/* @OttoWinter esphome/components/pid/* @OttoWinter
esphome/components/pipsolar/* @andreashergert1984 esphome/components/pipsolar/* @andreashergert1984
esphome/components/pixoo/* @jesserockz
esphome/components/pm1006/* @habbie esphome/components/pm1006/* @habbie
esphome/components/pm2005/* @andrewjswan esphome/components/pm2005/* @andrewjswan
esphome/components/pmsa003i/* @sjtrny esphome/components/pmsa003i/* @sjtrny
@@ -407,12 +397,10 @@ esphome/components/pn7160_i2c/* @jesserockz @kbx81
esphome/components/pn7160_spi/* @jesserockz @kbx81 esphome/components/pn7160_spi/* @jesserockz @kbx81
esphome/components/power_supply/* @esphome/core esphome/components/power_supply/* @esphome/core
esphome/components/preferences/* @esphome/core esphome/components/preferences/* @esphome/core
esphome/components/provisioning/* @esphome/core
esphome/components/psram/* @esphome/core esphome/components/psram/* @esphome/core
esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston esphome/components/pulse_meter/* @cstaahl @stevebaxter @TrentHouliston
esphome/components/pvvx_mithermometer/* @pasiz esphome/components/pvvx_mithermometer/* @pasiz
esphome/components/pylontech/* @functionpointer esphome/components/pylontech/* @functionpointer
esphome/components/qmi8658/* @clydebarrow
esphome/components/qmp6988/* @andrewpc esphome/components/qmp6988/* @andrewpc
esphome/components/qr_code/* @wjtje esphome/components/qr_code/* @wjtje
esphome/components/qspi_dbi/* @clydebarrow esphome/components/qspi_dbi/* @clydebarrow
@@ -430,7 +418,7 @@ esphome/components/rf_bridge/* @jesserockz
esphome/components/rgbct/* @jesserockz esphome/components/rgbct/* @jesserockz
esphome/components/ring_buffer/* @kahrendt esphome/components/ring_buffer/* @kahrendt
esphome/components/router/speaker/* @kahrendt esphome/components/router/speaker/* @kahrendt
esphome/components/rp2/* @jesserockz esphome/components/rp2040/* @jesserockz
esphome/components/rp2040_ble/* @bdraco esphome/components/rp2040_ble/* @bdraco
esphome/components/rp2040_pio_led_strip/* @Papa-DMan esphome/components/rp2040_pio_led_strip/* @Papa-DMan
esphome/components/rp2040_pwm/* @jesserockz esphome/components/rp2040_pwm/* @jesserockz
@@ -454,7 +442,7 @@ esphome/components/select/* @esphome/core
esphome/components/sen0321/* @notjj esphome/components/sen0321/* @notjj
esphome/components/sen21231/* @shreyaskarnik esphome/components/sen21231/* @shreyaskarnik
esphome/components/sen5x/* @martgras esphome/components/sen5x/* @martgras
esphome/components/sen6x/* @martgras @mebner86 @tuct esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
esphome/components/sendspin/* @kahrendt esphome/components/sendspin/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt esphome/components/sendspin/media_source/* @kahrendt
@@ -506,7 +494,6 @@ esphome/components/ssd1331_base/* @kbx81
esphome/components/ssd1331_spi/* @kbx81 esphome/components/ssd1331_spi/* @kbx81
esphome/components/ssd1351_base/* @kbx81 esphome/components/ssd1351_base/* @kbx81
esphome/components/ssd1351_spi/* @kbx81 esphome/components/ssd1351_spi/* @kbx81
esphome/components/st7123/* @miniskipper
esphome/components/st7567_base/* @latonita esphome/components/st7567_base/* @latonita
esphome/components/st7567_i2c/* @latonita esphome/components/st7567_i2c/* @latonita
esphome/components/st7567_spi/* @latonita esphome/components/st7567_spi/* @latonita
@@ -571,7 +558,6 @@ esphome/components/uart/packet_transport/* @clydebarrow
esphome/components/udp/* @clydebarrow esphome/components/udp/* @clydebarrow
esphome/components/ufire_ec/* @pvizeli esphome/components/ufire_ec/* @pvizeli
esphome/components/ufire_ise/* @pvizeli esphome/components/ufire_ise/* @pvizeli
esphome/components/ufm01/* @ljungqvist
esphome/components/ultrasonic/* @ssieb @swoboda1337 esphome/components/ultrasonic/* @ssieb @swoboda1337
esphome/components/update/* @jesserockz esphome/components/update/* @jesserockz
esphome/components/uponor_smatrix/* @kroimon esphome/components/uponor_smatrix/* @kroimon
@@ -588,7 +574,6 @@ esphome/components/wake_on_lan/* @clydebarrow @willwill2will54
esphome/components/watchdog/* @oarcher esphome/components/watchdog/* @oarcher
esphome/components/water_heater/* @dhoeben esphome/components/water_heater/* @dhoeben
esphome/components/waveshare_epaper/* @clydebarrow esphome/components/waveshare_epaper/* @clydebarrow
esphome/components/waveshare_io_ch32v003/* @latonita
esphome/components/web_server/ota/* @esphome/core esphome/components/web_server/ota/* @esphome/core
esphome/components/web_server_base/* @esphome/core esphome/components/web_server_base/* @esphome/core
esphome/components/web_server_idf/* @dentra esphome/components/web_server_idf/* @dentra
@@ -610,7 +595,6 @@ esphome/components/wk2212_spi/* @DrCoolZic
esphome/components/wl_134/* @hobbypunk90 esphome/components/wl_134/* @hobbypunk90
esphome/components/wts01/* @alepee esphome/components/wts01/* @alepee
esphome/components/x9c/* @EtienneMD esphome/components/x9c/* @EtienneMD
esphome/components/xdb401/* @RT530
esphome/components/xgzp68xx/* @gcormier esphome/components/xgzp68xx/* @gcormier
esphome/components/xiaomi_hhccjcy10/* @fariouche esphome/components/xiaomi_hhccjcy10/* @fariouche
esphome/components/xiaomi_lywsd02mmc/* @juanluss31 esphome/components/xiaomi_lywsd02mmc/* @juanluss31
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version # could be handy for archiving the generated documentation or if some version
# control system is used. # control system is used.
PROJECT_NUMBER = 2026.8.0-dev PROJECT_NUMBER = 2026.6.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description # 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 # for a project that appears at the top of each page and should give viewer a
-1
View File
@@ -6,4 +6,3 @@ recursive-include esphome *.cpp *.h *.tcc *.c
recursive-include esphome *.py.script recursive-include esphome *.py.script
recursive-include esphome *.jinja recursive-include esphome *.jinja
recursive-include esphome LICENSE.txt recursive-include esphome LICENSE.txt
recursive-include esphome requirements.txt
-148
View File
@@ -1,148 +0,0 @@
# 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).
-18
View File
@@ -1,18 +0,0 @@
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
+18 -6
View File
@@ -1,9 +1,10 @@
ARG BUILD_VERSION=dev ARG BUILD_VERSION=dev
ARG BUILD_BASE_VERSION=2026.06.1 ARG BUILD_OS=alpine
ARG BUILD_BASE_VERSION=2025.04.0
ARG BUILD_TYPE=docker ARG BUILD_TYPE=docker
FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker
FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon
ARG BUILD_TYPE ARG BUILD_TYPE
FROM base-source-${BUILD_TYPE} AS base FROM base-source-${BUILD_TYPE} AS base
@@ -11,6 +12,20 @@ FROM base-source-${BUILD_TYPE} AS base
RUN git config --system --add safe.directory "*" \ RUN git config --system --add safe.directory "*" \
&& git config --system advice.detachedHead false && git config --system advice.detachedHead false
# Install build tools for Python packages that require compilation
# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager).
# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can
# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without
# it idf_tools.py rejects the openocd install with exit 127 and aborts
# the whole framework setup.
RUN if command -v apk > /dev/null; then \
apk add --no-cache build-base libusb; \
else \
apt-get update \
&& apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
&& rm -rf /var/lib/apt/lists/*; \
fi
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1
RUN pip install --no-cache-dir -U pip uv==0.10.1 RUN pip install --no-cache-dir -U pip uv==0.10.1
@@ -21,9 +36,6 @@ RUN \
uv pip install --no-cache-dir \ uv pip install --no-cache-dir \
-r /requirements.txt -r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.6.4
RUN \ RUN \
platformio settings set enable_telemetry No \ platformio settings set enable_telemetry No \
&& platformio settings set check_platformio_interval 1000000 \ && platformio settings set check_platformio_interval 1000000 \
+13 -42
View File
@@ -20,10 +20,6 @@ TYPE_HA_ADDON = "ha-addon"
TYPE_LINT = "lint" TYPE_LINT = "lint"
TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT] TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT]
REGISTRY_GHCR = "ghcr"
REGISTRY_DOCKERHUB = "dockerhub"
REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB]
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument( parser.add_argument(
@@ -38,12 +34,6 @@ parser.add_argument(
parser.add_argument( parser.add_argument(
"--build-type", choices=TYPES, required=True, help="The type of build to run" "--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( parser.add_argument(
"--dry-run", action="store_true", help="Don't run any commands, just print them" "--dry-run", action="store_true", help="Don't run any commands, just print them"
) )
@@ -55,11 +45,6 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t
build_parser.add_argument( build_parser.add_argument(
"--load", help="Load the docker image locally", action="store_true" "--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_parser = subparsers.add_parser(
"manifest", help="Create a manifest from already pushed images" "manifest", help="Create a manifest from already pushed images"
) )
@@ -110,14 +95,11 @@ def main():
print("Command failed") print("Command failed")
sys.exit(1) sys.exit(1)
registries = args.registry or REGISTRIES
# detect channel from tag # detect channel from tag
match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag) match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag)
major_minor_version = None major_minor_version = None
if match is None: if match is None:
# Custom tag (e.g. a branch name) -- push only the tag itself channel = CHANNEL_DEV
channel = None
elif match.group(2) is None: elif match.group(2) is None:
major_minor_version = match.group(1) major_minor_version = match.group(1)
channel = CHANNEL_RELEASE channel = CHANNEL_RELEASE
@@ -146,18 +128,11 @@ def main():
CHANNEL_DEV: "cache-dev", CHANNEL_DEV: "cache-dev",
CHANNEL_BETA: "cache-beta", CHANNEL_BETA: "cache-beta",
CHANNEL_RELEASE: "cache-latest", CHANNEL_RELEASE: "cache-latest",
}.get(channel, "cache-dev") }[channel]
# Cache images live alongside the pushed images; prefer GHCR when it is cache_img = f"ghcr.io/{params.build_to}:{cache_tag}"
# 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 = [] imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push]
if REGISTRY_DOCKERHUB in registries: imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push]
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 # 3. build
cmd = [ cmd = [
@@ -180,9 +155,7 @@ def main():
for img in imgs: for img in imgs:
cmd += ["--tag", img] cmd += ["--tag", img]
if args.push: if args.push:
cmd += ["--push"] cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"]
if not args.no_cache_to:
cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"]
if args.load: if args.load:
cmd += ["--load"] cmd += ["--load"]
@@ -190,22 +163,20 @@ def main():
elif args.command == "manifest": elif args.command == "manifest":
manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to
targets = [] targets = [f"{manifest}:{tag}" for tag in tags_to_push]
if REGISTRY_DOCKERHUB in registries: targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push]
targets += [f"{manifest}:{tag}" for tag in tags_to_push] # 1. Create manifests
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: for target in targets:
cmd = ["docker", "buildx", "imagetools", "create", "--tag", target] cmd = ["docker", "manifest", "create", target]
for arch in ARCHS: for arch in ARCHS:
src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}" src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}"
if target.startswith("ghcr.io"): if target.startswith("ghcr.io"):
src = f"ghcr.io/{src}" src = f"ghcr.io/{src}"
cmd.append(src) cmd.append(src)
run_command(*cmd) run_command(*cmd)
# 2. Push manifests
for target in targets:
run_command("docker", "manifest", "push", target)
if __name__ == "__main__": if __name__ == "__main__":
-13
View File
@@ -21,23 +21,10 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" 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 # If /build is mounted, use that as the build path
# otherwise use path in /config (so that builds aren't lost on container restart) # otherwise use path in /config (so that builds aren't lost on container restart)
if [[ -d /build ]]; then if [[ -d /build ]]; then
export ESPHOME_BUILD_PATH=/build export ESPHOME_BUILD_PATH=/build
fi 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 "$@" exec esphome "$@"
@@ -0,0 +1,22 @@
#!/usr/bin/with-contenv bashio
# ==============================================================================
# Installs the latest prerelease of esphome-device-builder when the
# `use_new_device_builder` config option is enabled.
# This is a temporary install-on-boot step until esphome-device-builder
# becomes a direct dependency of esphome.
# ==============================================================================
if ! bashio::config.true 'use_new_device_builder'; then
exit 0
fi
bashio::log.info "Installing latest prerelease of esphome-device-builder..."
if command -v uv > /dev/null; then
uv pip install --system --no-cache-dir --prerelease=allow --upgrade \
esphome-device-builder ||
bashio::exit.nok "Failed installing esphome-device-builder."
else
pip install --no-cache-dir --pre --upgrade esphome-device-builder ||
bashio::exit.nok "Failed installing esphome-device-builder."
fi
bashio::log.info "Installed esphome-device-builder."
@@ -0,0 +1,96 @@
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;
}
@@ -0,0 +1,16 @@
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 "";
@@ -0,0 +1,8 @@
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;
@@ -0,0 +1,8 @@
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;
@@ -0,0 +1,3 @@
upstream esphome {
server unix:/var/run/esphome.sock;
}
@@ -0,0 +1,30 @@
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;
}
@@ -0,0 +1 @@
Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley)
@@ -0,0 +1,28 @@
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;
}
}
@@ -0,0 +1,18 @@
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;
}
}
@@ -16,7 +16,7 @@ fi
port=$(bashio::addon.ingress_port) port=$(bashio::addon.ingress_port)
# Wait for the ESPHome Device Builder to become available # Wait for NGINX to become available
bashio::net.wait_for "${port}" "127.0.0.1" 300 bashio::net.wait_for "${port}" "127.0.0.1" 300
config=$(\ config=$(\
@@ -2,7 +2,7 @@
# shellcheck shell=bash # shellcheck shell=bash
# ============================================================================== # ==============================================================================
# Home Assistant Community Add-on: ESPHome # Home Assistant Community Add-on: ESPHome
# Take down the S6 supervision tree when ESPHome Device Builder fails # Take down the S6 supervision tree when ESPHome dashboard fails
# ============================================================================== # ==============================================================================
declare exit_code declare exit_code
readonly exit_code_container=$(</run/s6-linux-init-container-results/exitcode) readonly exit_code_container=$(</run/s6-linux-init-container-results/exitcode)
@@ -10,7 +10,7 @@ readonly exit_code_service="${1}"
readonly exit_code_signal="${2}" readonly exit_code_signal="${2}"
bashio::log.info \ bashio::log.info \
"Service ESPHome Device Builder exited with code ${exit_code_service}" \ "Service ESPHome dashboard exited with code ${exit_code_service}" \
"(by signal ${exit_code_signal})" "(by signal ${exit_code_signal})"
if [[ "${exit_code_service}" -eq 256 ]]; then if [[ "${exit_code_service}" -eq 256 ]]; then
@@ -2,7 +2,7 @@
# shellcheck shell=bash # shellcheck shell=bash
# ============================================================================== # ==============================================================================
# Community Hass.io Add-ons: ESPHome # Community Hass.io Add-ons: ESPHome
# Runs the ESPHome Device Builder # Runs the ESPHome dashboard
# ============================================================================== # ==============================================================================
readonly pio_cache_base=/data/cache/platformio readonly pio_cache_base=/data/cache/platformio
@@ -15,15 +15,18 @@ export PLATFORMIO_PLATFORMS_DIR="${pio_cache_base}/platforms"
export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages" export PLATFORMIO_PACKAGES_DIR="${pio_cache_base}/packages"
export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache" export PLATFORMIO_CACHE_DIR="${pio_cache_base}/cache"
# Keep the native toolchain installs on the persistent /data volume, not the
# container's ephemeral user cache dir (wiped on every add-on update/restart).
export ESPHOME_ESP_IDF_PREFIX=/data/cache/idf
export ESPHOME_SDK_NRF_PREFIX=/data/cache/sdk-nrf
if bashio::config.true 'leave_front_door_open'; then if bashio::config.true 'leave_front_door_open'; then
export DISABLE_HA_AUTHENTICATION=true export DISABLE_HA_AUTHENTICATION=true
fi fi
if bashio::config.true 'streamer_mode'; then
export ESPHOME_STREAMER_MODE=true
fi
if bashio::config.has_value 'relative_url'; then
export ESPHOME_DASHBOARD_RELATIVE_URL=$(bashio::config 'relative_url')
fi
if bashio::config.has_value 'default_compile_process_limit'; then if bashio::config.has_value 'default_compile_process_limit'; then
export ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT=$(bashio::config 'default_compile_process_limit') export ESPHOME_DEFAULT_COMPILE_PROCESS_LIMIT=$(bashio::config 'default_compile_process_limit')
else else
@@ -46,21 +49,12 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then
rm -rf /config/esphome/.esphome rm -rf /config/esphome/.esphome
fi fi
# Only signal device-builder to expose the public LAN port when the operator if bashio::config.true 'use_new_device_builder'; then
# mapped port 6052, matching the legacy dashboard where nginx listened on the bashio::log.info "Starting ESPHome Device Builder..."
# fixed port 6052 only when it was configured. We use the mapping purely as a exec esphome-device-builder /config/esphome \
# presence check and don't forward the published value; device-builder binds --ha-addon \
# its default port 6052 (the fixed container port, as the legacy --ingress-port "$(bashio::addon.ingress_port)"
# "listen 6052" did). --ha-addon-allow-public is inert on its own: the no-auth
# gate is the DISABLE_HA_AUTHENTICATION env var set above, so both opt-ins are
# required to bind 6052 unauthenticated; either alone stays ingress-only.
set --
if bashio::var.has_value "$(bashio::addon.port 6052)"; then
set -- --ha-addon-allow-public
fi fi
bashio::log.info "Starting ESPHome Device Builder..." bashio::log.info "Starting ESPHome dashboard..."
exec esphome-device-builder /config/esphome \ exec esphome dashboard /config/esphome --socket /var/run/esphome.sock --ha-addon
--ha-addon \
--ingress-port "$(bashio::addon.ingress_port)" \
"$@"
@@ -0,0 +1,35 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
# ==============================================================================
# Community Hass.io Add-ons: ESPHome
# Configures NGINX for use with ESPHome
# ==============================================================================
# When the new device builder is enabled it serves HA ingress directly,
# so nginx is not used at all -- skip configuration.
if bashio::config.true 'use_new_device_builder'; then
bashio::log.info "Skipping NGINX setup: new device builder serves ingress directly."
bashio::exit.ok
fi
mkdir -p /var/log/nginx
# Generate Ingress configuration
bashio::var.json \
interface "$(bashio::addon.ip_address)" \
port "^$(bashio::addon.ingress_port)" \
| tempio \
-template /etc/nginx/templates/ingress.gtpl \
-out /etc/nginx/servers/ingress.conf
# Generate direct access configuration, if enabled.
if bashio::var.has_value "$(bashio::addon.port 6052)"; then
bashio::config.require.ssl
bashio::var.json \
certfile "$(bashio::config 'certfile')" \
keyfile "$(bashio::config 'keyfile')" \
ssl "^$(bashio::config 'ssl')" \
| tempio \
-template /etc/nginx/templates/direct.gtpl \
-out /etc/nginx/servers/direct.conf
fi
@@ -0,0 +1 @@
oneshot
@@ -0,0 +1 @@
/etc/s6-overlay/s6-rc.d/init-nginx/run
+25
View File
@@ -0,0 +1,25 @@
#!/command/with-contenv bashio
# ==============================================================================
# Community Hass.io Add-ons: ESPHome
# Take down the S6 supervision tree when NGINX fails
# ==============================================================================
declare exit_code
readonly exit_code_container=$(</run/s6-linux-init-container-results/exitcode)
readonly exit_code_service="${1}"
readonly exit_code_signal="${2}"
bashio::log.info \
"Service NGINX exited with code ${exit_code_service}" \
"(by signal ${exit_code_signal})"
if [[ "${exit_code_service}" -eq 256 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo $((128 + $exit_code_signal)) > /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
+23
View File
@@ -0,0 +1,23 @@
#!/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
@@ -0,0 +1 @@
longrun
-7
View File
@@ -1,7 +0,0 @@
esphome:
name: docker-test-bk72xx-arduino
bk72xx:
board: generic-bk7231n-qfn32-tuya
logger:
@@ -1,10 +0,0 @@
esphome:
name: docker-test-esp32-ard-idf
esp32:
variant: esp32
framework:
type: arduino
toolchain: esp-idf
logger:
@@ -1,10 +0,0 @@
esphome:
name: docker-test-esp32-ard-pio
esp32:
variant: esp32
framework:
type: arduino
toolchain: platformio
logger:
@@ -1,10 +0,0 @@
esphome:
name: docker-test-esp32-idf-idf
esp32:
variant: esp32
framework:
type: esp-idf
toolchain: esp-idf
logger:
@@ -1,10 +0,0 @@
esphome:
name: docker-test-esp32-idf-pio
esp32:
variant: esp32
framework:
type: esp-idf
toolchain: platformio
logger:
-7
View File
@@ -1,7 +0,0 @@
esphome:
name: docker-test-esp8266-arduino
esp8266:
board: d1_mini
logger:
-6
View File
@@ -1,6 +0,0 @@
esphome:
name: docker-test-host
host:
logger:
-7
View File
@@ -1,7 +0,0 @@
esphome:
name: docker-test-ln882x-arduino
ln882x:
board: generic-ln882h
logger:
-8
View File
@@ -1,8 +0,0 @@
esphome:
name: docker-test-nrf52
nrf52:
board: adafruit_itsybitsy_nrf52840
bootloader: adafruit_nrf52_sd140_v6
logger:
-7
View File
@@ -1,7 +0,0 @@
esphome:
name: docker-test-rp2040-arduino
rp2040:
variant: rp2040
logger:
-7
View File
@@ -1,7 +0,0 @@
esphome:
name: docker-test-rtl87xx-arduino
rtl87xx:
board: generic-rtl8710bn-2mb-788k
logger:
+98 -186
View File
@@ -28,13 +28,13 @@ from esphome.const import (
ALLOWED_NAME_CHARS, ALLOWED_NAME_CHARS,
ARGUMENT_HELP_DEVICE, ARGUMENT_HELP_DEVICE,
CONF_API, CONF_API,
CONF_AUTH,
CONF_BAUD_RATE, CONF_BAUD_RATE,
CONF_BROKER, CONF_BROKER,
CONF_DEASSERT_RTS_DTR, CONF_DEASSERT_RTS_DTR,
CONF_DISABLED, CONF_DISABLED,
CONF_ESPHOME, CONF_ESPHOME,
CONF_LEVEL, CONF_LEVEL,
CONF_LOG,
CONF_LOG_TOPIC, CONF_LOG_TOPIC,
CONF_LOGGER, CONF_LOGGER,
CONF_MDNS, CONF_MDNS,
@@ -48,10 +48,15 @@ from esphome.const import (
CONF_PORT, CONF_PORT,
CONF_SUBSTITUTIONS, CONF_SUBSTITUTIONS,
CONF_TOPIC, CONF_TOPIC,
CONF_VERSION, CONF_USERNAME,
CONF_WEB_SERVER, CONF_WEB_SERVER,
CONF_WIFI, CONF_WIFI,
ENV_NOGITIGNORE, ENV_NOGITIGNORE,
KEY_CORE,
KEY_TARGET_PLATFORM,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
SECRETS_FILES, SECRETS_FILES,
Toolchain, Toolchain,
) )
@@ -225,9 +230,8 @@ def _discover_mac_suffix_devices() -> list[str] | None:
Returns: Returns:
- ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off, - ``None`` when discovery isn't applicable (``name_add_mac_suffix`` off,
mDNS disabled, or ``CORE.address`` isn't a ``.local`` mDNS address). mDNS disabled, or ``CORE.address`` is already an IP). Callers should
Callers should then fall back to whatever default OTA address they then fall back to whatever default OTA address they normally use.
normally use.
- ``[]`` when discovery ran but found nothing. Callers should NOT fall - ``[]`` when discovery ran but found nothing. Callers should NOT fall
back to the base name: with ``name_add_mac_suffix`` enabled, the base back to the base name: with ``name_add_mac_suffix`` enabled, the base
name by definition doesn't exist on the network. name by definition doesn't exist on the network.
@@ -237,7 +241,7 @@ def _discover_mac_suffix_devices() -> list[str] | None:
``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we ``aioesphomeapi`` via :func:`_resolve_network_devices`) reuses the IPs we
already have without opening a second Zeroconf client. already have without opening a second Zeroconf client.
""" """
if not (has_name_add_mac_suffix() and has_mdns() and has_mdns_address()): if not (has_name_add_mac_suffix() and has_mdns() and has_non_ip_address()):
return None return None
from esphome.zeroconf import discover_mdns_devices from esphome.zeroconf import discover_mdns_devices
@@ -264,36 +268,6 @@ def _ota_hostnames_for_default(purpose: Purpose) -> list[str]:
return _resolve_with_cache(CORE.address, purpose) return _resolve_with_cache(CORE.address, purpose)
def _unresolved_default_error(purpose: Purpose, defaults: list[str]) -> str:
"""Build the error when a default device target produced no usable host.
When the OTA default was requested and the address resolves but the config
lacks the transport the purpose needs (``api:`` for logs, an ``ota:``
platform for uploads), name that gap instead of the misleading
"could not be resolved" / set-use_address hint.
"""
if "OTA" in defaults and has_resolvable_address():
if purpose == Purpose.LOGGING and not has_api():
return (
"Cannot view logs over the network: no 'api:' component is "
"configured. Network log streaming requires the native API; add "
"an 'api:' component, enable MQTT logging, or view logs over USB."
)
if purpose == Purpose.UPLOADING and not has_ota():
return (
"Cannot upload over the network: no 'ota:' platform is "
"configured. Add an 'ota:' platform, or upload over USB."
)
if CORE.dashboard:
hint = "If you know the IP, set 'use_address' in your network config."
else:
hint = "If you know the IP, try --device <IP>"
return (
f"All specified devices {defaults} could not be resolved. "
f"Is the device connected to the network? {hint}"
)
def choose_upload_log_host( def choose_upload_log_host(
default: list[str] | str | None, default: list[str] | str | None,
check_default: str | None, check_default: str | None,
@@ -317,12 +291,9 @@ def choose_upload_log_host(
] ]
resolved.append(choose_prompt(options, purpose=purpose)) resolved.append(choose_prompt(options, purpose=purpose))
elif device == "OTA": 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 # ensure IP adresses are used first
if is_ip_address(CORE.address) and ( if is_ip_address(CORE.address) and (
(purpose == Purpose.LOGGING and network_logging) (purpose == Purpose.LOGGING and has_api())
or (purpose == Purpose.UPLOADING and has_ota()) or (purpose == Purpose.UPLOADING and has_ota())
): ):
resolved.extend(_resolve_with_cache(CORE.address, purpose)) resolved.extend(_resolve_with_cache(CORE.address, purpose))
@@ -334,11 +305,7 @@ def choose_upload_log_host(
if has_mqtt_logging(): if has_mqtt_logging():
resolved.append("MQTT") resolved.append("MQTT")
if ( if has_api() and has_non_ip_address() and has_resolvable_address():
network_logging
and has_non_ip_address()
and has_resolvable_address()
):
resolved.extend(_ota_hostnames_for_default(purpose)) resolved.extend(_ota_hostnames_for_default(purpose))
elif purpose == Purpose.UPLOADING: elif purpose == Purpose.UPLOADING:
@@ -350,7 +317,14 @@ def choose_upload_log_host(
else: else:
resolved.append(device) resolved.append(device)
if not resolved: if not resolved:
raise EsphomeError(_unresolved_default_error(purpose, defaults)) 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 <IP>"
raise EsphomeError(
f"All specified devices {defaults} could not be resolved. "
f"Is the device connected to the network? {hint}"
)
return resolved return resolved
# No devices specified, show interactive chooser # No devices specified, show interactive chooser
@@ -362,7 +336,7 @@ def choose_upload_log_host(
bootsel_permission_error = False bootsel_permission_error = False
if ( if (
purpose == Purpose.UPLOADING purpose == Purpose.UPLOADING
and CORE.is_rp2 and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
and (picotool := _find_picotool()) is not None and (picotool := _find_picotool()) is not None
): ):
bootsel = detect_rp2040_bootsel(picotool) bootsel = detect_rp2040_bootsel(picotool)
@@ -400,7 +374,7 @@ def choose_upload_log_host(
mqtt_config = CORE.config[CONF_MQTT] mqtt_config = CORE.config[CONF_MQTT]
options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT")) options.append((f"MQTT ({mqtt_config[CONF_BROKER]})", "MQTT"))
if has_api() or has_web_server_logging(): if has_api():
add_ota_options() add_ota_options()
elif purpose == Purpose.UPLOADING and has_ota(): elif purpose == Purpose.UPLOADING and has_ota():
@@ -409,7 +383,7 @@ def choose_upload_log_host(
# Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found # Show helpful BOOTSEL instructions for RP2040 when no BOOTSEL device is found
if ( if (
purpose == Purpose.UPLOADING purpose == Purpose.UPLOADING
and CORE.is_rp2 and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options) and not any(get_port_type(opt[1]) == PortType.BOOTSEL for opt in options)
): ):
if bootsel_permission_error: if bootsel_permission_error:
@@ -493,21 +467,6 @@ 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: def has_mqtt_ip_lookup() -> bool:
"""Check if MQTT is available and IP lookup is supported.""" """Check if MQTT is available and IP lookup is supported."""
from esphome.components.mqtt import CONF_DISCOVER_IP from esphome.components.mqtt import CONF_DISCOVER_IP
@@ -526,22 +485,17 @@ def has_mdns() -> bool:
def has_non_ip_address() -> 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) 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: 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) return CORE.address is not None and is_ip_address(CORE.address)
def has_resolvable_address() -> bool: 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 # Any address (IP, mDNS hostname, or regular DNS hostname) is resolvable
# The resolve_ip_address() function in helpers.py handles all types via AsyncResolver # The resolve_ip_address() function in helpers.py handles all types via AsyncResolver
if CORE.address is None: if CORE.address is None:
@@ -550,17 +504,11 @@ def has_resolvable_address() -> bool:
if has_ip_address(): if has_ip_address():
return True 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(): if has_mdns():
return True return True
# .local mDNS hostnames are only resolvable if mDNS is enabled # .local mDNS hostnames are only resolvable if mDNS is enabled
return not has_mdns_address() return not CORE.address.endswith(".local")
def has_name_add_mac_suffix() -> bool: def has_name_add_mac_suffix() -> bool:
@@ -730,7 +678,6 @@ def _wrap_to_code(name, comp, yaml_util):
@functools.wraps(comp.to_code) @functools.wraps(comp.to_code)
async def wrapped(conf): async def wrapped(conf):
cg.add(cg.ComponentMarker(name))
cg.add(cg.LineComment(f"{name}:")) cg.add(cg.LineComment(f"{name}:"))
if comp.config_schema is not None: if comp.config_schema is not None:
conf_str = yaml_util.dump(conf) conf_str = yaml_util.dump(conf)
@@ -748,11 +695,6 @@ def _wrap_to_code(name, comp, yaml_util):
def write_cpp(config: ConfigType) -> int: def write_cpp(config: ConfigType) -> int:
from esphome import writer 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): if not get_bool_env(ENV_NOGITIGNORE):
writer.write_gitignore() writer.write_gitignore()
@@ -1008,7 +950,7 @@ def upload_using_platformio(config: ConfigType, port: str) -> int:
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for # RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
# the upload target, but 'nobuild' skips the build phase that creates it. # the upload target, but 'nobuild' skips the build phase that creates it.
# Create it here so the upload doesn't fail. # Create it here so the upload doesn't fail.
if CORE.is_rp2: if CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040:
idedata = toolchain.get_idedata(config) idedata = toolchain.get_idedata(config)
build_dir = Path(idedata.firmware_elf_path).parent build_dir = Path(idedata.firmware_elf_path).parent
firmware_bin = build_dir / "firmware.bin" firmware_bin = build_dir / "firmware.bin"
@@ -1193,10 +1135,10 @@ def upload_program(
check_permissions(host) check_permissions(host)
exit_code = 1 exit_code = 1
if CORE.is_esp32 or CORE.is_esp8266: if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266):
file = getattr(args, "file", None) file = getattr(args, "file", None)
exit_code = upload_using_esptool(config, host, file, args.upload_speed) exit_code = upload_using_esptool(config, host, file, args.upload_speed)
elif CORE.is_rp2 or CORE.is_libretiny: elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny:
exit_code = upload_using_platformio(config, host) exit_code = upload_using_platformio(config, host)
# else: Unknown target platform, exit_code remains 1 # else: Unknown target platform, exit_code remains 1
@@ -1314,23 +1256,25 @@ def _upload_via_native_api(
def _upload_via_web_server( def _upload_via_web_server(
config: ConfigType, network_devices: list[str], binary: Path config: ConfigType, network_devices: list[str], binary: Path
) -> tuple[int, str | None]: ) -> 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 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( return web_server_ota.run_ota(
network_devices, remote_port, username, password, binary 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 # 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 # 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 # bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
@@ -1459,13 +1403,6 @@ def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int
config, args.topic, args.username, args.password, args.client_id 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)") raise EsphomeError("No remote or local logging method configured (api/mqtt/logger)")
@@ -1486,16 +1423,7 @@ def command_wizard(args: ArgsProtocol) -> int | None:
def command_config(args: ArgsProtocol, config: ConfigType) -> int | None: def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import yaml_util from esphome import yaml_util
if getattr(args, "no_defaults", False): if not CORE.verbose:
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) config = strip_default_ids(config)
output = yaml_util.dump(config, args.show_secrets) output = yaml_util.dump(config, args.show_secrets)
if not args.show_secrets: if not args.show_secrets:
@@ -1522,29 +1450,12 @@ _LEGACY_REDACTION_REMOVAL = "2026.12.0"
def _redact_with_legacy_fallback(output: str) -> str: def _redact_with_legacy_fallback(output: str) -> str:
unmarked: set[str] = set() 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") def _replace(m: re.Match[str]) -> str:
for i, line in enumerate(lines): unmarked.add(m.group("key"))
# A non-indented, non-blank line is a top-level key that opens or return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m"
# closes the substitutions block.
if line and not line[0].isspace(): output = _LEGACY_REDACTION_RE.sub(_replace, output)
in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:")
m = _LEGACY_REDACTION_RE.search(line)
if m is None:
continue
if not in_substitutions:
unmarked.add(m.group("key"))
lines[i] = (
f"{line[: m.start()]}{m.group('key')}: "
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
)
output = "\n".join(lines)
for key in sorted(unmarked): for key in sorted(unmarked):
_LOGGER.warning( _LOGGER.warning(
"Field '%s' is being redacted by a legacy substring heuristic. " "Field '%s' is being redacted by a legacy substring heuristic. "
@@ -1675,7 +1586,10 @@ def command_run(args: ArgsProtocol, config: ConfigType) -> int | None:
# After BOOTSEL upload, wait for a new serial port to appear # After BOOTSEL upload, wait for a new serial port to appear
# so it shows up in the log chooser # so it shows up in the log chooser
if successful_device is None and CORE.is_rp2: if (
successful_device is None
and CORE.data.get(KEY_CORE, {}).get(KEY_TARGET_PLATFORM) == PLATFORM_RP2040
):
_wait_for_serial_port(known_ports=pre_upload_ports) _wait_for_serial_port(known_ports=pre_upload_ports)
# If exactly one new serial port appeared, use it directly # If exactly one new serial port appeared, use it directly
serial_ports = get_serial_ports() serial_ports = get_serial_ports()
@@ -1717,7 +1631,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import writer from esphome import writer
try: try:
writer.clean_build(full=True) writer.clean_build()
except OSError as err: except OSError as err:
_LOGGER.error("Error deleting build files: %s", err) _LOGGER.error("Error deleting build files: %s", err)
return 1 return 1
@@ -1758,13 +1672,9 @@ def command_bundle(args: ArgsProtocol, config: ConfigType) -> int | None:
def command_dashboard(args: ArgsProtocol) -> int | None: def command_dashboard(args: ArgsProtocol) -> int | None:
raise EsphomeError( from esphome.dashboard import dashboard
"The built-in dashboard has been removed from ESPHome. "
"Install and run ESPHome Device Builder instead:\n" return dashboard.start_dashboard(args)
" pip install esphome-device-builder\n"
" esphome-device-builder\n"
"See https://github.com/esphome/device-builder for more information."
)
def run_multiple_configs( def run_multiple_configs(
@@ -1841,21 +1751,6 @@ def command_update_all(args: ArgsProtocol) -> int | None:
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int: def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json 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: if not CORE.using_toolchain_platformio:
_LOGGER.error( _LOGGER.error(
"The idedata command is not compatible with %s toolchain", "The idedata command is not compatible with %s toolchain",
@@ -2252,12 +2147,6 @@ def parse_args(argv):
parser_config.add_argument( parser_config.add_argument(
"--show-secrets", help="Show secrets in output.", action="store_true" "--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( parser_config_hash = subparsers.add_parser(
"config-hash", help="Calculate the hash of the configuration." "config-hash", help="Calculate the hash of the configuration."
@@ -2420,31 +2309,50 @@ def parse_args(argv):
) )
parser_clean_all = subparsers.add_parser( parser_clean_all = subparsers.add_parser(
"clean-all", "clean-all", help="Clean all build and platform files."
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( parser_clean_all.add_argument(
"configuration", help="Your YAML file or configuration directory.", nargs="*" "configuration", help="Your YAML file or configuration directory.", nargs="*"
) )
# The dashboard moved to ESPHome Device Builder; the command is kept only to parser_dashboard = subparsers.add_parser(
# print a redirect (see command_dashboard). Accept and ignore the old flags "dashboard", help="Create a simple web server for a dashboard."
# 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( parser_dashboard.add_argument(
"--ha-addon", action="store_true", help=argparse.SUPPRESS "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
) )
parser_vscode = subparsers.add_parser("vscode") parser_vscode = subparsers.add_parser("vscode")
@@ -2539,7 +2447,11 @@ def run_esphome(argv):
elif args.quiet: elif args.quiet:
args.log_level = "CRITICAL" args.log_level = "CRITICAL"
setup_log(log_level=args.log_level) setup_log(
log_level=args.log_level,
# Show timestamp for dashboard access logs
include_timestamp=args.command == "dashboard",
)
if args.command in PRE_CONFIG_ACTIONS: if args.command in PRE_CONFIG_ACTIONS:
try: try:
+28 -10
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable from collections.abc import Callable
import heapq import heapq
import json
from operator import itemgetter from operator import itemgetter
from pathlib import Path from pathlib import Path
import sys import sys
@@ -21,7 +20,6 @@ from . import (
RAM_SECTIONS, RAM_SECTIONS,
MemoryAnalyzer, MemoryAnalyzer,
) )
from .toolchain import find_elf_path, find_idedata_path, idedata_candidates
if TYPE_CHECKING: if TYPE_CHECKING:
from . import ComponentMemory from . import ComponentMemory
@@ -43,7 +41,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
# Symbol size threshold for detailed analysis # Symbol size threshold for detailed analysis
SYMBOL_SIZE_THRESHOLD: int = ( SYMBOL_SIZE_THRESHOLD: int = (
10 # Show symbols larger than this in detailed analysis 100 # Show symbols larger than this in detailed analysis
) )
# Lower threshold for RAM symbols (RAM is more constrained) # Lower threshold for RAM symbols (RAM is more constrained)
RAM_SYMBOL_SIZE_THRESHOLD: int = 24 RAM_SYMBOL_SIZE_THRESHOLD: int = 24
@@ -761,25 +759,45 @@ def main():
print(f"Error: {build_path} is not a directory", file=sys.stderr) print(f"Error: {build_path} is not a directory", file=sys.stderr)
sys.exit(1) sys.exit(1)
elf_path = find_elf_path(build_path) # Find firmware.elf
if not elf_path: elf_file = None
print(f"Error: no firmware ELF found in {build_dir}", file=sys.stderr) 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)
sys.exit(1) sys.exit(1)
elf_file = str(elf_path)
# 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",
]
idedata = None idedata = None
if idedata_path := find_idedata_path(build_path): for idedata_path in idedata_candidates:
if not idedata_path.exists():
continue
try: try:
with idedata_path.open(encoding="utf-8") as f: with idedata_path.open(encoding="utf-8") as f:
raw_data = json.load(f) raw_data = json.load(f)
idedata = IDEData(raw_data) idedata = IDEData(raw_data)
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr) print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
break
except (json.JSONDecodeError, OSError) as e: except (json.JSONDecodeError, OSError) as e:
print(f"Warning: Failed to load idedata: {e}", file=sys.stderr) print(f"Warning: Failed to load idedata: {e}", file=sys.stderr)
if not idedata: if not idedata:
searched = "\n ".join(str(p) for p in idedata_candidates(build_path)) print(
print(f"Warning: idedata not found, searched:\n {searched}", file=sys.stderr) f"Warning: idedata not found (searched {idedata_candidates[0]} and {idedata_candidates[1]})",
file=sys.stderr,
)
analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata) analyzer = MemoryAnalyzerCLI(elf_file, idedata=idedata)
analyzer.analyze() analyzer.analyze()
+10 -34
View File
@@ -8,7 +8,7 @@ memory-constrained platforms like ESP8266.
from __future__ import annotations from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass
import logging import logging
from pathlib import Path from pathlib import Path
import re import re
@@ -65,7 +65,6 @@ class RamSymbol:
size: int size: int
section: str section: str
demangled: str = "" # Demangled name, set after batch demangling demangled: str = "" # Demangled name, set after batch demangling
aliases: list[str] = field(default_factory=list) # Other names at same address
class RamStringsAnalyzer: class RamStringsAnalyzer:
@@ -236,11 +235,6 @@ class RamStringsAnalyzer:
except (subprocess.CalledProcessError, FileNotFoundError): except (subprocess.CalledProcessError, FileNotFoundError):
return 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"): for line in output.split("\n"):
parts = line.split() parts = line.split()
if len(parts) < 4: if len(parts) < 4:
@@ -259,18 +253,6 @@ class RamStringsAnalyzer:
if sym_type not in DATA_SYMBOL_TYPES: if sym_type not in DATA_SYMBOL_TYPES:
continue 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 # Check if symbol is in a RAM section
for section_name in self.ram_sections: for section_name in self.ram_sections:
if section_name not in self.sections: if section_name not in self.sections:
@@ -278,15 +260,15 @@ class RamStringsAnalyzer:
section = self.sections[section_name] section = self.sections[section_name]
if section.address <= addr < section.address + section.size: if section.address <= addr < section.address + section.size:
symbol = RamSymbol( self.ram_symbols.append(
name=name, RamSymbol(
sym_type=sym_type, name=name,
address=addr, sym_type=sym_type,
size=size, address=addr,
section=section_name, size=size,
section=section_name,
)
) )
symbols_by_addr[addr] = symbol
self.ram_symbols.append(symbol)
break break
def _demangle_symbols(self) -> None: def _demangle_symbols(self) -> None:
@@ -454,13 +436,7 @@ class RamStringsAnalyzer:
for symbol in largest_symbols: for symbol in largest_symbols:
# Use demangled name if available, otherwise raw name # Use demangled name if available, otherwise raw name
display_name = symbol.demangled or symbol.name display_name = symbol.demangled or symbol.name
# Truncate the name, not the alias note, so merged aliases stay name_display = display_name[:49] if len(display_name) > 49 else display_name
# 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( lines.append(
f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}" f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}"
) )
-72
View File
@@ -23,78 +23,6 @@ TOOLCHAIN_PREFIXES = [
] ]
def find_elf_path(build_path: Path) -> Path | None:
"""Locate the firmware ELF inside an ESPHome build directory.
The layout depends on the toolchain that produced the build, so try each
known one in turn.
Args:
build_path: Path to an ESPHome build directory
Returns:
Path to the ELF file, or None if no known layout matches
"""
name = build_path.name
for candidate in (
# Native ESP-IDF: idf.py writes build/<name>.elf, which ESPHome copies
# to build/firmware.elf (see espidf.toolchain.create_elf_copy)
build_path / "build" / "firmware.elf",
# PlatformIO
build_path / "firmware.elf",
build_path / ".pioenvs" / name / "firmware.elf",
# LibreTiny uses raw_firmware.elf
build_path / "raw_firmware.elf",
build_path / ".pioenvs" / name / "raw_firmware.elf",
# Zephyr (nRF52); the SDK nests the artifacts one level deeper from 2.9.2
build_path / ".pioenvs" / name / "zephyr" / "zephyr" / "zephyr.elf",
build_path / ".pioenvs" / name / "zephyr" / "zephyr.elf",
):
if candidate.is_file():
return candidate
return None
def idedata_candidates(build_path: Path) -> list[Path]:
"""Return the idedata locations searched for a build directory, in order.
Exposed so a caller reporting "not found" can name the paths it tried
without keeping its own copy of the list.
Args:
build_path: Path to an ESPHome build directory
Returns:
The candidate idedata JSON paths, most specific first
"""
name = build_path.name
return [
# In .pioenvs for test builds
build_path / ".pioenvs" / name / "idedata.json",
# Both toolchains cache it in the data dir, which holds this build dir:
# <data_dir>/idedata/<name>.json next to <data_dir>/build/<name>
build_path.parent.parent / "idedata" / f"{name}.json",
# Regular builds, invoked from the config dir or from anywhere
Path.cwd() / ".esphome" / "idedata" / f"{name}.json",
Path.home() / ".esphome" / "idedata" / f"{name}.json",
]
def find_idedata_path(build_path: Path) -> Path | None:
"""Locate the idedata JSON belonging to an ESPHome build directory.
Args:
build_path: Path to an ESPHome build directory
Returns:
Path to the idedata JSON, or None if it was not found
"""
for candidate in idedata_candidates(build_path):
if candidate.is_file():
return candidate
return None
def _find_in_platformio_packages(tool_name: str) -> str | None: def _find_in_platformio_packages(tool_name: str) -> str | None:
"""Search for a tool in PlatformIO package directories. """Search for a tool in PlatformIO package directories.
+6 -3
View File
@@ -12,9 +12,12 @@ from __future__ import annotations
import asyncio import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
import threading import threading
from typing import Generic, TypeVar
_T = TypeVar("_T")
class AsyncThreadRunner[T](threading.Thread): class AsyncThreadRunner(threading.Thread, Generic[_T]):
"""Run an async coroutine in a daemon thread and expose its result. """Run an async coroutine in a daemon thread and expose its result.
The runner catches all exceptions from the coroutine and stores them in The runner catches all exceptions from the coroutine and stores them in
@@ -32,10 +35,10 @@ class AsyncThreadRunner[T](threading.Thread):
result = runner.result result = runner.result
""" """
def __init__(self, coro_factory: Callable[[], Awaitable[T]]) -> None: def __init__(self, coro_factory: Callable[[], Awaitable[_T]]) -> None:
super().__init__(daemon=True) super().__init__(daemon=True)
self._coro_factory = coro_factory self._coro_factory = coro_factory
self.result: T | None = None self.result: _T | None = None
self.exception: BaseException | None = None self.exception: BaseException | None = None
self.event = threading.Event() self.event = threading.Event()
+17 -37
View File
@@ -6,23 +6,8 @@ from pathlib import Path
from esphome.components.esp32 import get_esp32_variant, idf_version from esphome.components.esp32 import get_esp32_variant, idf_version
import esphome.config_validation as cv import esphome.config_validation as cv
from esphome.core import CORE from esphome.core import CORE
from esphome.framework_helpers import (
get_project_compile_flags,
get_project_cxx_compile_flags,
get_project_link_flags,
)
from esphome.helpers import mkdir_p, write_file_if_changed from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.writer import update_storage_json
# 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: def get_available_components() -> list[str] | None:
@@ -89,26 +74,17 @@ def get_project_cmakelists(minimal: bool = False) -> str:
# esphome__micro-mp3) rather than just src/. Required so suppressions # esphome__micro-mp3) rather than just src/. Required so suppressions
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in # like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
# third-party components we don't author. # third-party components we don't author.
project_compile_opts = get_project_compile_flags() project_compile_opts = [
flag
for flag in sorted(CORE.build_flags)
if flag.startswith("-D")
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
]
extra_compile_options = "\n".join( extra_compile_options = "\n".join(
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)' f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
for flag in project_compile_opts for flag in project_compile_opts
) )
# Flags registered via cg.add_cxx_build_flag() go on CXX_COMPILE_OPTIONS
# (not COMPILE_OPTIONS) because GCC warns when a C++-only flag such as
# -Wno-volatile is passed on a C compile.
cxx_compile_options = "\n".join(
f'idf_build_set_property(CXX_COMPILE_OPTIONS "{flag}" APPEND)'
for flag in get_project_cxx_compile_flags()
)
cpp_standard_options = (
CPP_STANDARD_TEMPLATE.format(standard=CORE.cpp_standard)
if CORE.cpp_standard
else ""
)
# Per-project list exposed as a CMake variable so converted PIO libs # Per-project list exposed as a CMake variable so converted PIO libs
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking # can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
# project-specific names into their cached CMakeLists. # project-specific names into their cached CMakeLists.
@@ -165,10 +141,6 @@ set(EXTRA_COMPONENT_DIRS ${{CMAKE_SOURCE_DIR}}/src)
include($ENV{{IDF_PATH}}/tools/cmake/project.cmake) include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{cpp_standard_options}
{cxx_compile_options}
{extra_compile_options} {extra_compile_options}
{managed_components_property} {managed_components_property}
@@ -198,8 +170,8 @@ def get_component_cmakelists() -> str:
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are # Extract linker options (-Wl, flags). Compile flags (-D, -W) are
# emitted project-wide via idf_build_set_property in # emitted project-wide via idf_build_set_property in
# get_project_cmakelists so they reach every component, not just src/. # get_project_cmakelists so they reach every component, not just src/.
link_opts = get_project_link_flags() link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")]
link_opts_str = "\n ".join(link_opts) if link_opts else "" link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else ""
return f"""\ return f"""\
# Auto-generated by ESPHome # Auto-generated by ESPHome
@@ -229,6 +201,9 @@ idf_component_register(
REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}} REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
) )
# Apply C++ standard
target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
# ESPHome linker options # ESPHome linker options
target_link_options(${{COMPONENT_LIB}} PUBLIC target_link_options(${{COMPONENT_LIB}} PUBLIC
{link_opts_str} {link_opts_str}
@@ -238,6 +213,11 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
def write_project(minimal: bool = False) -> None: def write_project(minimal: bool = False) -> None:
"""Write ESP-IDF project files.""" """Write ESP-IDF project files."""
# Refresh <data_dir>/storage/<name>.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.build_path)
mkdir_p(CORE.relative_src_path()) mkdir_p(CORE.relative_src_path())
+5 -18
View File
@@ -1,7 +1,7 @@
from esphome.const import __version__ from esphome.const import __version__
from esphome.core import CORE from esphome.core import CORE
from esphome.helpers import mkdir_p, read_file, write_file_if_changed from esphome.helpers import mkdir_p, read_file, write_file_if_changed
from esphome.writer import find_begin_end from esphome.writer import find_begin_end, update_storage_json
INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ===========" INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ==========="
INI_AUTO_GENERATE_END = "; =========== AUTO GENERATED CODE END ============" INI_AUTO_GENERATE_END = "; =========== AUTO GENERATED CODE END ============"
@@ -33,27 +33,12 @@ def format_ini(data: dict[str, str | list[str]]) -> str:
return content 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(): def get_ini_content():
CORE.add_platformio_option( CORE.add_platformio_option(
"lib_deps", "lib_deps",
[x.as_lib_dep for x in CORE.platformio_libraries.values()] [x.as_lib_dep for x in CORE.platformio_libraries.values()]
+ ["${common.lib_deps}"], + ["${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 # Sort to avoid changing build flags order
CORE.add_platformio_option("build_flags", sorted(CORE.build_flags)) CORE.add_platformio_option("build_flags", sorted(CORE.build_flags))
@@ -73,6 +58,7 @@ def get_ini_content():
def write_ini(content): def write_ini(content):
update_storage_json()
path = CORE.relative_build_path("platformio.ini") path = CORE.relative_build_path("platformio.ini")
if path.is_file(): if path.is_file():
@@ -108,6 +94,7 @@ Import("env")
def write_cxx_flags_script() -> None: def write_cxx_flags_script() -> None:
path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME) path = CORE.relative_build_path(CXX_FLAGS_FILE_NAME)
contents = CXX_FLAGS_FILE_CONTENTS contents = CXX_FLAGS_FILE_CONTENTS
for flag in sorted(CORE.cxx_build_flags): if not CORE.is_host:
contents += f'env.Append(CXXFLAGS=["{flag}"])\n' contents += 'env.Append(CXXFLAGS=["-Wno-volatile"])'
contents += "\n"
write_file_if_changed(path, contents) write_file_if_changed(path, contents)
+2 -35
View File
@@ -7,7 +7,7 @@ and compiled directly: ``esphome compile my_device.esphomebundle.tar.gz``
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass
from enum import StrEnum from enum import StrEnum
import io import io
import json import json
@@ -32,8 +32,6 @@ from esphome.core import CORE, EsphomeError
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
DOMAIN = "bundle"
BUNDLE_EXTENSION = ".esphomebundle.tar.gz" BUNDLE_EXTENSION = ".esphomebundle.tar.gz"
MANIFEST_FILENAME = "manifest.json" MANIFEST_FILENAME = "manifest.json"
CURRENT_MANIFEST_VERSION = 1 CURRENT_MANIFEST_VERSION = 1
@@ -122,32 +120,6 @@ def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
return keys return keys
@dataclass
class BundleData:
"""Files components asked to include, keyed under DOMAIN in CORE.data."""
extra_files: list[Path] = field(default_factory=list)
def _get_data() -> BundleData:
if DOMAIN not in CORE.data:
CORE.data[DOMAIN] = BundleData()
return CORE.data[DOMAIN]
def add_bundle_file(path: Path) -> None:
"""Register a file that a bundle must include.
Bundle discovery walks the validated config, so it only finds files the config
names. Components call this during validation for files it cannot see, such as a
file that is referenced from inside another file.
A relative path is taken as relative to the config directory. Files outside the
config directory are skipped when the bundle is built.
"""
_get_data().extra_files.append(CORE.relative_config_path(path))
@dataclass @dataclass
class BundleFile: class BundleFile:
"""A file to include in the bundle.""" """A file to include in the bundle."""
@@ -314,18 +286,13 @@ class ConfigBundleCreator:
with known file extensions are also resolved and checked. with known file extensions are also resolved and checked.
Core ESPHome concepts that use relative paths or directories Core ESPHome concepts that use relative paths or directories
are handled explicitly. Files the config does not name at all are are handled explicitly.
registered by their component with add_bundle_file().
""" """
config = self._config config = self._config
# Generic walk: find all file paths in the validated config # Generic walk: find all file paths in the validated config
self._walk_config_for_files(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 --- # --- Core ESPHome concepts needing explicit handling ---
# esphome.includes / includes_c - can be relative paths and directories # esphome.includes / includes_c - can be relative paths and directories
-2
View File
@@ -10,7 +10,6 @@
# pylint: disable=unused-import # pylint: disable=unused-import
from esphome.cpp_generator import ( # noqa: F401 from esphome.cpp_generator import ( # noqa: F401
ArrayInitializer, ArrayInitializer,
ComponentMarker,
Expression, Expression,
FlashStringLiteral, FlashStringLiteral,
LineComment, LineComment,
@@ -26,7 +25,6 @@ from esphome.cpp_generator import ( # noqa: F401
add, add,
add_build_flag, add_build_flag,
add_build_unflag, add_build_unflag,
add_cxx_build_flag,
add_define, add_define,
add_global, add_global,
add_library, add_library,
-6
View File
@@ -1,6 +0,0 @@
# Importing `esphome.loader` here installs the component-alias
# ``sys.meta_path`` finder before any submodule lookup runs. Without this,
# `from esphome.components import <legacy_alias>` from a fresh interpreter
# can race the finder install and raise ImportError, since the legacy
# alias dir no longer exists on disk.
from esphome import loader as _loader # noqa: F401
+1 -1
View File
@@ -25,7 +25,7 @@ void A01nyubComponent::check_buffer_() {
if (this->buffer_[3] == checksum) { if (this->buffer_[3] == checksum) {
float distance = (this->buffer_[1] << 8) + this->buffer_[2]; float distance = (this->buffer_[1] << 8) + this->buffer_[2];
if (distance > 280) { if (distance > 280) {
float meters = distance / 1000.0f; float meters = distance / 1000.0;
ESP_LOGV(TAG, "Distance from sensor: %f mm, %f m", distance, meters); ESP_LOGV(TAG, "Distance from sensor: %f mm, %f m", distance, meters);
this->publish_state(meters); this->publish_state(meters);
} else { } else {
+1 -1
View File
@@ -8,7 +8,7 @@
namespace esphome::a01nyub { namespace esphome::a01nyub {
class A01nyubComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { class A01nyubComponent : public sensor::Sensor, public Component, public uart::UARTDevice {
public: public:
// Nothing really public. // Nothing really public.
+1 -1
View File
@@ -8,7 +8,7 @@
namespace esphome::a02yyuw { namespace esphome::a02yyuw {
class A02yyuwComponent final : public sensor::Sensor, public Component, public uart::UARTDevice { class A02yyuwComponent : public sensor::Sensor, public Component, public uart::UARTDevice {
public: public:
// Nothing really public. // Nothing really public.
+1 -1
View File
@@ -6,7 +6,7 @@
namespace esphome::a4988 { namespace esphome::a4988 {
class A4988 final : public stepper::Stepper, public Component { class A4988 : public stepper::Stepper, public Component {
public: public:
void set_step_pin(GPIOPin *step_pin) { step_pin_ = step_pin; } void set_step_pin(GPIOPin *step_pin) { step_pin_ = step_pin; }
void set_dir_pin(GPIOPin *dir_pin) { dir_pin_ = dir_pin; } void set_dir_pin(GPIOPin *dir_pin) { dir_pin_ = dir_pin; }
@@ -13,7 +13,7 @@ enum SaturationVaporPressureEquation {
}; };
/// This class implements calculation of absolute humidity from temperature and relative humidity. /// This class implements calculation of absolute humidity from temperature and relative humidity.
class AbsoluteHumidityComponent final : public sensor::Sensor, public Component { class AbsoluteHumidityComponent : public sensor::Sensor, public Component {
public: public:
void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; } void set_temperature_sensor(sensor::Sensor *temperature_sensor) { this->temperature_sensor_ = temperature_sensor; }
void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; } void set_humidity_sensor(sensor::Sensor *humidity_sensor) { this->humidity_sensor_ = humidity_sensor; }
+1 -1
View File
@@ -216,7 +216,7 @@ void AcDimmer::setup() {
} }
void AcDimmer::write_state(float state) { void AcDimmer::write_state(float state) {
state = std::acos(1 - (2 * state)) / std::numbers::pi_v<float>; // RMS power compensation state = std::acos(1 - (2 * state)) / std::numbers::pi; // RMS power compensation
auto new_value = static_cast<uint16_t>(roundf(state * 65535)); auto new_value = static_cast<uint16_t>(roundf(state * 65535));
if (new_value != 0 && this->store_.value == 0) if (new_value != 0 && this->store_.value == 0)
this->store_.init_cycle = this->init_with_half_cycle_; this->store_.init_cycle = this->init_with_half_cycle_;
+1 -1
View File
@@ -41,7 +41,7 @@ struct AcDimmerDataStore {
#endif #endif
}; };
class AcDimmer final : public output::FloatOutput, public Component { class AcDimmer : public output::FloatOutput, public Component {
public: public:
void setup() override; void setup() override;
+4 -4
View File
@@ -227,12 +227,12 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = {
def validate_adc_pin(value): def validate_adc_pin(value):
if str(value).upper() == "VCC": if str(value).upper() == "VCC":
if CORE.is_rp2: if CORE.is_rp2040:
return pins.internal_gpio_input_pin_schema(29) return pins.internal_gpio_input_pin_schema(29)
return cv.only_on([PLATFORM_ESP8266])("VCC") return cv.only_on([PLATFORM_ESP8266])("VCC")
if str(value).upper() == "TEMPERATURE": if str(value).upper() == "TEMPERATURE":
return cv.only_on_rp2("TEMPERATURE") return cv.only_on_rp2040("TEMPERATURE")
if CORE.is_esp32: if CORE.is_esp32:
conf = pins.internal_gpio_input_pin_schema(value) conf = pins.internal_gpio_input_pin_schema(value)
@@ -261,11 +261,11 @@ def validate_adc_pin(value):
raise cv.Invalid("ESP8266: Only pin A0 (GPIO17) supports ADC") raise cv.Invalid("ESP8266: Only pin A0 (GPIO17) supports ADC")
return conf return conf
if CORE.is_rp2: if CORE.is_rp2040:
conf = pins.internal_gpio_input_pin_schema(value) conf = pins.internal_gpio_input_pin_schema(value)
number = conf[CONF_NUMBER] number = conf[CONF_NUMBER]
if number not in (26, 27, 28, 29): if number not in (26, 27, 28, 29):
raise cv.Invalid("RP2: Only pins 26, 27, 28 and 29 support ADC") raise cv.Invalid("RP2040: Only pins 26, 27, 28 and 29 support ADC")
return conf return conf
if CORE.is_libretiny: if CORE.is_libretiny:
+5 -5
View File
@@ -54,7 +54,7 @@ template<typename T> class Aggregator {
SamplingMode mode_{SamplingMode::AVG}; SamplingMode mode_{SamplingMode::AVG};
}; };
class ADCSensor final : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler { class ADCSensor : public sensor::Sensor, public PollingComponent, public voltage_sampler::VoltageSampler {
public: public:
/// Update the sensor's state by reading the current ADC value. /// Update the sensor's state by reading the current ADC value.
/// This method is called periodically based on the update interval. /// This method is called periodically based on the update interval.
@@ -123,9 +123,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
void set_autorange(bool autorange) { this->autorange_ = autorange; } void set_autorange(bool autorange) { this->autorange_ = autorange; }
#endif // USE_ESP32 #endif // USE_ESP32
#ifdef USE_RP2 #ifdef USE_RP2040
void set_is_temperature() { this->is_temperature_ = true; } void set_is_temperature() { this->is_temperature_ = true; }
#endif // USE_RP2 #endif // USE_RP2040
protected: protected:
uint8_t sample_count_{1}; uint8_t sample_count_{1};
@@ -152,9 +152,9 @@ class ADCSensor final : public sensor::Sensor, public PollingComponent, public v
static adc_oneshot_unit_handle_t shared_adc_handles[2]; static adc_oneshot_unit_handle_t shared_adc_handles[2];
#endif // USE_ESP32 #endif // USE_ESP32
#ifdef USE_RP2 #ifdef USE_RP2040
bool is_temperature_{false}; bool is_temperature_{false};
#endif // USE_RP2 #endif // USE_RP2040
#ifdef USE_ZEPHYR #ifdef USE_ZEPHYR
const struct adc_dt_spec *channel_ = nullptr; const struct adc_dt_spec *channel_ = nullptr;
@@ -1,4 +1,4 @@
#ifdef USE_RP2 #ifdef USE_RP2040
#include "adc_sensor.h" #include "adc_sensor.h"
#include "esphome/core/log.h" #include "esphome/core/log.h"
@@ -17,7 +17,7 @@
namespace esphome::adc { namespace esphome::adc {
static const char *const TAG = "adc.rp2"; static const char *const TAG = "adc.rp2040";
void ADCSensor::setup() { void ADCSensor::setup() {
static bool initialized = false; static bool initialized = false;
@@ -66,18 +66,15 @@ float ADCSensor::sample() {
} }
uint8_t pin = this->pin_->get_pin(); uint8_t pin = this->pin_->get_pin();
#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) #ifdef CYW43_USES_VSYS_PIN
if (pin == PICO_VSYS_PIN) { if (pin == PICO_VSYS_PIN) {
// Measuring VSYS on Raspberry Pico W needs to be wrapped with // Measuring VSYS on Raspberry Pico W needs to be wrapped with
// `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in // `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in
// https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and // 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(); cyw43_thread_enter();
} }
#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) #endif // CYW43_USES_VSYS_PIN
adc_gpio_init(pin); adc_gpio_init(pin);
adc_select_input(pin - 26); adc_select_input(pin - 26);
@@ -87,11 +84,11 @@ float ADCSensor::sample() {
aggr.add_sample(raw); aggr.add_sample(raw);
} }
#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) #ifdef CYW43_USES_VSYS_PIN
if (pin == PICO_VSYS_PIN) { if (pin == PICO_VSYS_PIN) {
cyw43_thread_exit(); cyw43_thread_exit();
} }
#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI) #endif // CYW43_USES_VSYS_PIN
if (this->output_raw_) { if (this->output_raw_) {
return aggr.aggregate(); return aggr.aggregate();
@@ -102,4 +99,4 @@ float ADCSensor::sample() {
} // namespace esphome::adc } // namespace esphome::adc
#endif // USE_RP2 #endif // USE_RP2040
+1 -1
View File
@@ -201,7 +201,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
PlatformFramework.ESP32_IDF, PlatformFramework.ESP32_IDF,
}, },
"adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "adc_sensor_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO},
"adc_sensor_rp2.cpp": {PlatformFramework.RP2_ARDUINO}, "adc_sensor_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO},
"adc_sensor_libretiny.cpp": { "adc_sensor_libretiny.cpp": {
PlatformFramework.BK72XX_ARDUINO, PlatformFramework.BK72XX_ARDUINO,
PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO,
+3 -3
View File
@@ -6,9 +6,9 @@
namespace esphome::adc128s102 { namespace esphome::adc128s102 {
class ADC128S102 final : public Component, class ADC128S102 : public Component,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::CLOCK_PHASE_LEADING, spi::DATA_RATE_10MHZ> { spi::DATA_RATE_10MHZ> {
public: public:
ADC128S102() = default; ADC128S102() = default;
@@ -9,10 +9,10 @@
namespace esphome::adc128s102 { namespace esphome::adc128s102 {
class ADC128S102Sensor final : public PollingComponent, class ADC128S102Sensor : public PollingComponent,
public Parented<ADC128S102>, public Parented<ADC128S102>,
public sensor::Sensor, public sensor::Sensor,
public voltage_sampler::VoltageSampler { public voltage_sampler::VoltageSampler {
public: public:
ADC128S102Sensor(uint8_t channel); ADC128S102Sensor(uint8_t channel);
@@ -9,7 +9,7 @@
namespace esphome::addressable_light { namespace esphome::addressable_light {
class AddressableLightDisplay final : public display::DisplayBuffer { class AddressableLightDisplay : public display::DisplayBuffer {
public: public:
light::AddressableLight *get_light() const { return this->light_; } light::AddressableLight *get_light() const { return this->light_; }
+1
View File
@@ -0,0 +1 @@
CODEOWNERS = ["@kpfleming"]

Some files were not shown because too many files have changed in this diff Show More