Compare commits

..

2 Commits

Author SHA1 Message Date
J. Nick Koston 064ece1f4d [ci] PROBE: salt venv cache key (do not merge)
Chained off #16451. Every job in that PR's CI run cache-hit on the
existing dev caches (Linux-/macOS-/Windows-3.11/3.13/3.14-venv-...),
which means the uv install path the PR introduces was never actually
executed. This branch salts the cache key in the common job so every
downstream restore misses and forces the new ``uv pip install`` lines
to run on every (OS, Python) combo.

Probe only -- close once #16451 is validated.
2026-05-15 09:58:20 -07:00
J. Nick Koston 6d62b3af5f [ci] Use uv for pip installs across CI workflows
Follow-up to #16449 / #16448, extending the uv switch to the workflows
that actually dominate CI cost (every push + every PR):

* ``.github/actions/restore-python`` -- the composite action invoked by
  almost every downstream job as the venv cache-miss fallback. Installs
  setup-uv only when the venv cache misses and swaps ``pip install``
  for ``uv pip install`` (no ``--system``: ``VIRTUAL_ENV`` is set by the
  activate step, so installs land in the venv that downstream jobs
  expect to ``. venv/bin/activate``).

* ``.github/workflows/ci.yml`` ``common`` job -- the canonical venv
  builder whose cache feeds the rest of the workflow.

* ``.github/workflows/ci.yml`` ``integration-tests`` job -- has its
  own venv create path on cache miss for the 3.13 matrix entry.

* ``.github/workflows/ci-api-proto.yml`` -- single ``pip install``
  step; uses ``uv pip install --system`` since this workflow does not
  set up or restore a venv.

All four call sites use ``astral-sh/setup-uv@v8.1.0`` (SHA-pinned) with
``enable-cache: true`` for a persistent wheel cache across runs.

Cache-hit paths are unchanged -- setup-uv only runs on cache miss for
the venv-restoring jobs, so steady-state behavior matches today.
2026-05-15 09:53:27 -07:00
114 changed files with 874 additions and 3790 deletions
+2 -2
View File
@@ -47,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -73,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -35,10 +35,6 @@ runs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
shell: bash
-4
View File
@@ -32,10 +32,6 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install apt dependencies
run: |
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
with:
python-version: "3.11"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Set TAG
run: |
+19 -26
View File
@@ -6,6 +6,14 @@ on:
branches: [dev, beta, release]
pull_request:
paths:
- "**"
- "!.github/workflows/*.yml"
- "!.github/actions/build-image/*"
- ".github/workflows/ci.yml"
- "!.yamllint"
- "!.github/dependabot.yml"
- "!docker/**"
merge_group:
permissions:
@@ -31,7 +39,11 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Generate cache-key
id: cache-key
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
# PROBE: salt prefix forces every venv-cache restore in this
# workflow run to miss so the uv install path from #16451 is
# actually exercised across Linux/macOS/Windows x 3.11/3.13/3.14.
# DO NOT MERGE -- remove this salt before landing.
run: echo key="uvprobe-${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
- name: Set up Python ${{ env.DEFAULT_PYTHON }}
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -52,10 +64,6 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -93,8 +101,6 @@ jobs:
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -173,10 +179,6 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install device-builder + esphome from PR
# Install device-builder with its esphome + test extras
# first so its pinned versions of pytest/etc. land, then
@@ -191,7 +193,7 @@ jobs:
# own CI). No ``--cov`` here -- this is purely a downstream
# smoke check against this PR's esphome code.
working-directory: device-builder
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks
run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks
pytest:
name: Run pytest
@@ -217,8 +219,6 @@ jobs:
runs-on: ${{ matrix.os }}
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -234,14 +234,14 @@ jobs:
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
@@ -257,7 +257,6 @@ jobs:
needs:
- common
outputs:
core-ci: ${{ steps.determine.outputs.core-ci }}
integration-tests: ${{ steps.determine.outputs.integration-tests }}
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
@@ -311,7 +310,6 @@ jobs:
echo "$output" | jq
# Extract individual fields
echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
@@ -371,10 +369,6 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
@@ -394,7 +388,7 @@ jobs:
. venv/bin/activate
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
cpp-unit-tests:
name: Run C++ unit tests
@@ -967,8 +961,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: "/language:${{matrix.language}}"
+3 -4
View File
@@ -29,11 +29,10 @@ jobs:
} = require('./.github/scripts/detect-tags.js');
const title = context.payload.pull_request.title;
const user = context.payload.pull_request.user;
const author = context.payload.pull_request.user.login;
// Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) -
// they have their own title formats.
if (user.type === 'Bot') {
// Skip bot PRs (e.g. dependabot) - they have their own title format
if (author === 'dependabot[bot]') {
return;
}
+75 -7
View File
@@ -99,15 +99,15 @@ jobs:
python-version: "3.11"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to docker hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -178,17 +178,17 @@ jobs:
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -212,6 +212,74 @@ jobs:
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
$(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *)
deploy-ha-addon-repo:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs:
- init
- deploy-manifest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: home-assistant-addon
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
let description = "ESPHome";
if (context.eventName == "release") {
description = ${{ toJSON(github.event.release.body) }};
}
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "home-assistant-addon",
workflow_id: "bump-version.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
content: description
}
})
deploy-esphome-schema:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs: [init]
environment: ${{ needs.init.outputs.deploy_env }}
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: esphome-schema
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "esphome-schema",
workflow_id: "generate-schemas.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
}
})
version-notifier:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
@@ -234,7 +302,7 @@ jobs:
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "version-notifier",
workflow_id: "notify.yml",
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Stale
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
+13 -16
View File
@@ -30,6 +30,11 @@ jobs:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Create sync branch
# Switch off dev before pre-commit runs so the
# no-commit-to-branch hook passes (it blocks dev/release/beta).
run: git checkout -B sync/device-classes
- name: Checkout Home Assistant
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -50,10 +55,6 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install Home Assistant
run: |
@@ -70,26 +71,22 @@ jobs:
# which would otherwise abort the workflow before the auto-fixes
# can flow into the sync PR.
#
# SKIP:
# - no-commit-to-branch is a local guard against committing on
# dev/release/beta; CI runs on dev by definition, and
# peter-evans/create-pull-request creates the branch itself.
# - pylint surfaces import-error / relative-beyond-top-level
# noise here because this workflow installs only a subset of
# the runtime deps (HA + requirements*.txt); main CI already
# gates pylint on real PRs.
# pylint is skipped: this workflow only installs a subset of the
# runtime deps (HA + requirements*.txt), so pylint surfaces
# import-error / relative-beyond-top-level noise that the main CI
# already gates on real PRs.
env:
SKIP: pylint,no-commit-to-branch
SKIP: pylint
run: python script/run-in-env.py pre-commit run --all-files || true
- name: Verify pre-commit clean
# Second pass: re-run all hooks against the now-fixed tree.
# Auto-fixers exit 0 (nothing to change); any remaining failure
# from a check-only hook (flake8 / yamllint / ci-custom) is a
# real issue and fails the workflow loudly. Same SKIP list as
# above for the same reasons.
# real issue and fails the workflow loudly. pylint stays skipped
# for the same reason as above.
env:
SKIP: pylint,no-commit-to-branch
SKIP: pylint
run: python script/run-in-env.py pre-commit run --all-files
- name: Commit changes
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.14
rev: v0.15.12
hooks:
# Run the linter.
- id: ruff
+1 -22
View File
@@ -50,7 +50,6 @@ from esphome.const import (
CONF_TOPIC,
CONF_USERNAME,
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_TARGET_PLATFORM,
@@ -734,13 +733,6 @@ def write_cpp_file() -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
# Keep this gate here, NOT in config validation: device-builder needs
# `esphome config` to keep succeeding with placeholders so onboarding can run.
if CONF_WIFI in config:
from esphome.components.wifi import check_placeholder_credentials
check_placeholder_credentials(config)
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
@@ -2449,10 +2441,7 @@ def run_esphome(argv):
# Skipped when -s overrides are passed, since the cache was written
# against the previous substitution set.
config: ConfigType | None = None
cache_eligible = (
args.command in ("upload", "logs") and not command_line_substitutions
)
if cache_eligible:
if args.command in ("upload", "logs") and not command_line_substitutions:
from esphome.compiled_config import load_compiled_config
config = load_compiled_config(conf_path)
@@ -2467,16 +2456,6 @@ def run_esphome(argv):
command_line_substitutions,
skip_external_update=skip_external,
)
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage
# sidecar is absent (no compile has run): the cache would
# never be loaded back, so writing secrets to disk is wasted.
if cache_eligible and config is not None:
from esphome.compiled_config import save_compiled_config
from esphome.storage_json import ext_storage_path
if ext_storage_path(conf_path.name).exists():
save_compiled_config(config)
if config is None:
return 2
CORE.config = config
+85 -12
View File
@@ -260,20 +260,42 @@ class ConfigBundleCreator:
def _discover_yaml_includes(self) -> None:
"""Discover YAML files loaded during config parsing.
Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a
fresh re-parse and force-loads every deferred ``IncludeFile`` so that
*all* potentially-reachable includes are captured (even branches not
selected by local substitutions). Bundles are meant to be compiled on
another system where command-line substitution overrides may choose a
different branch — e.g. ``!include network/${eth_model}/config.yaml``
must ship every candidate so the remote build can pick any one.
Deliberately uses a fresh re-parse and force-loads every deferred
``IncludeFile`` to include *all* potentially-reachable includes,
even branches not selected by the local substitutions. Bundles are
meant to be compiled on another system where command-line
substitution overrides may choose a different branch — e.g.
``!include network/${eth_model}/config.yaml`` must ship every
candidate so the remote build can pick any one.
Entries with unresolved substitution variables in the filename
path are skipped with a warning (they cannot be resolved without
the substitution pass).
Secrets files are tracked separately so we can filter them to
only include the keys this config actually references.
"""
discovered = yaml_util.discover_user_yaml_files(self._config_path)
self._secrets_paths.update(discovered.secrets)
config_resolved = self._config_path.resolve()
for fpath in discovered.files:
if fpath == config_resolved:
# Must be a fresh parse: IncludeFile.load() caches its result in
# _content, and we discover files by listening for loader calls. On
# an already-parsed tree the cache is populated, .load() returns
# without calling the loader, the listener never fires, and the
# referenced files would be silently dropped from the bundle.
with yaml_util.track_yaml_loads() as loaded_files:
try:
data = yaml_util.load_yaml(self._config_path)
except EsphomeError:
_LOGGER.debug(
"Bundle: re-loading YAML for include discovery failed, "
"proceeding with partial file list"
)
else:
_force_load_include_files(data)
for fpath in loaded_files:
if fpath == self._config_path.resolve():
continue # Already added as config
if fpath.name in const.SECRETS_FILES:
self._secrets_paths.add(fpath)
self._add_file(fpath)
def _discover_component_files(self) -> None:
@@ -603,6 +625,57 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
tar.addfile(info, io.BytesIO(data))
def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
"""Recursively resolve any ``IncludeFile`` instances in a YAML tree.
Nested ``!include`` returns a deferred ``IncludeFile`` that is only
resolved during the substitution pass. During bundle discovery we need
the referenced files to actually load so the ``track_yaml_loads``
listener fires for them.
``IncludeFile`` instances with unresolved substitution variables in the
filename cannot be loaded — we skip and warn about those.
"""
if _seen is None:
_seen = set()
if isinstance(obj, yaml_util.IncludeFile):
if id(obj) in _seen:
return
_seen.add(id(obj))
if obj.has_unresolved_expressions():
_LOGGER.warning(
"Bundle: cannot resolve !include %s (referenced from %s) "
"with substitutions in path",
obj.file,
obj.parent_file,
)
return
try:
loaded = obj.load()
except EsphomeError as err:
_LOGGER.warning(
"Bundle: failed to load !include %s (referenced from %s): %s",
obj.file,
obj.parent_file,
err,
)
return
_force_load_include_files(loaded, _seen)
elif isinstance(obj, dict):
if id(obj) in _seen:
return
_seen.add(id(obj))
for value in obj.values():
_force_load_include_files(value, _seen)
elif isinstance(obj, (list, tuple)):
if id(obj) in _seen:
return
_seen.add(id(obj))
for item in obj:
_force_load_include_files(item, _seen)
def _resolve_include_path(include_path: Any) -> Path | None:
"""Resolve an include path to absolute, skipping system includes."""
if isinstance(include_path, str) and include_path.startswith("<"):
@@ -1,6 +1,5 @@
#include "api_connection.h"
#ifdef USE_API
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
#endif
+40 -11
View File
@@ -11,8 +11,7 @@
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "list_entities.h"
#include "subscribe_state.h"
#include "api_server.h"
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#ifdef USE_ESP32_CRASH_HANDLER
@@ -37,9 +36,6 @@ class ComponentIterator;
namespace esphome::api {
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
class APIServer;
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending
@@ -415,10 +411,44 @@ class APIConnection final : public APIServerConnectionBase {
// Non-template buffer management for send_message
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
// Defined in api_connection_buffer.h (needs APIServer complete).
static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn, uint32_t remaining_size);
// Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn,
uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
@@ -762,8 +792,7 @@ class APIConnection final : public APIServerConnectionBase {
// Read by process_batch_multi_ to pass into MessageInfo.
uint8_t batch_header_size_{0};
// Defined in api_connection_buffer.h (needs APIServer complete).
uint32_t get_batch_delay_ms_() const;
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
// Message will use 8 more bytes than the minimum size, and typical
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
// If its IPv6 the header is 40 bytes, and if its IPv4
@@ -1,54 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API
// Inline APIConnection methods that need APIServer complete. Include this
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
#include "api_connection.h"
#include "api_server.h"
namespace esphome::api {
inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size,
MessageEncodeFn encode_fn, const void *msg,
APIConnection *conn, uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
} // namespace esphome::api
#endif
+5
View File
@@ -30,6 +30,11 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c
APIServer::APIServer() { global_api_server = this; }
// Custom deleter defined here so `delete` sees the complete APIConnection type.
// This prevents libc++ from emitting an "incomplete type" error when other
// translation units only have the forward declaration of APIConnection.
void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; }
void APIServer::socket_failed_(const LogString *msg) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
this->destroy_socket_();
+10 -4
View File
@@ -3,8 +3,6 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#include "api_noise_context.h"
#include "api_pb2.h"
#include "api_pb2_service.h"
@@ -14,6 +12,8 @@
#include "esphome/core/controller.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#include "list_entities.h"
#include "subscribe_state.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -191,9 +191,15 @@ class APIServer final : public Component,
bool is_connected_with_state_subscription() const;
// Range-for view over the populated slice [0, api_connection_count_). Read-only with respect
// to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the
// to ownership callers get `const unique_ptr&` so they can invoke non-const methods on the
// APIConnection but cannot reset/move the slot and break the count invariant.
using APIConnectionPtr = std::unique_ptr<APIConnection>;
// Custom deleter is defined out-of-line in api_server.cpp so libc++ does not
// eagerly instantiate `delete static_cast<APIConnection *>(p)` here, where
// only the forward declaration of APIConnection is visible (incomplete type).
struct APIConnectionDeleter {
void operator()(APIConnection *p) const;
};
using APIConnectionPtr = std::unique_ptr<APIConnection, APIConnectionDeleter>;
class ActiveClientsView {
const APIConnectionPtr *begin_;
const APIConnectionPtr *end_;
+1 -1
View File
@@ -335,7 +335,7 @@ async def to_code(config):
add_idf_component(
name="esphome/esp-audio-libs",
ref="3.1.0",
ref="3.0.0",
)
data = _get_data()
@@ -252,22 +252,6 @@ void RingBufferAudioSource::consume(size_t bytes) {
}
}
void RingBufferAudioSource::clear_buffered_data() {
// Release the held item before reset() so the source no longer references memory the reset will reclaim.
if (this->acquired_item_ != nullptr) {
this->ring_buffer_->receive_release(this->acquired_item_);
this->acquired_item_ = nullptr;
}
this->current_data_ = nullptr;
this->current_available_ = 0;
this->queued_data_ = nullptr;
this->queued_length_ = 0;
this->item_trailing_ptr_ = nullptr;
this->item_trailing_length_ = 0;
this->splice_length_ = 0;
this->ring_buffer_->reset();
}
bool RingBufferAudioSource::has_buffered_data() const {
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
@@ -250,10 +250,6 @@ class RingBufferAudioSource : public AudioReadableBuffer {
/// exposure stays in place and fill() returns 0 until it is fully consumed.
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
/// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight
/// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread.
void clear_buffered_data();
/// @brief Returns a mutable pointer to the currently exposed audio data.
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
@@ -135,26 +135,12 @@ void BluetoothConnection::loop() {
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
// Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
// 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
(this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
this->disable_loop();
}
}
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
// base class. Free the proxy slot, notify the API client, and reset send_service_.
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
if (this->address_ == 0) {
return;
}
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
this->reset_connection_(reason);
}
void BluetoothConnection::reset_connection_(esp_err_t reason) {
// Send disconnection notification
this->proxy_->send_device_connection(this->address_, false, 0, reason);
@@ -386,6 +372,14 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
break;
}
case ESP_GATTC_CLOSE_EVT: {
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
param->close.reason);
// Now the GATT connection is fully closed and controller resources are freed
// Safe to mark the connection slot as available
this->reset_connection_(param->close.reason);
break;
}
case ESP_GATTC_OPEN_EVT: {
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->reset_connection_(param->open.status);
@@ -33,8 +33,6 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
protected:
friend class BluetoothProxy;
void on_disconnect_complete(esp_err_t reason) override;
bool supports_efficient_uuids_() const;
void send_service_for_discovery_();
void reset_connection_(esp_err_t reason);
@@ -1,6 +1,5 @@
#include "bluetooth_proxy.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
#include "esphome/core/macros.h"
#include "esphome/core/application.h"
+47 -93
View File
@@ -113,7 +113,6 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs"
ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}"
ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32"
LOG_LEVELS_IDF = [
"NONE",
@@ -793,15 +792,19 @@ PLATFORM_VERSION_LOOKUP = {
}
def _resolve_framework_version(value: ConfigType) -> cv.Version:
"""Resolve a named or raw framework version and validate the minimum.
def _check_pio_versions(config):
config = config.copy()
value = config[CONF_FRAMEWORK]
Normalises value[CONF_VERSION] to its string form and returns the parsed
cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain-
specific concerns (source defaults, platform_version) live in the per-
toolchain functions.
"""
if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP:
if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value:
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]]
else:
@@ -814,38 +817,7 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version:
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
if version < cv.Version(3, 0, 0):
raise cv.Invalid("Only Arduino 3.0+ is supported.")
recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
if version != recommended:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
return version
def _check_pio_versions(config: ConfigType) -> ConfigType:
config = config.copy()
value = config[CONF_FRAMEWORK]
is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP
if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value):
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
if is_named_version:
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]])
)
version = _resolve_framework_version(value)
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get(
CONF_SOURCE, _format_framework_arduino_version(version)
@@ -853,6 +825,9 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
if _is_framework_url(value[CONF_SOURCE]):
value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}"
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get(
CONF_SOURCE,
@@ -868,6 +843,12 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
)
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if version != recommended_version:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP["recommended"])
):
@@ -879,26 +860,19 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
return config
def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
config = config.copy()
def _check_esp_idf_versions(config):
config = _check_pio_versions(config)
value = config[CONF_FRAMEWORK]
# platform_version is a PlatformIO concept; drop it if a user carried it
# over from a PIO-style config. CONF_SOURCE, on the other hand, is kept:
# it lets a user override the framework tarball URL under the esp-idf
# toolchain (the espidf framework downloader consults it).
value.pop(CONF_PLATFORM_VERSION, None)
# Remove unwanted keys if present
for key in (CONF_SOURCE, CONF_PLATFORM_VERSION):
value.pop(key, None)
version = _resolve_framework_version(value)
# Official ESP-IDF frameworks don't use extra
version = cv.Version.parse(value[CONF_VERSION])
version = cv.Version(version.major, version.minor, version.patch)
if CONF_SOURCE in value:
_LOGGER.warning(
"A custom framework source is set. "
"If there are connectivity or build issues please remove the manual source."
)
# Official ESP-IDF frameworks don't use the 'extra' semver component.
value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch))
value[CONF_VERSION] = str(version)
return config
@@ -1744,31 +1718,6 @@ async def _add_yaml_idf_components(components: list[ConfigType]):
)
@coroutine_with_priority(CoroPriority.FINAL - 1)
async def _finalize_arduino_aware_flags():
"""Build flags that depend on whether arduino-esp32 is linked in.
Scheduler runs lower priority values later, so ``FINAL - 1`` fires
after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) --
by then ``KEY_COMPONENTS`` is fully populated.
- Skip our esp_panic_handler wrap when Arduino is linked; Arduino
wraps the same symbol and the linker errors on the duplicate.
- Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component
case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The
framework=arduino branch already adds it inline in to_code.
"""
arduino_linked = (
CORE.using_arduino
or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS]
)
if not arduino_linked:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
elif not CORE.using_arduino:
cg.add_build_flag("-DUSE_ARDUINO")
async def to_code(config):
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
conf = config[CONF_FRAMEWORK]
@@ -1816,9 +1765,11 @@ async def to_code(config):
Path(__file__).parent / "iram_fix.py.script",
)
else:
# Undo IDF's blanket -Werror so third-party libraries and user
# lambdas don't need a -Wno-error=<class> entry per warning class.
cg.add_build_flag("-Wno-error")
cg.add_build_flag("-Wno-error=format")
cg.add_build_flag("-Wno-error=maybe-uninitialized")
cg.add_build_flag("-Wno-error=overloaded-virtual")
cg.add_build_flag("-Wno-error=reorder")
cg.add_build_flag("-Wno-error=volatile")
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
cg.add_build_flag("-Wno-missing-field-initializers")
@@ -1826,8 +1777,11 @@ async def to_code(config):
cg.add_build_flag("-DUSE_ESP32")
cg.add_define("USE_NATIVE_64BIT_TIME")
cg.add_build_flag("-Wl,-z,noexecstack")
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
CORE.add_job(_finalize_arduino_aware_flags)
# Arduino already wraps esp_panic_handler for its own backtrace handler,
# so only add our wrap when using ESP-IDF framework to avoid linker conflicts.
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
variant = config[CONF_VARIANT]
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
@@ -2008,7 +1962,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True)
# Setup watchdog
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
@@ -2050,8 +2004,7 @@ async def to_code(config):
if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]:
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]:
# Kconfig range is [1,63]; 0 gets clamped to the default.
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1)
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0)
_configure_lwip_max_sockets(conf)
@@ -2143,6 +2096,7 @@ async def to_code(config):
for key, flag in ASSERTION_LEVELS.items():
add_idf_sdkconfig_option(flag, assertion_level == key)
add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False)
compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION]
for key, flag in COMPILER_OPTIMIZATIONS.items():
add_idf_sdkconfig_option(flag, compiler_optimization == key)
@@ -2297,8 +2251,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2)
elif advanced[CONF_DISABLE_FATFS]:
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True)
# Kconfig range is [1,10]; 0 gets clamped to the default.
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1)
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0)
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
@@ -2535,8 +2488,9 @@ def _write_sdkconfig():
def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]:
dependency: dict[str, str] = {}
name, _version, path = generate_idf_component(library)
name, version, path = generate_idf_component(library)
dependency["override_path"] = str(path)
dependency["version"] = version
return name, dependency
@@ -2588,7 +2542,7 @@ def _write_idf_component_yml():
if CORE.using_toolchain_esp_idf:
add_idf_component(
name=ARDUINO_ESP32_COMPONENT_NAME,
name="espressif/arduino-esp32",
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
)
@@ -72,7 +72,6 @@ void BLEClientBase::loop() {
// never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call.
this->release_services();
this->set_idle_();
this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT);
}
}
@@ -419,7 +418,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->log_gattc_lifecycle_event_("CLOSE");
this->release_services();
this->set_idle_();
this->on_disconnect_complete(param->close.reason);
break;
}
case ESP_GATTC_SEARCH_RES_EVT: {
@@ -140,12 +140,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void log_gattc_warning_(const char *operation, esp_err_t err);
void log_connection_params_(const char *param_type);
void handle_connection_result_(esp_err_t ret);
/// Hook called once a connection has been fully torn down (after release_services() and
/// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout.
/// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state)
/// override this to release that state. `reason` is the controller reason code, or
/// ESP_GATT_CONN_TIMEOUT for the safety-timeout path.
virtual void on_disconnect_complete(esp_err_t reason) {}
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
void set_idle_() {
this->set_state(espbt::ClientState::IDLE);
@@ -155,10 +149,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state(espbt::ClientState::DISCONNECTING);
// BluetoothConnection::loop() disables the component loop after service discovery
// completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT
// gets lost. Re-enable the loop so the 10s safety timeout can force IDLE.
this->enable_loop();
}
// Compact error logging helpers to reduce flash usage
void log_error_(const char *message);
@@ -196,35 +196,42 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt
(*this->on_read_callback_)(param->read.conn_id);
}
// Use the client-supplied offset for long reads; short reads always start at 0.
// The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we
// just provide as much data as we have from the requested offset and let the stack
// handle framing. The client issues subsequent blob reads with increasing offsets
// until it has received the whole value.
const uint16_t offset = param->read.is_long ? param->read.offset : 0;
esp_gatt_status_t status = ESP_GATT_OK;
esp_gatt_rsp_t response;
response.attr_value.offset = offset;
uint16_t max_offset = 22;
if (offset > this->value_.size()) {
status = ESP_GATT_INVALID_OFFSET;
response.attr_value.len = 0;
} else {
size_t remaining = this->value_.size() - offset;
if (remaining > ESP_GATT_MAX_ATTR_LEN) {
ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating",
static_cast<unsigned>(remaining), ESP_GATT_MAX_ATTR_LEN);
remaining = ESP_GATT_MAX_ATTR_LEN;
esp_gatt_rsp_t response;
if (param->read.is_long) {
if (this->value_read_offset_ >= this->value_.size()) {
response.attr_value.len = 0;
response.attr_value.offset = this->value_read_offset_;
this->value_read_offset_ = 0;
} else if (this->value_.size() - this->value_read_offset_ < max_offset) {
// Last message in the chain
response.attr_value.len = this->value_.size() - this->value_read_offset_;
response.attr_value.offset = this->value_read_offset_;
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
this->value_read_offset_ = 0;
} else {
response.attr_value.len = max_offset;
response.attr_value.offset = this->value_read_offset_;
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
this->value_read_offset_ += max_offset;
}
response.attr_value.len = remaining;
memcpy(response.attr_value.value, this->value_.data() + offset, remaining);
} else {
response.attr_value.offset = 0;
if (this->value_.size() + 1 > max_offset) {
response.attr_value.len = max_offset;
this->value_read_offset_ = max_offset;
} else {
response.attr_value.len = this->value_.size();
}
memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len);
}
response.attr_value.handle = this->handle_;
response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE;
esp_err_t err =
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response);
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err);
}
@@ -79,6 +79,7 @@ class BLECharacteristic {
esp_gatt_char_prop_t properties_;
uint16_t handle_{0xFFFF};
uint16_t value_read_offset_{0};
std::vector<uint8_t> value_;
std::vector<BLEDescriptor *> descriptors_;
@@ -121,7 +121,7 @@ void Esp32HostedUpdate::setup() {
}
} else {
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")",
app_desc->magic_word, static_cast<uint32_t>(ESP_APP_DESC_MAGIC_WORD));
app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD);
this->state_ = update::UPDATE_STATE_NO_UPDATE;
}
} else {
+12 -12
View File
@@ -5,7 +5,6 @@
#include <Arduino.h>
#include <core_esp8266_features.h>
#include <coredecls.h>
extern "C" {
#include <user_interface.h>
@@ -72,22 +71,23 @@ uint32_t IRAM_ATTR HOT millis() {
return result;
}
// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to
// suspend the cont task for `ms` milliseconds without polling millis(). This
// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay())
// and lets the SDK run freely while we wait, which timing-sensitive
// interrupt-driven code (e.g. ESP8266 software-serial RX in components like
// fingerprint_grow) depends on. The poll-based busy-wait that this replaced
// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and
// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does
// not call millis(), so the slow Arduino millis() body is not pulled into IRAM
// by this path (the --wrap=millis goal of #15662 is preserved).
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
// call to the original millis() that --wrap can't intercept, so calling ::delay()
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
// WiFi run correctly. Theoretically less power-efficient than Arduino's
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
// (sensor/I²C/SPI settling in the 1100 ms range) where the difference is
// negligible.
void HOT delay(uint32_t ms) {
if (ms == 0) {
optimistic_yield(1000);
return;
}
esp_delay(ms);
uint32_t start = millis();
while (millis() - start < ms) {
optimistic_yield(1000);
}
}
void arch_restart() {
-6
View File
@@ -58,12 +58,6 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c
__attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) {
return pgm_read_word(addr); // NOLINT
}
// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers
// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on
// ESP8266 is ~4x as many flash accesses as the bulk path.
__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) {
memcpy_P(dst, src, len); // NOLINT
}
// NOLINTNEXTLINE(readability-identifier-naming)
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
+1 -1
View File
@@ -17,7 +17,7 @@ from esphome.core import HexInt
from esphome.types import ConfigType
CODEOWNERS = ["@jesserockz"]
AUTO_LOAD = ["network"]
byte_vector = cg.std_vector.template(cg.uint8)
peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6)
@@ -149,6 +149,12 @@ bool ESPNowComponent::is_wifi_enabled() {
}
void ESPNowComponent::setup() {
#ifndef USE_WIFI
// Initialize LwIP stack for wake_loop_threadsafe() socket support
// When WiFi component is present, it handles esp_netif_init()
ESP_ERROR_CHECK(esp_netif_init());
#endif
if (this->enable_on_boot_) {
this->enable_();
} else {
@@ -168,6 +174,8 @@ void ESPNowComponent::enable() {
void ESPNowComponent::enable_() {
if (!this->is_wifi_enabled()) {
esp_event_loop_create_default();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
+6 -37
View File
@@ -3,11 +3,7 @@ import logging
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components.network import (
KEY_NETWORK_PRIORITY,
get_network_priority,
ip_address_literal,
)
from esphome.components.network import ip_address_literal
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -17,7 +13,6 @@ from esphome.const import (
CONF_DNS1,
CONF_DNS2,
CONF_DOMAIN,
CONF_ENABLE_ON_BOOT,
CONF_GATEWAY,
CONF_ID,
CONF_INTERRUPT_PIN,
@@ -32,7 +27,6 @@ from esphome.const import (
CONF_PAGE_ID,
CONF_PIN,
CONF_POLLING_INTERVAL,
CONF_PRIORITY,
CONF_RESET_PIN,
CONF_SPI,
CONF_STATIC_IP,
@@ -54,6 +48,7 @@ from esphome.core import (
import esphome.final_validate as fv
from esphome.types import ConfigType
CONFLICTS_WITH = ["wifi"]
AUTO_LOAD = ["network"]
LOGGER = logging.getLogger(__name__)
@@ -353,7 +348,6 @@ BASE_SCHEMA = cv.Schema(
cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name,
cv.Optional(CONF_USE_ADDRESS): cv.string_strict,
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True),
}
@@ -493,11 +487,6 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# Apply network priority if configured, otherwise use the existing default
prio = get_network_priority("ethernet")
if prio is not None:
cg.add(var.set_setup_priority(prio))
if CORE.is_esp32:
await _to_code_esp32(var, config)
elif CORE.is_rp2040:
@@ -505,9 +494,6 @@ async def to_code(config):
cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]]))
cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
# enable_on_boot defaults to true in C++ - only set if false
if not config[CONF_ENABLE_ON_BOOT]:
cg.add(var.set_enable_on_boot(False))
CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE]
if CONF_MANUAL_IP in config:
@@ -590,16 +576,10 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
)
cg.add(var.add_phy_register(reg))
# Disable WiFi when using Ethernet alone to save memory.
# When network: priority: lists both interfaces, WiFi must remain enabled.
net_priority = CORE.data.get(KEY_NETWORK_PRIORITY, [])
priority_ifaces = {e["interface"] for e in net_priority}
running_with_wifi = "wifi" in priority_ifaces and "ethernet" in priority_ifaces
if not running_with_wifi:
# Disable WiFi when using Ethernet to save memory
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False)
# Also disable WiFi/BT coexistence since WiFi is disabled
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
# Disable WiFi when using Ethernet to save memory
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False)
# Also disable WiFi/BT coexistence since WiFi is disabled
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
# Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time)
include_builtin_idf_component("esp_eth")
@@ -685,17 +665,6 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
def _final_validate(config: ConfigType) -> ConfigType:
"""Final validation for Ethernet component."""
# Allow ethernet + wifi coexistence only when both are declared in network: priority:
full = fv.full_config.get()
net_priority = full.get("network", {}).get(CONF_PRIORITY, [])
priority_ifaces = {e["interface"] for e in net_priority}
has_priority_config = "ethernet" in priority_ifaces and "wifi" in priority_ifaces
if "wifi" in full and not has_priority_config:
raise cv.Invalid(
"Component ethernet cannot be used together with component wifi "
"unless both are listed under 'network: priority:'"
)
_final_validate_spi(config)
_final_validate_rmii_pins(config)
return config
@@ -124,17 +124,6 @@ class EthernetComponent final : public Component {
void on_powerdown() override { powerdown(); }
bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; }
// Per-interface lifecycle (parallels WiFiComponent::enable/disable/is_disabled).
// enable_on_boot defaults to true; when false, setup() runs all the driver/netif
// installation but skips esp_eth_start(), keeping the link cold until enable() is
// called. This is the primary lever for memory reclamation in multi-interface
// configurations where only one interface should carry traffic at a time.
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
void enable();
void disable();
bool is_disabled() { return this->disabled_; }
bool is_enabled() { return !this->disabled_; }
void set_type(EthernetType type);
#ifdef USE_ETHERNET_MANUAL_IP
void set_manual_ip(const ManualIP &manual_ip);
@@ -205,16 +194,6 @@ class EthernetComponent final : public Component {
void finish_connect_();
void dump_connect_params_();
#ifdef USE_ESP32
// ESP-IDF only: defers the SPI bus init, netif creation, MAC/PHY install, driver
// install, netif attach, and event handler registration (which together allocate
// ~3-8KB of DMA-capable internal SRAM via SPI driver state + eth driver RX queue)
// until ethernet actually needs to come up. Idempotent — guarded by the
// ethernet_initialized_ flag. Called from setup() when enable_on_boot_=true, or
// from enable() on first runtime enable. Mirrors wifi_lazy_init_() in WiFi.
void ethernet_lazy_init_();
#endif
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
void notify_ip_state_listeners_();
#endif
@@ -308,17 +287,6 @@ class EthernetComponent final : public Component {
bool started_{false};
bool connected_{false};
bool got_ipv4_address_{false};
// Codegen-time YAML option. When false, setup() defers esp_eth_start().
bool enable_on_boot_{true};
// Mirror of "is the link intentionally stopped" — set when setup() honors
// enable_on_boot=false, cleared by enable(), set again by disable().
bool disabled_{false};
#ifdef USE_ESP32
// Tracks whether ethernet_lazy_init_() has completed successfully. Allows enable()
// to be called at runtime after enable_on_boot:false without re-allocating, and
// ensures setup() skips the heavy init when enable_on_boot_ is false.
bool ethernet_initialized_{false};
#endif
#if LWIP_IPV6
uint8_t ipv6_count_{0};
bool ipv6_setup_done_{false};
@@ -5,7 +5,6 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "w5500_custom_spi.h"
#include <lwip/dns.h>
#include <cinttypes>
@@ -138,24 +137,6 @@ void EthernetComponent::setup() {
delay(300); // NOLINT
}
if (this->enable_on_boot_) {
this->ethernet_lazy_init_();
if (!this->ethernet_initialized_) {
// lazy_init bailed early via ESPHL_ERROR_CHECK or mark_failed; nothing more to do.
return;
}
esp_err_t err = esp_eth_start(this->eth_handle_);
ESPHL_ERROR_CHECK(err, "ETH start error");
} else {
ESP_LOGCONFIG(TAG, "Skipping init (enable_on_boot: false)");
this->disabled_ = true;
}
}
void EthernetComponent::ethernet_lazy_init_() {
if (this->ethernet_initialized_)
return;
esp_err_t err;
#ifdef USE_ETHERNET_SPI
@@ -182,7 +163,11 @@ void EthernetComponent::ethernet_lazy_init_() {
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
#endif
// Network interface setup handled by network component
err = esp_netif_init();
ESPHL_ERROR_CHECK(err, "ETH netif init error");
err = esp_event_loop_create_default();
ESPHL_ERROR_CHECK(err, "ETH event loop error");
esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH();
this->eth_netif_ = esp_netif_new(&cfg);
@@ -222,10 +207,6 @@ void EthernetComponent::ethernet_lazy_init_() {
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
w5500_config.poll_period_ms = this->polling_interval_;
#endif
// Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait
// path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which
// runs the driver's init().
install_w5500_async_spi(w5500_config);
#elif defined(USE_ETHERNET_DM9051)
dm9051_config.int_gpio_num = this->interrupt_pin_;
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
@@ -389,41 +370,9 @@ void EthernetComponent::ethernet_lazy_init_() {
ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error");
#endif /* USE_NETWORK_IPV6 */
this->ethernet_initialized_ = true;
}
void EthernetComponent::enable() {
if (!this->disabled_)
return;
ESP_LOGD(TAG, "Enabling");
this->ethernet_lazy_init_();
if (!this->ethernet_initialized_) {
ESP_LOGE(TAG, "Cannot enable - init failed");
return;
}
esp_err_t err = esp_eth_start(this->eth_handle_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_eth_start failed: %s", esp_err_to_name(err));
return;
}
this->disabled_ = false;
// The ETH_EVENT_START handler will set started_=true; the loop state machine
// will then drive the STOPPED -> CONNECTING -> CONNECTED transitions.
this->enable_loop();
}
void EthernetComponent::disable() {
if (this->disabled_)
return;
ESP_LOGD(TAG, "Disabling");
esp_err_t err = esp_eth_stop(this->eth_handle_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_eth_stop failed: %s — disabling anyway", esp_err_to_name(err));
}
this->disabled_ = true;
// ETH_EVENT_STOP will clear started_; loop() will transition to STOPPED.
/* start Ethernet driver state machine */
err = esp_eth_start(this->eth_handle_);
ESPHL_ERROR_CHECK(err, "ETH start error");
}
void EthernetComponent::dump_config() {
@@ -537,8 +486,6 @@ void EthernetComponent::dump_config() {
network::IPAddresses EthernetComponent::get_ip_addresses() {
network::IPAddresses addresses;
if (!this->ethernet_initialized_)
return addresses; // all-zero IPs
esp_netif_ip_info_t ip;
esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip);
if (err != ESP_OK) {
@@ -761,10 +708,6 @@ void EthernetComponent::start_connect_() {
}
void EthernetComponent::dump_connect_params_() {
if (!this->ethernet_initialized_) {
ESP_LOGCONFIG(TAG, " uninitialized/disabled");
return;
}
esp_netif_ip_info_t ip;
esp_netif_get_ip_info(this->eth_netif_, &ip);
const ip_addr_t *dns_ip1;
@@ -832,13 +775,6 @@ void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy
#endif
void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
if (!this->ethernet_initialized_) {
// External callers (sendspin, ethernet_info, mdns, etc.) may ask for the MAC
// before/regardless of whether ethernet is enabled. Fall back to the system MAC
// assigned to the ETH interface — same value the driver would have returned.
esp_read_mac(mac, ESP_MAC_ETH);
return;
}
esp_err_t err;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac);
ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error");
@@ -858,8 +794,6 @@ const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
}
eth_duplex_t EthernetComponent::get_duplex_mode() {
if (!this->ethernet_initialized_)
return ETH_DUPLEX_HALF;
esp_err_t err;
eth_duplex_t duplex_mode;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode);
@@ -868,8 +802,6 @@ eth_duplex_t EthernetComponent::get_duplex_mode() {
}
eth_speed_t EthernetComponent::get_link_speed() {
if (!this->ethernet_initialized_)
return ETH_SPEED_10M;
esp_err_t err;
eth_speed_t speed;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed);
@@ -361,23 +361,6 @@ void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; }
void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; }
void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; }
void EthernetComponent::enable() {
// RP2040 uses arduino-pico's LwipIntfDev which manages link state internally;
// there is no clean enable/disable hook today. The YAML option is accepted on
// RP2040 for schema parity but has no effect.
if (!this->disabled_)
return;
ESP_LOGW(TAG, "enable_on_boot/disable not supported");
this->disabled_ = false;
}
void EthernetComponent::disable() {
if (this->disabled_)
return;
ESP_LOGW(TAG, "enable_on_boot/disable not supported");
this->disabled_ = true;
}
} // namespace esphome::ethernet
#endif // USE_ETHERNET && USE_RP2040
@@ -1,118 +0,0 @@
#include "w5500_custom_spi.h"
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
#include <driver/spi_master.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <cstring>
#include <new>
namespace esphome::ethernet {
namespace {
// Per-device context returned by init() and handed back to read/write/deinit.
struct W5500CustomSpiContext {
spi_device_handle_t handle;
SemaphoreHandle_t lock;
};
// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger
// transfers (the frame payloads) use the blocking, DMA-backed transmit.
constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64;
constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50;
void *w5500_custom_spi_init(const void *spi_config) {
const auto *config = static_cast<const eth_w5500_config_t *>(spi_config);
auto *ctx = new (std::nothrow) W5500CustomSpiContext{};
if (ctx == nullptr) {
return nullptr;
}
// The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control
// byte in the address phase; mirror what the stock driver configures.
spi_device_interface_config_t devcfg = *config->spi_devcfg;
devcfg.command_bits = 16;
devcfg.address_bits = 8;
if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) {
delete ctx;
return nullptr;
}
ctx->lock = xSemaphoreCreateMutex();
if (ctx->lock == nullptr) {
spi_bus_remove_device(ctx->handle);
delete ctx;
return nullptr;
}
return ctx;
}
esp_err_t w5500_custom_spi_deinit(void *spi_ctx) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_bus_remove_device(ctx->handle);
vSemaphoreDelete(ctx->lock);
delete ctx;
return ESP_OK;
}
// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size.
// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register
// accesses stay on the cheaper polling path. Used by both read and write.
esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) {
if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) {
return ESP_ERR_TIMEOUT;
}
esp_err_t ret;
if (len > W5500_SPI_BULK_THRESHOLD) {
ret = spi_device_transmit(ctx->handle, trans);
} else {
ret = spi_device_polling_transmit(ctx->handle, trans);
}
xSemaphoreGive(ctx->lock);
return ret;
}
esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_transaction_t trans = {};
trans.cmd = static_cast<uint16_t>(cmd);
trans.addr = addr;
trans.length = 8 * len;
trans.tx_buffer = data;
return w5500_custom_spi_transfer(ctx, &trans, len);
}
esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_transaction_t trans = {};
// Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary
// overwrites of adjacent registers (same guard the stock driver uses).
const bool use_rxdata = len <= 4;
trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0;
trans.cmd = static_cast<uint16_t>(cmd);
trans.addr = addr;
trans.length = 8 * len;
trans.rx_buffer = data;
esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len);
if (use_rxdata && (ret == ESP_OK)) {
memcpy(data, trans.rx_data, len);
}
return ret;
}
} // namespace
void install_w5500_async_spi(eth_w5500_config_t &config) {
// Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and
// spi_devcfg back out of it. The self-reference is valid because both the config and the
// spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init().
config.custom_spi_driver.config = &config;
config.custom_spi_driver.init = w5500_custom_spi_init;
config.custom_spi_driver.deinit = w5500_custom_spi_deinit;
config.custom_spi_driver.read = w5500_custom_spi_read;
config.custom_spi_driver.write = w5500_custom_spi_write;
}
} // namespace esphome::ethernet
#endif // USE_ESP32 && USE_ETHERNET_W5500
@@ -1,35 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
#include <esp_idf_version.h>
// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t
// is no longer reachable through esp_eth.h and needs the explicit header.
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#include <esp_eth_mac_w5500.h>
#else
#include <esp_eth.h>
#endif
namespace esphome::ethernet {
// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path.
//
// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which
// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame,
// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX
// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers
// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps
// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait
// is cheaper than an interrupt round-trip.
//
// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back
// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay
// alive until esp_eth_mac_new_w5500() returns.
void install_w5500_async_spi(eth_w5500_config_t &config);
} // namespace esphome::ethernet
#endif // USE_ESP32 && USE_ETHERNET_W5500
@@ -206,7 +206,6 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() {
break;
case ENROLL_MISMATCH:
ESP_LOGE(TAG, "Scans do not match");
[[fallthrough]];
default:
return this->data_[0];
}
@@ -15,16 +15,6 @@ void FT5x06Touchscreen::setup() {
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
// wait 200ms after reset.
this->set_timeout(200, [this] { this->continue_setup_(); });
}
@@ -49,6 +39,15 @@ void FT5x06Touchscreen::continue_setup_() {
this->mark_failed();
return;
}
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
}
void FT5x06Touchscreen::update_touches() {
@@ -72,7 +71,7 @@ void FT5x06Touchscreen::update_touches() {
uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]);
uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]);
ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
if (status == 0 || status == 2) {
this->add_raw_touch_position_(id, x, y);
}
@@ -17,9 +17,9 @@ void HomeassistantSensor::setup() {
}
if (this->attribute_ != nullptr) {
ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
} else {
ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
}
this->publish_state(*val);
});
@@ -89,10 +89,10 @@ def _set_num_channels_from_config(config):
def _set_stream_limits(config):
if config.get(CONF_SPDIF_MODE, False):
# SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate
# SPDIF mode: fixed to 16-bit stereo at configured sample rate
audio.set_stream_limits(
min_bits_per_sample=16,
max_bits_per_sample=32,
max_bits_per_sample=16,
min_channels=2,
max_channels=2,
min_sample_rate=config.get(CONF_SAMPLE_RATE),
@@ -213,6 +213,9 @@ def _final_validate(config):
)
if config[CONF_CHANNEL] != CONF_STEREO:
raise cv.Invalid("SPDIF mode only supports stereo channel configuration")
# bits_per_sample is converted to float by the schema
if config[CONF_BITS_PER_SAMPLE] != 16:
raise cv.Invalid("SPDIF mode only supports 16 bits per sample")
if not config[CONF_USE_APLL]:
raise cv.Invalid(
"SPDIF mode requires 'use_apll: true' for accurate clock generation"
@@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_.
xQueueReset(this->write_records_queue_);
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT;
// Ensure ring buffer duration is at least the duration of all DMA buffers
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices.
const size_t ring_buffer_size =
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz),
// not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size.
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames
const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES;
const size_t bytes_to_fill_single_dma_buffer =
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT;
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers.
const size_t requested_ring_buffer_bytes =
(this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes);
bool successful_setup = false;
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
@@ -177,8 +177,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// on_sent events drain in lockstep without crediting any audio frames.
this->spdif_encoder_->set_preload_mode(true);
for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) {
// i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait.
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0);
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
if (preload_err != ESP_OK) {
break; // DMA preload buffer full or error
}
@@ -411,9 +410,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
this->sample_rate_, audio_stream_info.get_sample_rate());
return ESP_ERR_NOT_SUPPORTED;
}
const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample();
if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) {
ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample);
if (audio_stream_info.get_bits_per_sample() != 16) {
ESP_LOGE(TAG, "Only supports 16 bits per sample");
return ESP_ERR_NOT_SUPPORTED;
}
if (audio_stream_info.get_channels() != 2) {
@@ -421,8 +419,11 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
return ESP_ERR_NOT_SUPPORTED;
}
// Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire.
this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8);
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO &&
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) {
ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration");
return ESP_ERR_NOT_SUPPORTED;
}
if (!this->parent_->try_lock()) {
ESP_LOGE(TAG, "Parent bus is busy");
@@ -2,7 +2,6 @@
#ifdef USE_ESP32
#include <driver/gpio.h>
#include <driver/i2s_std.h>
#include "esphome/components/audio/audio.h"
@@ -300,15 +299,6 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() {
i2s_channel_disable(this->tx_handle_);
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr;
// i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only
// clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
gpio_reset_pin(this->dout_pin_);
gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT);
gpio_set_level(this->dout_pin_, 0);
}
this->parent_->unlock();
}
@@ -19,6 +19,7 @@
namespace esphome::i2s_audio {
// Shared constants used by both standard and SPDIF speaker implementations
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t TASK_STACK_SIZE = 4096;
static constexpr ssize_t TASK_PRIORITY = 19;
@@ -16,7 +16,6 @@ namespace esphome::i2s_audio {
static const char *const TAG = "i2s_audio.speaker.std";
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t DMA_BUFFERS_COUNT = 4;
// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight,
// doubled so that a transient backlog never overruns the queue (which would desync the lockstep
@@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start)
static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel
// BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33
// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero.
// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33.
static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33;
// Constexpr BMC encoder for compile-time LUT generation.
@@ -36,43 +36,21 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) {
return bmc;
}
// Compile-time parity helper (constexpr-friendly, runs only at LUT build time).
static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) {
uint32_t p = 0;
for (uint32_t b = 0; b < num_bits; b++)
p ^= (value >> b) & 1u;
return p;
}
// Combined BMC + phase-delta lookup tables.
// Each entry packs the BMC pattern (lower bits, phase=high start) together with
// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0).
// XORing the delta into the running phase mask propagates parity across chunks
// without an explicit popcount.
// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash.
// Bits 0-7 : 8-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
// 4-bit BMC lookup table: 16 entries (16 bytes in flash)
// Index: 4-bit data value (0-15), always phase=true start
static constexpr auto BMC_LUT_4 = [] {
std::array<uint32_t, 16> t{};
for (uint32_t i = 0; i < 16; i++) {
uint32_t bmc = bmc_lut_encode(i, 4);
uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
std::array<uint8_t, 16> t{};
for (uint32_t i = 0; i < 16; i++)
t[i] = static_cast<uint8_t>(bmc_lut_encode(i, 4));
return t;
}();
// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash.
// Bits 0-15 : 16-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
// 8-bit BMC lookup table: 256 entries (512 bytes in flash)
// Index: 8-bit data value (0-255), always phase=true start
static constexpr auto BMC_LUT_8 = [] {
std::array<uint32_t, 256> t{};
for (uint32_t i = 0; i < 256; i++) {
uint32_t bmc = bmc_lut_encode(i, 8);
uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
std::array<uint16_t, 256> t{};
for (uint32_t i = 0; i < 256; i++)
t[i] = bmc_lut_encode(i, 8);
return t;
}();
@@ -85,7 +63,7 @@ bool SPDIFEncoder::setup() {
}
ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES);
// Build initial channel status block with default sample rate and width
// Build initial channel status block with default sample rate
this->build_channel_status_();
this->reset();
@@ -95,7 +73,7 @@ bool SPDIFEncoder::setup() {
void SPDIFEncoder::reset() {
this->spdif_block_ptr_ = this->spdif_block_buf_.get();
this->frame_in_block_ = 0;
this->block_buf_is_silence_block_ = false;
this->is_left_channel_ = true;
}
void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
@@ -106,27 +84,31 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
}
}
void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) {
if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) {
ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample);
return;
}
if (this->bytes_per_sample_ != bytes_per_sample) {
this->bytes_per_sample_ = bytes_per_sample;
this->build_channel_status_();
// Discard any partial block built at the previous width so we never mix widths on the wire.
this->reset();
ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8);
}
}
void SPDIFEncoder::build_channel_status_() {
// IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes)
// Transmitted LSB-first within each byte, one bit per frame via C bit.
// Any cached silence block was built for the previous channel status; it is now stale.
this->block_buf_is_silence_block_ = false;
// Transmitted LSB-first within each byte, one bit per frame via C bit
//
// Byte 0: Control bits
// Bit 0: 0 = Consumer format (not professional AES3)
// Bit 1: 0 = PCM audio (not non-audio data like AC3)
// Bit 2: 0 = No copyright assertion
// Bits 3-5: 000 = No pre-emphasis
// Bits 6-7: 00 = Mode 0 (basic consumer format)
//
// Byte 1: Category code (0x00 = general, 0x01 = CD, etc.)
//
// Byte 2: Source/channel numbers
// Bits 0-3: Source number (0 = unspecified)
// Bits 4-7: Channel number (0 = unspecified)
//
// Byte 3: Sample frequency and clock accuracy
// Bits 0-3: Sample frequency code
// Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32)
// Bits 6-7: Reserved (0)
//
// Bytes 4-23: Reserved (zeros for basic compliance)
// Clear all bytes first
this->channel_status_.fill(0);
// Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0
@@ -158,148 +140,132 @@ void SPDIFEncoder::build_channel_status_() {
// Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5
this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0
// Byte 4: Word length encoding (IEC 60958-3 consumer)
// bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits)
// bits 1-3: word length code relative to the max
// For our supported widths:
// 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20"
// 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24"
// 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code.
uint8_t word_length_code;
switch (this->bytes_per_sample_) {
case 2:
word_length_code = 0x02;
break;
case 3: // Shared case
case 4:
word_length_code = 0x0D;
break;
default:
word_length_code = 0x00; // not specified
break;
// Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.)
}
HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) {
// ============================================================================
// Build raw 32-bit subframe (IEC 60958 format)
// ============================================================================
// Bit layout:
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
// Bits 4-7: Auxiliary audio data (zeros for 16-bit audio)
// Bits 8-11: Audio LSB extension (zeros for 16-bit audio)
// Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB)
uint32_t raw_subframe = (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
// V = 0 (valid audio), U = 0 (no user data)
// C = channel status bit for current frame (same bit used for both L and R subframes)
bool c_bit = this->get_channel_status_bit_(this->frame_in_block_);
if (c_bit) {
raw_subframe |= (1U << 30);
}
this->channel_status_[4] = word_length_code;
}
// Extract the C bit for the given frame from channel_status_ and shift it into bit 30
// so it can be OR'd directly into a raw subframe.
ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array<uint8_t, 24> &channel_status,
uint32_t frame) {
return static_cast<uint32_t>((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30;
}
// Calculate even parity over bits 4-30
// This ensures consistent BMC ending phase regardless of audio content
uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30)
uint32_t ones_count = __builtin_popcount(bits_4_30);
uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even
raw_subframe |= parity << 31; // Set P bit to make total even
// ============================================================================
// IEC 60958 subframe bit layout
// ============================================================================
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
// Bits 4-7: Auxiliary audio data / 24-bit audio LSB
// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit)
// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes.
// Caller is responsible for OR-ing in the C bit and parity.
template<uint8_t Bps> ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) {
static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample");
if constexpr (Bps == 2) {
// 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27.
return (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
} else if constexpr (Bps == 3) {
// 24-bit input: full 24-bit audio field, bits 4-27.
return (static_cast<uint32_t>(pcm_sample[2]) << 20) | (static_cast<uint32_t>(pcm_sample[1]) << 12) |
(static_cast<uint32_t>(pcm_sample[0]) << 4);
} else { // Bps == 4
// 32-bit input truncated to 24-bit: drop the lowest byte.
return (static_cast<uint32_t>(pcm_sample[3]) << 20) | (static_cast<uint32_t>(pcm_sample[2]) << 12) |
(static_cast<uint32_t>(pcm_sample[1]) << 4);
}
}
// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes
// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is
// derived from the cumulative parity-mask delta of the per-byte LUT lookups.
//
// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7.
// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first.
// All preambles end at phase HIGH, so phase=true at the start of bit 4.
//
// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each
// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the
// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to
// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the
// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit
// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output
// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower
// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`.
template<uint8_t Bps>
ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) {
if constexpr (Bps == 2) {
// 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants.
// Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even),
// so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity.
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = lut_n & 0xFFu;
uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t lut_m = BMC_LUT_8[byte_mid];
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_m >> 16;
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint32_t lut_h = BMC_LUT_8[byte_hi];
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_h >> 16;
// phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31.
bmc_24_31 ^= phase_mask & 1u;
dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
// ============================================================================
// Select preamble based on position in block and channel
// ============================================================================
// B = block start (left channel, frame 0 of 192-frame block)
// M = left channel (frames 1-191)
// W = right channel (all frames)
uint8_t preamble;
if (this->is_left_channel_) {
preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
} else {
// 24-bit (and 32-bit truncated) path: bits 4-11 are live audio.
uint32_t byte_lo = (raw_subframe >> 4) & 0xFF;
uint32_t lut_l = BMC_LUT_8[byte_lo];
uint32_t bmc_4_11 = lut_l & 0xFFFFu;
uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu);
phase_mask ^= lut_n >> 16;
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t lut_m = BMC_LUT_8[byte_mid];
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_m >> 16;
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint32_t lut_h = BMC_LUT_8[byte_hi];
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_h >> 16;
bmc_24_31 ^= phase_mask & 1u;
// word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15)
// word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31)
dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
preamble = PREAMBLE_W;
}
}
template<uint8_t Bps> void SPDIFEncoder::encode_silence_frame_() {
static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0};
uint32_t raw = build_raw_subframe<Bps>(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_);
uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
bmc_encode_subframe<Bps>(raw, preamble_l, this->spdif_block_ptr_);
bmc_encode_subframe<Bps>(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2);
this->spdif_block_ptr_ += 4;
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
this->frame_in_block_ = 0;
// ============================================================================
// BMC encode the data portion (bits 4-31) using lookup tables
// ============================================================================
// The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15.
// This applies to BOTH word[0] and word[1].
//
// word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15]
// For correct S/PDIF subframe order (preamble → aux → audio):
// - bits 16-23: preamble (8 BMC bits)
// - bits 24-31: BMC(subframe bits 4-7) - first aux nibble
// - bits 0-7: BMC(subframe bits 8-11) - second aux nibble
// - bits 8-15: BMC(subframe bits 12-15) - audio low nibble
//
// word[1] transmission order: [16-31] → [0-15]
// For correct S/PDIF subframe order:
// - bits 16-31: BMC(subframe bits 16-23) - audio mid byte
// - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP
// ============================================================================
// All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio;
// two zero nibbles flip phase 8 times total → back to HIGH.
// So bits 12-15 always start encoding at phase=true.
// Bits 12-15: 4-bit LUT lookup (always phase=true start)
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t bmc_12_15 = BMC_LUT_4[nibble];
// Phase tracking via branchless XOR mask:
// - 0x0000 means phase=true (use LUT value directly)
// - 0xFFFF means phase=false (complement LUT value)
// End phase = start XOR (popcount & 1) since zero-bits flip phase,
// and for even bit widths: #zeros parity == popcount parity.
uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF;
// Bits 16-23: 8-bit LUT lookup with phase correction
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask;
phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF;
// Bits 24-31: 8-bit LUT lookup with phase correction
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF;
uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask;
// ============================================================================
// Combine with correct positioning for I2S transmission
// ============================================================================
// I2S with halfword swap: transmits bits 16-31, then bits 0-15.
// Within each halfword, MSB (highest bit) is transmitted first.
//
// For upper halfword (bits 16-31): bit 31 → bit 16
// For lower halfword (bits 0-15): bit 15 → bit 0
//
// Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15
//
// word[0] layout for correct transmission:
// bits 24-31: preamble (transmitted 1st, as MSB of upper halfword)
// bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7)
// bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11)
// bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble)
//
// word[1] layout:
// bits 16-31: bmc_16_23 (transmitted 5th)
// bits 0-15: bmc_24_31 (transmitted 6th)
this->spdif_block_ptr_[0] =
bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16);
this->spdif_block_ptr_ += 2;
// ============================================================================
// Update position tracking
// ============================================================================
if (!this->is_left_channel_) {
// Completed a stereo frame, advance frame counter
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
this->frame_in_block_ = 0;
}
}
this->is_left_channel_ = !this->is_left_channel_;
}
esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
@@ -329,162 +295,79 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
return err;
}
template<uint8_t Bps>
HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait,
uint32_t *blocks_sent, size_t *bytes_consumed) {
const uint8_t *pcm_data = src;
const uint8_t *const pcm_end = src + size;
uint32_t block_count = 0;
// Hot state lives in locals so the compiler can keep it in registers across the
// per-frame encoding work; byte writes through block_ptr may alias the member fields,
// which would block register allocation if the encoding read them directly from this->*.
uint32_t *block_ptr = this->spdif_block_ptr_;
uint32_t *const block_buf = this->spdif_block_buf_.get();
uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32;
uint32_t frame = this->frame_in_block_;
const std::array<uint8_t, 24> &channel_status = this->channel_status_;
auto save_state = [&]() {
this->spdif_block_ptr_ = block_ptr;
this->frame_in_block_ = static_cast<uint8_t>(frame);
};
auto report_out_params = [&]() {
if (blocks_sent != nullptr)
*blocks_sent = block_count;
if (bytes_consumed != nullptr)
*bytes_consumed = pcm_data - src;
};
// Send a completed block if the buffer is full, propagating any error.
// send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it
// unchanged on error -- mirror both behaviors in our local block_ptr.
auto maybe_send = [&]() -> esp_err_t {
if (block_ptr >= block_end) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
return ESP_OK;
};
// Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only
// buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from
// 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or
// preamble branch. The encoding body is inlined here so block_ptr lives in a register
// for the duration of the loop.
while (pcm_data + 2 * Bps <= pcm_end) {
if (frame == 0) {
esp_err_t err = maybe_send();
if (err != ESP_OK)
return err;
uint32_t c_bit = c_bit_for_frame(channel_status, 0);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_B, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
frame = 1;
pcm_data += 2 * Bps;
}
// The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The
// input-size bound is folded into end_frame so a single `frame < end_frame` test
// governs termination.
uint32_t input_frames = static_cast<uint32_t>(pcm_end - pcm_data) / (2u * Bps);
uint32_t end_frame = SPDIF_BLOCK_SAMPLES;
if (frame + input_frames < end_frame)
end_frame = frame + input_frames;
while (frame < end_frame) {
uint32_t c_bit = c_bit_for_frame(channel_status, frame);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_M, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
++frame;
pcm_data += 2 * Bps;
}
if (frame >= SPDIF_BLOCK_SAMPLES)
frame = 0;
size_t SPDIFEncoder::get_pending_pcm_bytes() const {
if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) {
return 0;
}
// Send any complete block that was just finished.
if (block_ptr >= block_end) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
save_state();
report_out_params();
return ESP_OK;
// Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer
// So pending uint32s / 2 = pending samples, and each sample is 2 bytes
size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get();
size_t pending_samples = pending_uint32s / 2;
return pending_samples * 2; // 2 bytes per sample
}
HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed) {
if (size > 0) {
// Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block.
this->block_buf_is_silence_block_ = false;
}
switch (this->bytes_per_sample_) {
case 2:
return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 3:
return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 4:
return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
default:
return ESP_ERR_INVALID_STATE;
}
}
const uint8_t *pcm_data = src;
const uint8_t *pcm_end = src + size;
uint32_t block_count = 0;
template<uint8_t Bps> esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) {
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty) and
// send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get());
// Continuous-silence idle case: a full silence block is byte-identical every time for the
// active channel status, so when the buffer already holds one, re-send it as-is.
if (was_empty && this->block_buf_is_silence_block_) {
return this->send_block_(ticks_to_wait);
while (pcm_data < pcm_end) {
// Check if there's a pending complete block from a previous failed send
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = pcm_data - src;
}
return err;
}
++block_count;
}
// Pad with silence frames at the configured width.
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_silence_frame_<Bps>();
}
// The buffer is a reusable full-silence block only if it was built entirely from silence; a
// partial real-audio block padded out with silence is not.
this->block_buf_is_silence_block_ = was_empty;
// Encode one 16-bit sample
this->encode_sample_(pcm_data);
pcm_data += 2;
}
return this->send_block_(ticks_to_wait);
// Send any complete block that was just finished
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = pcm_data - src;
}
return err;
}
++block_count;
}
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = size;
}
return ESP_OK;
}
esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) {
switch (this->bytes_per_sample_) {
case 2:
return this->flush_with_silence_typed_<2>(ticks_to_wait);
case 3:
return this->flush_with_silence_typed_<3>(ticks_to_wait);
case 4:
return this->flush_with_silence_typed_<4>(ticks_to_wait);
default:
return ESP_ERR_INVALID_STATE;
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty)
// and send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
static const uint8_t SILENCE[2] = {0, 0};
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_sample_(SILENCE);
}
}
return this->send_block_(ticks_to_wait);
}
} // namespace esphome::i2s_audio
@@ -24,6 +24,8 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT
static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768
// I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo)
static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames
// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels)
static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes
/// Callback signature for block completion (raw function pointer for minimal overhead)
/// @param user_ctx User context pointer passed during callback registration
@@ -62,16 +64,8 @@ class SPDIFEncoder {
/// @brief Check if currently in preload mode
bool is_preload_mode() const { return this->preload_mode_; }
/// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire).
/// Must be called before write() if input width changes from the default (16-bit). Triggers a
/// channel-status rebuild to reflect the new word length.
void set_bytes_per_sample(uint8_t bytes_per_sample);
/// @brief Get the configured input PCM width in bytes per sample
uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; }
/// @brief Convert PCM audio data to SPDIF BMC encoded data
/// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample)
/// @param src Source PCM audio data (16-bit stereo)
/// @param size Size of source data in bytes
/// @param ticks_to_wait Timeout for blocking writes
/// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent
@@ -80,6 +74,17 @@ class SPDIFEncoder {
esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr,
size_t *bytes_consumed = nullptr);
/// @brief Get the number of PCM bytes currently pending in the partial block buffer
/// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1)
size_t get_pending_pcm_bytes() const;
/// @brief Get the number of PCM frames currently pending in the partial block buffer
/// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1)
uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; }
/// @brief Check if there is a partial block pending
bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); }
/// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send,
/// or send a full silence block if nothing is pending. Always produces exactly one block on success.
/// @param ticks_to_wait Timeout for blocking writes
@@ -90,7 +95,7 @@ class SPDIFEncoder {
void reset();
/// @brief Set the sample rate for Channel Status Block encoding
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000)
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000)
/// Call this before writing audio data to ensure correct channel status.
void set_sample_rate(uint32_t sample_rate);
@@ -98,19 +103,8 @@ class SPDIFEncoder {
uint32_t get_sample_rate() const { return this->sample_rate_; }
protected:
/// @brief Encode a single stereo silence frame at the current block position.
/// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the
/// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers.
template<uint8_t Bps> void encode_silence_frame_();
/// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_.
template<uint8_t Bps>
HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed);
/// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width
/// (or builds a full silence block when nothing is pending) and sends it. Always emits one block.
template<uint8_t Bps> esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait);
/// @brief Encode a single 16-bit PCM sample into the current block position
HOT void encode_sample_(const uint8_t *pcm_sample);
/// @brief Send the completed block via the appropriate callback
esp_err_t send_block_(TickType_t ticks_to_wait);
@@ -118,6 +112,15 @@ class SPDIFEncoder {
/// @brief Build the channel status block from current configuration
void build_channel_status_();
/// @brief Get the channel status bit for a specific frame
/// @param frame Frame number (0-191)
/// @return The C bit value for this frame
ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const {
// Channel status is 192 bits transmitted over 192 frames
// Bit N is transmitted in frame N, LSB-first within each byte
return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1;
}
// Member ordering optimized to minimize padding (largest alignment first)
// 4-byte aligned members (pointers and uint32_t)
@@ -130,13 +133,9 @@ class SPDIFEncoder {
uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding
// 1-byte aligned members (grouped together to avoid internal padding)
uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire.
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool preload_mode_{false}; // Whether to use preload callback vs write callback
// True when spdif_block_buf_ currently holds a complete full-silence block valid for the active
// channel status. A full silence block is deterministic for a given sample rate and word length,
// so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding.
bool block_buf_is_silence_block_{false};
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool is_left_channel_{true}; // Alternates L/R for stereo samples
bool preload_mode_{false}; // Whether to use preload callback vs write callback
// Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames)
// Placed last since std::array<uint8_t> has 1-byte alignment
@@ -13,9 +13,7 @@ import subprocess
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
# - LN882H: stock linker has no glob for ".sram.text", so we inject
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
# immediately after KEEP(*(.vectors)), so the vector table stays at
# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment.
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH).
#
# All families also get a post-link summary showing where IRAM_ATTR landed.
@@ -29,11 +27,7 @@ _KEEP_LINE = (
"__esphome_sram_text_end = .; "
+ _MARKER + "\n"
)
# Inject after KEEP(*(.vectors)) so the vector table stays at
# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte-
# aligned address; injecting before the vectors would push them to an
# unaligned offset and mis-route every IRQ handler.
_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)")
_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)")
def _detect(env):
@@ -62,7 +56,7 @@ KNOWN_VARIANTS = frozenset({
def _inject_keep(host_section):
"""Return a patcher that injects _KEEP_LINE after `host_section` match."""
"""Return a patcher that injects _KEEP_LINE at the top of `host_section`."""
def patch(content):
if _MARKER in content:
return content
+2 -31
View File
@@ -86,22 +86,10 @@ class EffectRef:
component_path: list[str | int] # path_context when the action was validated
@dataclass
class EffectCycleRef:
"""A pending light.effect.next/previous action to validate.
Records that the referenced light needs at least one effect configured.
"""
light_id: ID
component_path: list[str | int]
@dataclass
class LightData:
gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr
effect_refs: list[EffectRef] = field(default_factory=list)
effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list)
def _get_data() -> LightData:
@@ -172,15 +160,13 @@ def _final_validate(config: ConfigType) -> ConfigType:
this never runs but the ID validator will catch the missing light ID separately.
"""
data = _get_data()
if not data.effect_refs and not data.effect_cycle_refs:
if not data.effect_refs:
return config
# Drain the lists so we only validate once even though
# Drain the list so we only validate once even though
# FINAL_VALIDATE_SCHEMA runs for each light platform instance.
refs = data.effect_refs
data.effect_refs = []
cycle_refs = data.effect_cycle_refs
data.effect_cycle_refs = []
fconf = fv.full_config.get()
@@ -202,21 +188,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
for ref in cycle_refs:
try:
light_path = fconf.get_path_for_id(ref.light_id)[:-1]
light_config = fconf.get_config_for_path(light_path)
except KeyError:
continue
if not light_config.get(CONF_EFFECTS):
raise cv.FinalExternalInvalid(
f"Light '{ref.light_id}' has no effects configured, but a "
f"'light.effect.next' or 'light.effect.previous' action "
f"references it. Add at least one effect to the light.",
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
return config
-41
View File
@@ -104,47 +104,6 @@ template<bool HasTransitionLength, typename... Ts> class DimRelativeAction : pub
transition_length_{};
};
// Cycle through the light's configured effects. `Forward` selects direction
// at compile time so the chosen branch is the only one that gets instantiated
// per action site. `include_none` is runtime so a single set of templates
// covers both the "wrap through None" and "skip None" variants.
template<bool Forward, typename... Ts> class LightEffectCycleAction : public Action<Ts...> {
public:
explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {}
void set_include_none(bool include_none) { this->include_none_ = include_none; }
void play(const Ts &...) override {
size_t count = this->parent_->get_effect_count();
if (count == 0) {
return;
}
uint32_t current = this->parent_->get_current_effect_index();
uint32_t next;
if (this->include_none_) {
uint32_t total = static_cast<uint32_t>(count) + 1;
if constexpr (Forward) {
next = (current + 1) % total;
} else {
next = (current + total - 1) % total;
}
} else {
if constexpr (Forward) {
next = (current % static_cast<uint32_t>(count)) + 1;
} else {
next = (current <= 1) ? static_cast<uint32_t>(count) : current - 1;
}
}
auto call = this->parent_->turn_on();
call.set_effect(next);
call.perform();
}
protected:
LightState *parent_;
bool include_none_{false};
};
template<typename... Ts> class LightIsOnCondition : public Condition<Ts...> {
public:
explicit LightIsOnCondition(LightState *state) : state_(state) {}
+2 -74
View File
@@ -26,8 +26,8 @@ from esphome.const import (
CONF_WARM_WHITE,
CONF_WHITE,
)
from esphome.core import CORE, ID, EsphomeError, Lambda
from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType
from esphome.core import CORE, EsphomeError, Lambda
from esphome.cpp_generator import LambdaExpression
from esphome.types import ConfigType
from .types import (
@@ -39,15 +39,12 @@ from .types import (
DimRelativeAction,
LightCall,
LightControlAction,
LightEffectCycleAction,
LightIsOffCondition,
LightIsOnCondition,
LightState,
ToggleAction,
)
CONF_INCLUDE_NONE = "include_none"
@automation.register_action(
"light.toggle",
@@ -256,75 +253,6 @@ async def light_control_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda)
def _record_effect_cycle_ref(config: ConfigType) -> ConfigType:
"""Record a cycle-action reference for later validation against the target light."""
from . import EffectCycleRef, _get_data
_get_data().effect_cycle_refs.append(
EffectCycleRef(
light_id=config[CONF_ID],
component_path=path_context.get(),
)
)
return config
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(LightState),
cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean,
}
)
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref)
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id(
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA
)
@automation.register_action(
"light.effect.next",
LightEffectCycleAction,
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
synchronous=True,
)
async def light_effect_next_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
return await _light_effect_cycle_to_code(config, action_id, template_arg, True)
@automation.register_action(
"light.effect.previous",
LightEffectCycleAction,
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
synchronous=True,
)
async def light_effect_previous_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
return await _light_effect_cycle_to_code(config, action_id, template_arg, False)
async def _light_effect_cycle_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
forward: bool,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
cycle_template_arg = cg.TemplateArguments(forward, *template_arg)
var = cg.new_Pvariable(action_id, cycle_template_arg, paren)
cg.add(var.set_include_none(config[CONF_INCLUDE_NONE]))
return var
CONF_RELATIVE_BRIGHTNESS = "relative_brightness"
LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema(
{
-1
View File
@@ -39,7 +39,6 @@ LIMIT_MODES = {
# Actions
ToggleAction = light_ns.class_("ToggleAction", automation.Action)
LightControlAction = light_ns.class_("LightControlAction", automation.Action)
LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action)
DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action)
AddressableSet = light_ns.class_("AddressableSet", automation.Action)
LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition)
-5
View File
@@ -55,7 +55,6 @@ from .automation import layers_to_code, lvgl_update
from .defines import (
CONF_ALIGN_TO_LAMBDA_ID,
LOGGER,
add_lv_use,
get_focused_widgets,
get_lv_images_used,
get_refreshed_widgets,
@@ -72,7 +71,6 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code
from .lv_validation import lv_bool
from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static
from .schemas import (
BASE_PROPS,
DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA,
STYLE_REMAP,
@@ -102,7 +100,6 @@ from .widgets import (
get_screen_active,
set_obj_properties,
)
from .widgets.img import CONF_IMAGE
# Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
@@ -436,8 +433,6 @@ async def to_code(configs):
# This must be done after all widgets are created
styles_used = df.get_styles_used()
if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used):
add_lv_use(CONF_IMAGE)
for use in df.get_lv_uses():
df.add_define(f"LV_USE_{use.upper()}")
cg.add_define(f"USE_LVGL_{use.upper()}")
+4 -6
View File
@@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
lv_style_set_text_font(style, font->get_lv_font());
}
#endif
#ifdef USE_IMAGE
#ifdef USE_LVGL_IMAGE
#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE)
#if LV_USE_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
#endif // LV_USE_IMAGE
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
@@ -93,8 +93,7 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) {
inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) {
::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc());
}
#endif
#endif // USE_LVGL_IMAGE
#ifdef USE_LVGL_ANIMIMG
inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images) {
auto *dsc = static_cast<std::vector<lv_image_dsc_t *> *>(lv_obj_get_user_data(img));
@@ -110,7 +109,6 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size());
}
#endif // USE_LVGL_ANIMIMG
#endif // USE_IMAGE
#ifdef USE_LVGL_METER
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
+10 -32
View File
@@ -1,5 +1,4 @@
from collections.abc import Callable
from typing import Any
from esphome import config_validation as cv
from esphome.automation import Trigger, validate_automation
@@ -535,16 +534,7 @@ def strip_defaults(schema: cv.Schema):
return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()})
# Keyed by (id(widget_type), id(extras)); strong refs in the value keep both
# alive so id() can't be recycled.
_CONTAINER_SCHEMA_CACHE: dict[
tuple[int, int], tuple[Any, Any, Callable[[Any], Any]]
] = {}
def container_schema(
widget_type: WidgetType, extras: Any = None
) -> Callable[[Any], Any]:
def container_schema(widget_type: WidgetType, extras=None):
"""
Create a schema for a container widget of a given type. All obj properties are available, plus
the extras passed in, plus any defined for the specific widget being specified.
@@ -552,31 +542,19 @@ def container_schema(
:param extras: Additional options to be made available, e.g. layout properties for children
:return: The schema for this type of widget.
"""
cache_key = (id(widget_type), id(extras))
cached = _CONTAINER_SCHEMA_CACHE.get(cache_key)
if cached is not None:
cached_widget_type, cached_extras, cached_validator = cached
if cached_widget_type is widget_type and cached_extras is extras:
return cached_validator
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
# Delayed evaluation for recursion
cached_schema: cv.Schema | None = None
schema = schema.extend(widget_type.schema)
def get_schema() -> cv.Schema:
nonlocal cached_schema
if cached_schema is None:
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
cached_schema = schema.extend(widget_type.schema)
return cached_schema
def validator(value: Any) -> Any:
def validator(value):
value = value or {}
return append_layout_schema(get_schema(), value)(value)
return append_layout_schema(schema, value)(value)
_CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator)
return validator
-2
View File
@@ -9,7 +9,6 @@ from .defines import (
CONF_THEME,
LValidator,
add_lv_use,
get_styles_used,
get_theme_widget_map,
literal,
)
@@ -26,7 +25,6 @@ def has_style_props(config) -> bool:
async def style_set(svar, style):
for prop, validator in ALL_STYLES.items():
if (value := style.get(prop)) is not None:
get_styles_used().add(prop)
if isinstance(validator, LValidator):
value = await validator.process(value)
if isinstance(value, list):
+3 -33
View File
@@ -1,6 +1,4 @@
from collections.abc import Callable
import sys
from typing import Any
from esphome import codegen as cg, config_validation as cv
from esphome.automation import register_action
@@ -17,7 +15,6 @@ from esphome.const import (
from esphome.core import ID, EsphomeError, TimePeriod
from esphome.coroutine import FakeAwaitable
from esphome.cpp_generator import MockObj
from esphome.schema_extractors import EnableSchemaExtraction
from esphome.types import Expression
from ..defines import (
@@ -76,34 +73,6 @@ from ..types import (
EVENT_LAMB = "event_lamb__"
def _build_update_schema(widget_type: "WidgetType") -> Schema:
# Local import: ..schemas imports WidgetType from this module.
from ..schemas import base_update_schema
return base_update_schema(widget_type, widget_type.parts).extend(
widget_type.modify_schema
)
def _update_action_schema(
widget_type: "WidgetType",
) -> Schema | Callable[[Any], Any]:
# Eager when extracting so build_language_schema.py sees the mapping;
# lazy otherwise to skip ~200 ms of import-time voluptuous work.
if EnableSchemaExtraction:
return _build_update_schema(widget_type)
cached: Schema | None = None
def validator(value: Any) -> Any:
nonlocal cached
if cached is None:
cached = _build_update_schema(widget_type)
return cached(value)
return validator
class WidgetType:
"""
Describes a type of Widget, e.g. "bar" or "line"
@@ -144,17 +113,18 @@ class WidgetType:
# Local import to avoid circular import
from ..automation import update_to_code
from ..schemas import WIDGET_TYPES
from ..schemas import WIDGET_TYPES, base_update_schema
if not is_mock:
if self.name in WIDGET_TYPES:
raise EsphomeError(f"Duplicate definition of widget type '{self.name}'")
WIDGET_TYPES[self.name] = self
# Register the update action automatically, adding widget-specific properties
register_action(
f"lvgl.{self.name}.update",
ObjUpdateAction,
_update_action_schema(self),
base_update_schema(self, self.parts).extend(self.modify_schema),
synchronous=True,
)(update_to_code)
+1 -240
View File
@@ -5,15 +5,8 @@ import esphome.codegen as cg
from esphome.components.esp32 import add_idf_sdkconfig_option
from esphome.components.psram import is_guaranteed as psram_is_guaranteed
import esphome.config_validation as cv
from esphome.const import (
CONF_ENABLE_IPV6,
CONF_ID,
CONF_MIN_IPV6_ADDR_COUNT,
CONF_PRIORITY,
CONF_TIMEOUT,
)
from esphome.const import CONF_ENABLE_IPV6, CONF_MIN_IPV6_ADDR_COUNT
from esphome.core import CORE, CoroPriority, coroutine_with_priority
import esphome.final_validate as fv
CODEOWNERS = ["@esphome/core"]
AUTO_LOAD = ["mdns"]
@@ -25,30 +18,7 @@ _LOGGER = logging.getLogger(__name__)
KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking"
CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance"
# Network priority tracking infrastructure
# Components can query this to determine their relative setup priority and fallback timeout.
# CORE.data[KEY_NETWORK_PRIORITY] is a list of dicts:
# [{"interface": "ethernet", "timeout": 30000}, {"interface": "wifi", "timeout": None}, ...]
# where timeout is in milliseconds, or None meaning "start the next interface immediately".
KEY_NETWORK_PRIORITY = "network_priority"
VALID_NETWORK_TYPES = ["ethernet", "openthread", "wifi", "modem"]
# Setup priority base values — first in list gets the highest priority.
#
# The base equals the historical setup_priority::WIFI / ::ETHERNET default
# (250.0), so a single-entry priority list yields exactly the same setup order
# as a config with no priority block. Subsequent entries step down by a small
# amount to break ties without crossing other priority bands.
#
# Important: must stay strictly less than setup_priority::AFTER_BLUETOOTH
# (300.0), which NetworkComponent itself uses — otherwise the highest-priority
# interface could tie with NetworkComponent and run before esp_netif_init().
NETWORK_PRIORITY_BASE = 250.0
NETWORK_PRIORITY_STEP = 5.0
network_ns = cg.esphome_ns.namespace("network")
NetworkComponent = network_ns.class_("NetworkComponent", cg.Component)
IPAddress = network_ns.class_("IPAddress")
@@ -135,160 +105,8 @@ def has_high_performance_networking() -> bool:
return CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False)
def _get_priority_entry(iface: str) -> dict | None:
"""Return the priority entry dict for the given interface, or None if not configured."""
priority_list = CORE.data.get(KEY_NETWORK_PRIORITY)
if priority_list is None:
return None
iface_lower = iface.lower()
for entry in priority_list:
if entry["interface"] == iface_lower:
return entry
return None
def get_network_priority(iface: str) -> float | None:
"""Get the setup priority for the given network interface type.
Returns the float setup priority for ``iface`` based on the order declared
under ``network: priority:``. Interfaces listed first receive a higher
setup priority so they are initialised before lower-priority ones.
If no ``network: priority:`` has been configured this returns ``None`` and
the calling component should fall back to its own default setup priority.
Args:
iface: Interface type string one of ``"ethernet"``, ``"wifi"``,
``"openthread"`` or ``"modem"`` (case-insensitive).
Returns:
float setup priority, or None if no priority list was configured.
Example usage inside a component's ``to_code``::
from esphome.components import network
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
prio = network.get_network_priority("ethernet")
if prio is not None:
cg.add(var.set_setup_priority(prio))
...
"""
priority_list = CORE.data.get(KEY_NETWORK_PRIORITY)
if priority_list is None:
return None
iface_lower = iface.lower()
for idx, entry in enumerate(priority_list):
if entry["interface"] == iface_lower:
return NETWORK_PRIORITY_BASE - (idx * NETWORK_PRIORITY_STEP)
return None
def get_network_timeout(iface: str) -> int | None:
"""Get the fallback timeout in milliseconds for the given network interface.
Returns the timeout (in ms) that the runtime should wait for ``iface`` to
connect before attempting to bring up the next interface in the priority
list. Returns ``None`` if no timeout was configured for this interface,
meaning the next interface should start immediately.
Args:
iface: Interface type string one of ``"ethernet"``, ``"wifi"``,
``"openthread"`` or ``"modem"`` (case-insensitive).
Returns:
int timeout in milliseconds, or None if no timeout is configured.
Example usage inside a component's ``to_code``::
from esphome.components import network
async def to_code(config):
...
timeout_ms = network.get_network_timeout("ethernet")
if timeout_ms is not None:
cg.add(var.set_fallback_timeout(timeout_ms))
...
"""
entry = _get_priority_entry(iface)
if entry is None:
return None
return entry.get("timeout")
def _validate_timeout(value):
"""Accept any common ESPHome/HA time period format, or a plain integer as seconds.
Accepted formats: 30s, 10sec, 1min, 500ms, 1h, 1.5h, 30 (plain int 30s).
"""
if isinstance(value, int):
# Plain integer — treat as seconds, e.g. timeout: 30 means 30s
return cv.positive_time_period_milliseconds(f"{value}s")
return cv.positive_time_period_milliseconds(value)
def _priority_entry_schema(value):
"""Validate a single priority list entry in either plain string or mapping form.
Plain string form (no timeout):
- ethernet
Mapping form with optional timeout:
- ethernet:
timeout: 30s
"""
if isinstance(value, str):
return cv.one_of(*VALID_NETWORK_TYPES, lower=True)(value)
if isinstance(value, dict):
if len(value) != 1:
raise cv.Invalid(
"Each priority entry must have exactly one interface name as its key"
)
iface = next(iter(value))
cv.one_of(*VALID_NETWORK_TYPES, lower=True)(iface)
opts = cv.Schema(
{
cv.Optional(CONF_TIMEOUT): _validate_timeout,
}
)(value[iface] or {})
return {iface: opts}
raise cv.Invalid(
f"Expected an interface name string or a mapping, got {type(value).__name__}"
)
def _normalize_priority_entry(value) -> dict:
"""Normalize a validated priority entry to a canonical dict.
Returns a dict with keys:
- ``interface``: str, lowercase interface name
- ``timeout``: int milliseconds, or None
"""
if isinstance(value, str):
return {"interface": value, "timeout": None}
# Mapping form — exactly one key (the interface name)
iface, opts = next(iter(value.items()))
timeout = opts.get(CONF_TIMEOUT)
timeout_ms = int(timeout.total_milliseconds) if timeout is not None else None
return {"interface": iface, "timeout": timeout_ms}
def _validate_priority_list(value):
"""Validate and normalize the full priority list, rejecting duplicates."""
raw = cv.ensure_list(_priority_entry_schema)(value)
entries = [_normalize_priority_entry(e) for e in raw]
interfaces = [e["interface"] for e in entries]
if len(interfaces) != len(set(interfaces)):
raise cv.Invalid("Duplicate entries are not allowed in 'priority'")
return entries
CONFIG_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(NetworkComponent),
cv.SplitDefault(
CONF_ENABLE_IPV6,
bk72xx=False,
@@ -312,53 +130,15 @@ CONFIG_SCHEMA = cv.Schema(
),
cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int,
cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32),
cv.Optional(CONF_PRIORITY): _validate_priority_list,
}
)
def _final_validate(config):
"""Check that every interface named in 'priority' has a corresponding component block."""
full = fv.full_config.get()
for entry in config.get(CONF_PRIORITY, []):
iface = entry["interface"]
if iface not in full:
raise cv.Invalid(
f"'{iface}' is listed in 'network: priority:' but no '{iface}:' "
f"component is configured",
[CONF_PRIORITY],
)
FINAL_VALIDATE_SCHEMA = _final_validate
@coroutine_with_priority(CoroPriority.NETWORK)
async def to_code(config):
cg.add_define("USE_NETWORK")
# ESP32 with Arduino uses ESP-IDF network APIs directly, no Arduino Network library needed
# Store the user-declared network priority list in CORE.data so that ethernet,
# wifi and other network components can query it via get_network_priority() and
# get_network_timeout() during their own to_code phase.
if CONF_PRIORITY in config:
priority_list = config[CONF_PRIORITY]
CORE.data[KEY_NETWORK_PRIORITY] = priority_list
# Enable Component::set_setup_priority() so the per-interface to_code
# calls below have a defined symbol to link against. Without this define
# the implementation in core/component.cpp is compiled out.
cg.add_define("USE_SETUP_PRIORITY_OVERRIDE")
def _fmt(entry):
if entry["timeout"] is not None:
return f"{entry['interface']} (timeout: {entry['timeout']}ms)"
return entry["interface"]
_LOGGER.info(
"Network interface priority: %s",
" > ".join(_fmt(e) for e in priority_list),
)
# Apply high performance networking settings
# Config can explicitly enable/disable, or default to component-driven behavior
enable_high_perf = config.get(CONF_ENABLE_HIGH_PERFORMANCE)
@@ -444,22 +224,3 @@ async def to_code(config):
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY")
if CORE.is_rp2040:
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6")
# Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it
# emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config
# (above) separate from C++ object lifecycle and allows wiring in interface
# pointers via get_variable().
if CORE.is_esp32:
CORE.add_job(network_component_to_code, config)
@coroutine_with_priority(CoroPriority.NETWORK_SERVICES)
async def network_component_to_code(config: ConfigType) -> None:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# Pass the priority list to the C++ component. NetworkComponent::add_priority_entry
# captures the interface-name string literal pointer; CORE.data[KEY_NETWORK_PRIORITY]
# holds the normalized list of dicts (`{"interface": str, "timeout": int|None}`).
for entry in CORE.data.get(KEY_NETWORK_PRIORITY, []):
timeout_ms = entry["timeout"] if entry["timeout"] is not None else 0
cg.add(var.add_priority_entry(entry["interface"], timeout_ms))
@@ -1,119 +0,0 @@
#include "network_component.h"
#include "esphome/core/defines.h"
#if defined(USE_NETWORK) && defined(USE_ESP32)
#include "esphome/core/log.h"
#include <cstring>
#include "esp_err.h"
#include "esp_event.h"
#include "esp_netif.h"
namespace esphome::network {
static const char *const TAG = "network";
void NetworkComponent::setup() {
// Initialize ESP-IDF network interfaces and ensure the default event loop exists
esp_err_t err;
err = esp_netif_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_netif_init failed: (%d) %s", err, esp_err_to_name(err));
this->mark_failed();
return;
}
err = esp_event_loop_create_default();
// ESP_ERR_INVALID_STATE is returned if the default loop already exists,
// which is fine since we just want to make sure it exists
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
ESP_LOGE(TAG, "esp_event_loop_create_default failed: (%d) %s", err, esp_err_to_name(err));
this->mark_failed();
return;
}
// Register an IP_EVENT handler so we can re-arbitrate the default netif on every
// interface up/down. ESP-IDF's built-in auto-selection picks by route_prio (WiFi STA = 100
// > Ethernet = 50), which inverts the user's stated priority for same-subnet configurations.
// We register AFTER esp-idf's internal handler, so our esp_netif_set_default_netif() call
// wins and stays sticky thanks to esp-idf's "manual override" flag.
err = esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &NetworkComponent::event_handler_, this);
if (err != ESP_OK) {
ESP_LOGW(TAG, "IP_EVENT handler register failed: %s — default route arbitration disabled",
esp_err_to_name(err));
}
// Defensive: arbitrate now in case an interface came up before our handler registered
// (unlikely given our AFTER_BLUETOOTH priority but cheap).
this->update_default_netif_();
}
void NetworkComponent::add_priority_entry(const char *interface, uint32_t timeout_ms) {
if (this->priority_list_.size() >= MAX_NETWORK_PRIORITY_ENTRIES) {
ESP_LOGW(TAG, "Priority list full; ignoring '%s'", interface);
return;
}
this->priority_list_.push_back({interface, timeout_ms});
}
const char *NetworkComponent::interface_to_ifkey_(const char *interface) {
// Standard ESP-IDF netif keys. esphome's wifi/ethernet/openthread components create
// netifs using these defaults.
if (std::strcmp(interface, "ethernet") == 0)
return "ETH_DEF";
if (std::strcmp(interface, "wifi") == 0)
return "WIFI_STA_DEF"; // STA carries uplink; AP never wins default route
if (std::strcmp(interface, "openthread") == 0)
return "OT_DEF";
if (std::strcmp(interface, "modem") == 0)
return "PPP_DEF";
return nullptr;
}
void NetworkComponent::event_handler_(void *arg, esp_event_base_t /*base*/, int32_t /*id*/, void * /*data*/) {
auto *self = static_cast<NetworkComponent *>(arg);
self->update_default_netif_();
}
void NetworkComponent::update_default_netif_() {
// No priority list configured → leave ESP-IDF's route_prio-based auto-selection alone.
// Single-interface configs behave exactly as before.
if (this->priority_list_.empty()) {
return;
}
for (const auto &entry : this->priority_list_) {
const char *ifkey = interface_to_ifkey_(entry.interface);
if (ifkey == nullptr)
continue;
esp_netif_t *netif = esp_netif_get_handle_from_ifkey(ifkey);
if (netif == nullptr)
continue; // component for this interface hasn't run setup() yet
// is_netif_up returns true only when the netif has link + IP, which is what
// we want for "this interface can carry traffic right now."
if (!esp_netif_is_netif_up(netif))
continue;
if (netif != this->active_netif_) {
ESP_LOGI(TAG, "Default interface: %s", entry.interface);
esp_netif_set_default_netif(netif);
this->active_interface_ = entry.interface;
this->active_netif_ = netif;
}
return;
}
// No priority-listed interface is currently up.
if (this->active_netif_ != nullptr) {
ESP_LOGD(TAG, "No active interface in priority list");
this->active_interface_ = nullptr;
this->active_netif_ = nullptr;
// We intentionally don't clear esp-idf's default — the next interface that comes
// up will trigger our handler again and we'll re-pick.
}
}
} // namespace esphome::network
#endif
@@ -1,54 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_NETWORK) && defined(USE_ESP32)
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
#include "esp_event.h"
#include "esp_netif.h"
namespace esphome::network {
// Cap matches the number of interface types the priority list accepts in YAML
// (ethernet, wifi, openthread, modem). StaticVector keeps zero heap allocation.
inline constexpr size_t MAX_NETWORK_PRIORITY_ENTRIES = 4;
struct NetworkPriorityEntry {
const char *interface; // YAML name: "ethernet", "wifi", "openthread", "modem"
uint32_t timeout_ms; // 0 = no timeout; consumed by Unit D (runtime fallback)
};
class NetworkComponent : public Component {
public:
void setup() override;
// AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance.
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
// Codegen-time priority list construction. Called once per `network: priority:` entry
// in YAML order. The interface name pointer must have static storage duration.
void add_priority_entry(const char *interface, uint32_t timeout_ms);
// Currently-active interface in priority order (the one set as default netif).
// Returns nullptr if no priority list is configured or no interface is up.
const char *get_active_interface() const { return this->active_interface_; }
esp_netif_t *get_active_netif() const { return this->active_netif_; }
protected:
// Maps a YAML interface name to its ESP-IDF netif if-key.
// Returns nullptr if the interface name is not recognized.
static const char *interface_to_ifkey_(const char *interface);
// ESP-IDF event handler trampoline. Fires on IP_EVENT_* and re-arbitrates the default netif.
static void event_handler_(void *arg, esp_event_base_t base, int32_t id, void *data);
// Walk priority_list_ in order. Set the highest-priority netif that is up as the
// ESP-IDF default. No-op if priority_list_ is empty (single-interface configs).
void update_default_netif_();
StaticVector<NetworkPriorityEntry, MAX_NETWORK_PRIORITY_ENTRIES> priority_list_;
const char *active_interface_{nullptr};
esp_netif_t *active_netif_{nullptr};
};
} // namespace esphome::network
#endif
@@ -35,8 +35,9 @@ void OpenThreadComponent::setup() {
esp_vfs_eventfd_config_t eventfd_config = {
.max_fds = 3,
};
// Network interface setup handled by network component
ESP_ERROR_CHECK(nvs_flash_init());
ESP_ERROR_CHECK(esp_event_loop_create_default());
ESP_ERROR_CHECK(esp_netif_init());
ESP_ERROR_CHECK(esp_vfs_eventfd_register(&eventfd_config));
xTaskCreate(
+2
View File
@@ -25,6 +25,7 @@ from esphome.const import (
CONF_TEMPERATURE_COMPENSATION,
CONF_TIME_CONSTANT,
CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
DEVICE_CLASS_PM10,
@@ -76,6 +77,7 @@ def _gas_sensor(
return sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(
{
+3
View File
@@ -14,6 +14,7 @@ from esphome.const import (
CONF_TEMPERATURE,
CONF_TYPE,
CONF_VOC,
DEVICE_CLASS_AQI,
DEVICE_CLASS_CARBON_DIOXIDE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PM1,
@@ -92,11 +93,13 @@ CONFIG_SCHEMA = (
cv.Optional(CONF_VOC): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_NOX): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional(CONF_CO2): sensor.sensor_schema(
+1 -1
View File
@@ -206,7 +206,7 @@ async def to_code(config: ConfigType) -> None:
)
# sendspin-cpp library
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.5.0")
cg.add_define("USE_SENDSPIN", True) # for MDNS
-2
View File
@@ -96,7 +96,6 @@ from esphome.const import (
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TEMPERATURE_DELTA,
DEVICE_CLASS_TIMESTAMP,
DEVICE_CLASS_UPTIME,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
DEVICE_CLASS_VOLTAGE,
@@ -175,7 +174,6 @@ DEVICE_CLASSES = [
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TEMPERATURE_DELTA,
DEVICE_CLASS_TIMESTAMP,
DEVICE_CLASS_UPTIME,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS,
DEVICE_CLASS_VOLTAGE,
+3
View File
@@ -15,6 +15,7 @@ from esphome.const import (
CONF_STORE_BASELINE,
CONF_TEMPERATURE_SOURCE,
CONF_VOC,
DEVICE_CLASS_AQI,
ICON_RADIATOR,
STATE_CLASS_MEASUREMENT,
)
@@ -71,11 +72,13 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_VOC): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(VOC_SENSOR),
cv.Optional(CONF_NOX): sensor.sensor_schema(
icon=ICON_RADIATOR,
accuracy_decimals=0,
device_class=DEVICE_CLASS_AQI,
state_class=STATE_CLASS_MEASUREMENT,
).extend(NOX_SENSOR),
cv.Optional(CONF_STORE_BASELINE, default=True): cv.boolean,
+6 -6
View File
@@ -30,8 +30,8 @@ static constexpr uint8_t OCP_140MA = 0x38; // 140 mA max current
static constexpr float LOW_DATA_RATE_OPTIMIZE_THRESHOLD = 16.38f; // 16.38 ms
uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
this->enable();
this->wait_busy_();
this->enable();
this->transfer_byte(RADIO_READ_BUFFER);
this->transfer_byte(offset);
uint8_t status = this->transfer_byte(0x00);
@@ -43,8 +43,8 @@ uint8_t SX126x::read_fifo_(uint8_t offset, std::vector<uint8_t> &packet) {
}
void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) {
this->enable();
this->wait_busy_();
this->enable();
this->transfer_byte(RADIO_WRITE_BUFFER);
this->transfer_byte(offset);
for (const uint8_t &byte : packet) {
@@ -55,8 +55,8 @@ void SX126x::write_fifo_(uint8_t offset, const std::vector<uint8_t> &packet) {
}
uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->enable();
this->transfer_byte(opcode);
uint8_t status = this->transfer_byte(0x00);
for (int32_t i = 0; i < size; i++) {
@@ -67,8 +67,8 @@ uint8_t SX126x::read_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
}
void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->enable();
this->transfer_byte(opcode);
for (int32_t i = 0; i < size; i++) {
this->transfer_byte(data[i]);
@@ -78,8 +78,8 @@ void SX126x::write_opcode_(uint8_t opcode, uint8_t *data, uint8_t size) {
}
void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->enable();
this->write_byte(RADIO_READ_REGISTER);
this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF);
@@ -91,8 +91,8 @@ void SX126x::read_register_(uint16_t reg, uint8_t *data, uint8_t size) {
}
void SX126x::write_register_(uint16_t reg, uint8_t *data, uint8_t size) {
this->enable();
this->wait_busy_();
this->enable();
this->write_byte(RADIO_WRITE_REGISTER);
this->write_byte((reg >> 8) & 0xFF);
this->write_byte((reg >> 0) & 0xFF);
+7 -9
View File
@@ -206,17 +206,15 @@ void Tuya::handle_command_(uint8_t command, uint8_t version, const uint8_t *buff
if (this->status_pin_reported_ != -1) {
this->init_state_ = TuyaInitState::INIT_DATAPOINT;
this->send_empty_command_(TuyaCommandType::DATAPOINT_QUERY);
if (this->status_pin_ != nullptr) {
if (this->status_pin_->get_pin() != this->status_pin_reported_) {
ESP_LOGW(TAG, "Supplied status_pin does not equal the reported pin %i. Using supplied pin anyway.",
this->status_pin_reported_);
}
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
} else {
ESP_LOGW(TAG, "MCU reported status_pin %i but no status_pin was configured; running in limited mode.",
bool is_pin_equals =
this->status_pin_ != nullptr && this->status_pin_->get_pin() == this->status_pin_reported_;
// Configure status pin toggling (if reported and configured) or WIFI_STATE periodic send
if (!is_pin_equals) {
ESP_LOGW(TAG, "Supplied status_pin does not equals the reported pin %i. Using supplied pin anyway.",
this->status_pin_reported_);
}
ESP_LOGV(TAG, "Configured status pin %i", this->status_pin_->get_pin());
this->set_interval("wifi", 1000, [this] { this->set_status_pin_(); });
} else {
this->init_state_ = TuyaInitState::INIT_WIFI;
ESP_LOGV(TAG, "Configured WIFI_STATE periodic send");
+4 -5
View File
@@ -513,11 +513,10 @@ async def uart_write_to_code(config, action_id, template_arg, args):
@coroutine_with_priority(CoroPriority.FINAL)
async def final_step():
"""Final code generation step to configure optional UART features."""
if (CORE.is_esp32 or CORE.is_esp8266) and CORE.has_networking:
# Wake-on-RX is essentially free (just an ISR function pointer
# registration on ESP32, an inline flag set on ESP8266 software
# serial) — enable by default to reduce RX buffer overflow risk by
# waking the main loop immediately when data arrives.
if CORE.is_esp32 and CORE.has_networking:
# Wake-on-RX is essentially free on ESP32 (just an ISR function pointer
# registration) — enable by default to reduce RX buffer overflow risk
# by waking the main loop immediately when data arrives.
cg.add_define("USE_UART_WAKE_LOOP_ON_RX")
@@ -4,9 +4,6 @@
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#ifdef USE_UART_WAKE_LOOP_ON_RX
#include "esphome/core/wake.h"
#endif
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
@@ -152,11 +149,7 @@ void ESP8266UartComponent::dump_config() {
if (this->hw_serial_ != nullptr) {
ESP_LOGCONFIG(TAG, " Using hardware serial interface.");
} else {
ESP_LOGCONFIG(TAG, " Using software serial"
#ifdef USE_UART_WAKE_LOOP_ON_RX
"\n Wake on data RX: ENABLED"
#endif
);
ESP_LOGCONFIG(TAG, " Using software serial");
}
this->check_logger_conflict();
}
@@ -273,12 +266,6 @@ void IRAM_ATTR ESP8266SoftwareSerial::gpio_intr(ESP8266SoftwareSerial *arg) {
arg->rx_in_pos_ = (arg->rx_in_pos_ + 1) % arg->rx_buffer_size_;
// Clear RX pin so that the interrupt doesn't re-trigger right away again.
arg->rx_pin_.clear_interrupt();
#ifdef USE_UART_WAKE_LOOP_ON_RX
// Wake the main loop so the consuming component drains the byte promptly
// instead of waiting for the next loop_interval_ tick. Important for timing
// sensitive setups that poll read() in a tight loop (e.g. fingerprint_grow).
wake_loop_isrsafe();
#endif
}
void IRAM_ATTR HOT ESP8266SoftwareSerial::write_byte(uint8_t data) {
if (this->gpio_tx_pin_ == nullptr) {
+3 -2
View File
@@ -4,7 +4,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_TIME_ID,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_UPTIME,
DEVICE_CLASS_TIMESTAMP,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_TIMER,
STATE_CLASS_TOTAL_INCREASING,
@@ -33,8 +33,9 @@ CONFIG_SCHEMA = cv.typed_schema(
).extend(cv.polling_component_schema("60s")),
"timestamp": sensor.sensor_schema(
UptimeTimestampSensor,
icon=ICON_TIMER,
accuracy_decimals=0,
device_class=DEVICE_CLASS_UPTIME,
device_class=DEVICE_CLASS_TIMESTAMP,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
)
.extend(
+2 -57
View File
@@ -12,7 +12,6 @@ from esphome.components.esp32 import (
only_on_variant,
)
from esphome.components.network import (
get_network_priority,
has_high_performance_networking,
ip_address_literal,
)
@@ -43,6 +42,7 @@ from esphome.const import (
CONF_ON_CONNECT,
CONF_ON_DISCONNECT,
CONF_ON_ERROR,
CONF_OUTPUT_POWER,
CONF_PASSWORD,
CONF_POWER_SAVE_MODE,
CONF_PRIORITY,
@@ -54,18 +54,10 @@ from esphome.const import (
CONF_TTLS_PHASE_2,
CONF_USE_ADDRESS,
CONF_USERNAME,
CONF_WIFI,
PLACEHOLDER_WIFI_SSID,
Platform,
PlatformFramework,
)
from esphome.core import (
CORE,
CoroPriority,
EsphomeError,
HexInt,
coroutine_with_priority,
)
from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority
import esphome.final_validate as fv
from esphome.types import ConfigType
@@ -440,7 +432,6 @@ def _validate(config):
return config
CONF_OUTPUT_POWER = "output_power"
CONF_PASSIVE_SCAN = "passive_scan"
CONFIG_SCHEMA = cv.All(
cv.Schema(
@@ -574,10 +565,6 @@ def wifi_network(config, ap, static_ip):
@coroutine_with_priority(CoroPriority.COMMUNICATION)
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
prio = get_network_priority("wifi")
if prio is not None:
cg.add(var.set_setup_priority(prio))
cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
# Track if any network uses Enterprise authentication
@@ -916,45 +903,3 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
"wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO},
}
)
def _placeholder_wifi_credentials(config: ConfigType) -> list[str]:
"""Return human-readable locations where the dashboard's placeholder wifi
values still appear. Empty list means no placeholders were found.
"""
placeholders: list[str] = []
wifi_conf = config.get(CONF_WIFI)
if not wifi_conf:
return placeholders
for idx, network in enumerate(wifi_conf.get(CONF_NETWORKS, [])):
ssid = network.get(CONF_SSID)
if isinstance(ssid, str) and ssid == PLACEHOLDER_WIFI_SSID:
placeholders.append(f"wifi.networks[{idx}].ssid")
ap_conf = wifi_conf.get(CONF_AP)
if ap_conf:
ap_ssid = ap_conf.get(CONF_SSID)
if isinstance(ap_ssid, str) and ap_ssid == PLACEHOLDER_WIFI_SSID:
placeholders.append("wifi.ap.ssid")
return placeholders
def check_placeholder_credentials(config: ConfigType) -> None:
"""Raise EsphomeError if any wifi credential is the dashboard placeholder.
Call only at compile time. NEVER from CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA,
or any path reached by `esphome config`; device-builder relies on
validation passing with the placeholders still in place.
"""
locations = _placeholder_wifi_credentials(config)
if not locations:
return
formatted = ", ".join(locations)
raise EsphomeError(
f"wifi configuration still contains the dashboard placeholder value "
f"'{PLACEHOLDER_WIFI_SSID}' at: {formatted}. "
f"Open secrets.yaml and replace 'wifi_ssid' (and 'wifi_password') "
f"with your real wifi credentials before flashing."
)
+4 -17
View File
@@ -632,11 +632,11 @@ void WiFiComponent::setup() {
#endif
if (this->enable_on_boot_) {
#ifdef USE_ESP32
this->wifi_lazy_init_();
#endif
this->start();
} else {
#ifdef USE_ESP32
esp_netif_init();
#endif
this->state_ = WIFI_COMPONENT_STATE_DISABLED;
}
}
@@ -1278,11 +1278,6 @@ void WiFiComponent::enable() {
ESP_LOGD(TAG, "Enabling");
this->state_ = WIFI_COMPONENT_STATE_OFF;
#ifdef USE_ESP32
// Idempotent — only allocates DMA buffers + netifs on the first call. After this,
// start() can safely run.
this->wifi_lazy_init_();
#endif
this->start();
}
@@ -2198,15 +2193,7 @@ bool WiFiComponent::request_high_performance() {
}
// Give the semaphore (non-blocking). This increments the count.
bool success = xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
// Wake the main loop so the switch to high-performance mode is applied on the
// next tick instead of waiting up to loop_interval.
if (success) {
App.wake_loop_threadsafe();
}
return success;
return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
}
bool WiFiComponent::release_high_performance() {
-12
View File
@@ -694,12 +694,6 @@ class WiFiComponent final : public Component {
bool wifi_apply_hostname_();
bool wifi_sta_connect_(const WiFiAP &ap);
void wifi_pre_setup_();
#ifdef USE_ESP32
// ESP-IDF only: defers esp_wifi_init() + netif creation (which allocate ~15-30KB of
// DMA-capable internal SRAM) until wifi actually needs to come up. Idempotent.
// Called from setup() only when enable_on_boot_=true, and from enable() on first use.
void wifi_lazy_init_();
#endif
WiFiSTAConnectStatus wifi_sta_connect_status_() const;
bool is_connected_() const {
return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED &&
@@ -895,12 +889,6 @@ class WiFiComponent final : public Component {
bool rrm_{false};
#endif
bool enable_on_boot_{true};
#ifdef USE_ESP32
// Tracks whether esp_wifi_init() + netif creation has happened. Allows enable()
// to be called at runtime without re-allocating, and ensures the heavy init is
// skipped entirely when enable_on_boot_ is false until first enable().
bool wifi_initialized_{false};
#endif
bool got_ipv4_address_{false};
bool keep_scan_results_{false};
bool has_completed_scan_after_captive_portal_start_{
@@ -145,15 +145,23 @@ void WiFiComponent::wifi_pre_setup_() {
get_mac_address_raw(mac);
set_mac_address(mac);
}
// Network interface setup handled by network component
esp_err_t err = esp_netif_init();
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_netif_init failed: %s", esp_err_to_name(err));
return;
}
s_wifi_event_group = xEventGroupCreate();
if (s_wifi_event_group == nullptr) {
ESP_LOGE(TAG, "xEventGroupCreate failed");
return;
}
err = esp_event_loop_create_default();
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_event_loop_create_default failed: %s", esp_err_to_name(err));
return;
}
esp_event_handler_instance_t instance_wifi_id, instance_ip_id;
esp_err_t err =
esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id);
err = esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id);
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err));
return;
@@ -163,16 +171,6 @@ void WiFiComponent::wifi_pre_setup_() {
ESP_LOGE(TAG, "esp_event_handler_instance_register failed: %s", esp_err_to_name(err));
return;
}
// NOTE: netif creation + esp_wifi_init() used to live here. They allocate ~15-30KB of
// DMA-capable internal SRAM, which competes with W5500 SPI DMA and I2S DMA on
// memory-tight devices. They are now deferred to wifi_lazy_init_(), called from
// setup() when enable_on_boot_ is true, or from enable() on first runtime enable.
// This makes enable_on_boot:false genuinely skip the wifi DMA allocation.
}
void WiFiComponent::wifi_lazy_init_() {
if (this->wifi_initialized_)
return;
s_sta_netif = esp_netif_create_default_wifi_sta();
@@ -185,7 +183,7 @@ void WiFiComponent::wifi_lazy_init_() {
ESP_LOGW(TAG, "starting wifi without nvs");
cfg.nvs_enable = false;
}
esp_err_t err = esp_wifi_init(&cfg);
err = esp_wifi_init(&cfg);
if (err != ERR_OK) {
ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err));
return;
@@ -195,7 +193,6 @@ void WiFiComponent::wifi_lazy_init_() {
ESP_LOGE(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err));
return;
}
this->wifi_initialized_ = true;
}
bool WiFiComponent::wifi_mode_(optional<bool> sta, optional<bool> ap) {
-2
View File
@@ -50,8 +50,6 @@ _LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@luar123", "@tomaszduda23"]
CONFLICTS_WITH = ["openthread"]
BASE_SCHEMA = cv.Schema(
{
cv.Optional(CONF_REPORT): cv.All(
+7 -3
View File
@@ -117,11 +117,15 @@ def final_validate_esp32(config: ConfigType) -> ConfigType:
if not CORE.is_esp32:
return config
if CONF_WIFI in fv.full_config.get():
if CONF_AP in fv.full_config.get()[CONF_WIFI]:
if config[CONF_ROUTER] and CONF_AP in fv.full_config.get()[CONF_WIFI]:
raise cv.Invalid(
"A Wifi Access Point can not be used together with Zigbee."
"Only Zigbee End Device can be used together with a Wifi Access Point."
)
if config[CONF_ROUTER]:
if CONF_AP in fv.full_config.get()[CONF_WIFI]:
_LOGGER.warning(
"Wifi Access Point might be unstable while Zigbee is active, use only as fallback."
)
elif config[CONF_ROUTER]:
_LOGGER.warning(
"The Zigbee Router might miss packets while Wifi is active and could destabilize "
"your network. Use only if Wifi is off most of the time."
-10
View File
@@ -1367,7 +1367,6 @@ DEVICE_CLASS_TEMPERATURE = "temperature"
DEVICE_CLASS_TEMPERATURE_DELTA = "temperature_delta"
DEVICE_CLASS_TIMESTAMP = "timestamp"
DEVICE_CLASS_UPDATE = "update"
DEVICE_CLASS_UPTIME = "uptime"
DEVICE_CLASS_VIBRATION = "vibration"
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds"
DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts"
@@ -1416,12 +1415,3 @@ ENTITY_CATEGORY_DIAGNOSTIC = "diagnostic"
# The corresponding constant exists in c++
# when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds
SCHEDULER_DONT_RUN = 4294967295
# Sentinel values written by the esphome-device-builder dashboard into
# secrets.yaml on first boot so that !secret wifi_ssid / !secret wifi_password
# references resolve cleanly through validation before the user has finished
# the onboarding wizard. Compilation refuses if these reach the binary so that
# a user who dismisses onboarding can't accidentally flash a device that will
# never associate with their wifi.
PLACEHOLDER_WIFI_SSID = "REPLACE_WITH_YOUR_WIFI_NETWORK"
PLACEHOLDER_WIFI_PASSWORD = "REPLACE_WITH_YOUR_WIFI_PASSWORD" # noqa: S105
+1 -8
View File
@@ -5,7 +5,7 @@ import math
import os
from pathlib import Path
import re
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING
from esphome.const import (
CONF_COMMENT,
@@ -569,12 +569,6 @@ class EsphomeCore:
self.build_path: Path | None = None
# The validated configuration, this is None until the config has been validated
self.config: ConfigType | None = None
# YAML frontmatter loaded from user YAML files. Frontmatter is a leading
# YAML document separated by `---` from the actual configuration. It is
# ignored by config validation and code generation, but kept here so it
# can be inspected by callers (tooling, future features). Keyed by the
# resolved Path of the source file.
self.frontmatter: dict[Path, Any] = {}
# The pending tasks in the task queue (mostly for C++ generation)
# This is a priority queue (with heapq)
# Each item is a tuple of form: (-priority, unique number, task)
@@ -640,7 +634,6 @@ class EsphomeCore:
self.config_path = None
self.build_path = None
self.config = None
self.frontmatter = {}
self.event_loop = _FakeEventLoop()
self.task_counter = 0
self.variables = {}
+1 -5
View File
@@ -1,7 +1,6 @@
#pragma once
#include <cstdint>
#include <cstring>
#include <string>
#include <cstdint>
#include "gpio.h"
#include "esphome/core/defines.h"
#include "esphome/core/time_64.h"
@@ -43,9 +42,6 @@ void __attribute__((noreturn)) arch_restart();
inline uint8_t progmem_read_byte(const uint8_t *addr) { return *addr; }
inline const char *progmem_read_ptr(const char *const *addr) { return *addr; }
inline uint16_t progmem_read_uint16(const uint16_t *addr) { return *addr; }
// Bulk copy out of PROGMEM. PROGMEM is a no-op everywhere except ESP8266, so a
// plain `std::memcpy` is correct and the fast path here.
inline void progmem_memcpy(void *dst, const void *src, size_t len) { std::memcpy(dst, src, len); }
#endif
} // namespace esphome
+61 -38
View File
@@ -93,7 +93,7 @@ class URLSource(Source):
class GitSource(Source):
def __init__(self, url: str, ref: str | None):
def __init__(self, url: str, ref: str):
self.url = url
self.ref = ref
@@ -109,7 +109,7 @@ class GitSource(Source):
return path
def __str__(self):
return f"{self.url}#{self.ref}" if self.ref else self.url
return f"{self.url}#{self.ref}"
class InvalidIDFComponent(Exception):
@@ -154,6 +154,41 @@ class IDFComponent:
self.path = self.source.download(self.get_sanitized_name(), force=force)
def _sanitize_version(version: str) -> str:
"""
Sanitize a version string by removing common requirement prefixes or a leading v.
Args:
version: Version string to clean.
Returns:
Cleaned version string without common requirement symbols.
"""
version = version.strip()
prefixes = (
"^",
"~=",
"~",
">=",
"<=",
"==",
"!=",
">",
"<",
"=",
"v",
"V",
)
for p in prefixes:
if version.startswith(p):
version = version[len(p) :]
break
return version.strip()
def _get_package_from_pio_registry(
username: str | None, pkgname: str, requirements: str
) -> tuple[str, str, str | None, str | None]:
@@ -352,6 +387,7 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
IDFComponent: The resolved component with name, version, and URL
Raises:
ValueError: If a repository URL is missing a reference (#)
RuntimeError: If no artifact can be found for the library
"""
name = None
@@ -360,25 +396,20 @@ def _convert_library_to_component(library: Library) -> IDFComponent:
# Repository is provided directly
if library.repository:
# Parse repository URL: path becomes the component name, fragment
# (if any) becomes the git ref stored on GitSource. A missing
# fragment is fine -- clone_or_update leaves the depth-1 clone on
# the remote's default branch, matching PIO's lib_deps behavior
# and external_components handling.
# Parse repository URL to extract name and version
split_result = urlsplit(library.repository)
if not split_result.fragment.strip():
raise ValueError(f"Missing ref in URL {library.repository}")
# Sanitize name
name = str(split_result.path).strip("/")
name = name.removesuffix(".git")
# IDF Component Manager only accepts "*", a 40-char commit hash, or
# semver here. The actual git ref is preserved in GitSource.ref;
# override_path makes this field cosmetic at build time.
version = "*"
# Sanitize version
version = _sanitize_version(split_result.fragment)
repository = urlunsplit(split_result._replace(fragment=""))
ref = split_result.fragment.strip() or None
source = GitSource(str(repository), ref)
source = GitSource(str(repository), split_result.fragment)
# Version is provided - resolve using PlatformIO registry
elif library.version:
@@ -588,6 +619,9 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
if description:
data["description"] = description
# Do not use the version from library.json/library.properties; it may be incorrect.
data["version"] = component.version
repository = component.data.get("repository", {}).get("url", None)
if repository:
data["repository"] = repository
@@ -597,11 +631,20 @@ def generate_idf_component_yml(component: IDFComponent) -> str:
if "dependencies" not in data:
data["dependencies"] = {}
# Every dependency goes through _generate_idf_component →
# component.download() before this runs, so .path is always set.
data["dependencies"][dependency.get_sanitized_name()] = {
"override_path": str(dependency.path),
}
# Add this dependency to dependencies
dep = {}
dep["version"] = dependency.version
# Should use dependency.path as override path
try:
dep["override_path"] = str(dependency.path)
except RuntimeError as e:
# No local path: only a GitSource can substitute its URL.
if not isinstance(dependency.source, GitSource):
raise e
dep["git"] = dependency.source.url
data["dependencies"][dependency.get_sanitized_name()] = dep
return yaml_util.dump(data)
@@ -656,26 +699,6 @@ def _process_dependencies(component: IDFComponent):
if not dependencies:
return
# PIO's library.json accepts both the list-of-dicts form and the
# shorthand dict form ``{"owner/Name": "version_spec"}``. Normalize
# the dict form so the loop below sees a uniform list. Iterating a
# dict gives string keys, which would silently fail the
# ``"name" in dependency`` substring check and skip every entry.
if isinstance(dependencies, dict):
normalized = []
for raw_name, spec in dependencies.items():
if "/" in raw_name:
owner, pkgname = raw_name.split("/", 1)
else:
owner, pkgname = None, raw_name
entry = {"name": pkgname, "owner": owner}
if isinstance(spec, dict):
entry.update(spec)
else:
entry["version"] = spec
normalized.append(entry)
dependencies = normalized
_LOGGER.info("Processing %s@%s component dependencies...", name, version)
for dependency in dependencies:
# Validate dependency structure
+16 -140
View File
@@ -7,7 +7,6 @@ import json
import logging
import os
from pathlib import Path
import platform
import shutil
import subprocess
import sys
@@ -18,7 +17,7 @@ import requests
from esphome.config_validation import Version
from esphome.core import CORE
from esphome.helpers import ProgressBar, get_str_env, rmtree, write_file_if_changed
from esphome.helpers import ProgressBar, get_str_env, rmtree
PathType = str | os.PathLike
@@ -27,18 +26,16 @@ _LOGGER = logging.getLogger(__name__)
_SCRIPTS_DIR = Path(__file__).parent
def _str_to_lst_of_str(a: str | list[str]) -> list[str]:
def _str_to_lst_of_str(a: str) -> list[str]:
"""
Convert a string to a list of string
Args:
a: A string containing semicolon-separated values, or an already-split list
a: A string containing semicolon-separated values
Returns:
list of strings
"""
if isinstance(a, list):
return a
return list(f.strip() for f in a.split(";") if f.strip())
@@ -70,11 +67,10 @@ ESPHOME_IDF_DEFAULT_FEATURES = _str_to_lst_of_str(
)
ESPHOME_IDF_FRAMEWORK_MIRRORS = _str_to_lst_of_str(
os.environ.get("ESPHOME_IDF_FRAMEWORK_MIRRORS")
or [
"https://github.com/esphome-libs/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.tar.xz",
"https://github.com/esphome-libs/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.tar.xz",
]
os.environ.get(
"ESPHOME_IDF_FRAMEWORK_MIRRORS",
"https://github.com/espressif/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.zip;https://github.com/espressif/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.zip",
)
)
ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str(
@@ -550,11 +546,11 @@ def _tar_extract_all(
if not (mode & stat.S_IXUSR):
mode &= ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
mode |= stat.S_IRUSR | stat.S_IWUSR
elif not (member.isdir() or member.issym()):
# Block special files. Directories and symlinks keep
# their masked-original mode — passing None here would
# crash tarfile.extract on Python <3.12 (its chmod
# path calls os.chmod unconditionally).
elif member.isdir() or member.issym():
# Ignore mode for directories & symlinks
mode = None
else:
# Block special files
continue
member.mode = mode
@@ -784,109 +780,12 @@ def download_from_mirrors(
return None
def _write_idf_version_txt(framework_path: Path, version: str) -> None:
"""Write <framework_path>/version.txt if missing.
IDF's build.cmake picks the version it embeds in the firmware (and
stamps onto the bootloader) in this order: ``${IDF_PATH}/version.txt``
if present, else ``git describe`` against IDF_PATH, else the
``IDF_VERSION_MAJOR/MINOR/PATCH`` triplet from ``tools/cmake/version.cmake``.
On a clean esphome-libs tarball ``.git`` is fully stripped, so
git_describe returns ``HEAD-HASH-NOTFOUND`` (falsy) and the triplet
wins -- correct by luck. But a *partial* ``.git`` (e.g. a custom
framework.source pointed at a real git URL where build artifacts
mark the tree dirty) makes git_describe return ``<hash>-dirty``,
which is what then gets baked into the bootloader. Dropping
version.txt forces the right answer regardless.
"""
version_txt = framework_path / "version.txt"
if version_txt.exists():
return
try:
version_txt.write_text(f"v{version}\n", encoding="utf-8")
except OSError as e:
_LOGGER.warning(
"Could not write %s (%s); bootloader version string may be incorrect.",
version_txt,
e,
)
# Backport of espressif/esp-idf#18272: every ESPHome-supported IDF release
# through v6.0 ships a tools.json whose ninja 1.12.1 entry has no
# ``linux-arm64`` source. ``idf_tools.py`` then either fails to find a
# matching binary or grabs the x86_64 one, which can't execute on
# aarch64. cmake is already populated across the same release range; we
# only need to inject ninja. Values lifted verbatim from the IDF v6.0.1
# tools.json where the fix landed natively.
_NINJA_ARM64_BACKPORT: dict[str, dict[str, str | int]] = {
"1.12.1": {
"rename_dist": "ninja-linux-arm64-v1.12.1.zip",
"sha256": "5c25c6570b0155e95fce5918cb95f1ad9870df5768653afe128db822301a05a1",
"size": 121787,
"url": "https://github.com/ninja-build/ninja/releases/download/v1.12.1/ninja-linux-aarch64.zip",
},
}
def _patch_tools_json_for_linux_arm64(framework_path: Path) -> None:
"""Inject ninja linux-arm64 entries into the framework's tools.json on aarch64.
Idempotent: a tools.json that already has the entry, or a host that
isn't aarch64, is a no-op. Applied unconditionally on every install
check so a build dir extracted before the backport got fixed up
without forcing a clean.
"""
if platform.machine() != "aarch64":
return
tools_json = framework_path / "tools" / "tools.json"
if not tools_json.is_file():
return
try:
with open(tools_json, encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, OSError) as e:
_LOGGER.warning(
"Could not parse %s for linux-arm64 backport (%s); "
"skipping. A clean reinstall of the framework directory "
"may be needed.",
tools_json,
e,
)
return
changed = False
for tool in data.get("tools", []):
if tool.get("name") != "ninja":
continue
for ver in tool.get("versions", []):
entry = _NINJA_ARM64_BACKPORT.get(ver.get("name"))
if entry is None or ver.get("linux-arm64"):
continue
ver["linux-arm64"] = entry
changed = True
if changed:
# write_file_if_changed stages a tempfile in the destination dir
# and atomically replaces — safe against mid-write interruption
# and concurrent invocations.
write_file_if_changed(tools_json, json.dumps(data, indent=2) + "\n")
_LOGGER.info(
"Patched %s to add ninja linux-arm64 download "
"(espressif/esp-idf#18272 backport).",
tools_json,
)
def _check_esphome_idf_framework_install(
version: str,
targets: list[str],
tools: list[str],
force: bool = False,
env: dict[str, str] | None = None,
source_url: str | None = None,
) -> tuple[Path, bool]:
"""
Check and install ESP-IDF framework.
@@ -897,11 +796,6 @@ def _check_esphome_idf_framework_install(
tools: list of tools to install
force: If True, force reinstallation
env: Optional dictionary of environment variables to set
source_url: Optional override URL for the framework tarball. Supports
the same ``{VERSION}`` / ``{MAJOR}`` / ``{MINOR}`` / ``{PATCH}`` /
``{EXTRA}`` substitutions as ESPHOME_IDF_FRAMEWORK_MIRRORS. When
set, it replaces the default mirror list no implicit fallback,
so a misspelled URL fails loudly.
Returns:
tuple of (framework_path, install_flag)
@@ -923,10 +817,6 @@ def _check_esphome_idf_framework_install(
env_stamp_file = framework_path / ESPHOME_STAMP_FILE
idf_tools_path = framework_path / "tools" / "idf_tools.py"
_LOGGER.info("Checking ESP-IDF %s framework ...", version)
# Logged every invocation (not just on install) so the user can verify the
# override. A changed URL needs ``esphome clean`` to force a re-download.
if source_url:
_LOGGER.info("Using framework source override: %s", source_url)
# 2. Download and extract the framework if not already extracted.
# The marker is written last after extraction succeeds, so its presence
@@ -954,23 +844,14 @@ def _check_esphome_idf_framework_install(
except ValueError:
pass
mirrors = [source_url] if source_url else ESPHOME_IDF_FRAMEWORK_MIRRORS
download_from_mirrors(mirrors, substitutions, tmp.file)
download_from_mirrors(
ESPHOME_IDF_FRAMEWORK_MIRRORS, substitutions, tmp.file
)
_LOGGER.info("Extracting ESP-IDF %s framework ...", version)
archive_extract_all(tmp.file, framework_path, progress_header="Extracting")
extracted_marker.touch()
# Idempotent post-extract patch: written every invocation so a build
# dir extracted before this fix gets the file too, without forcing a
# clean. Skips when version.txt already exists.
_write_idf_version_txt(framework_path, version)
# Apply the ninja linux-arm64 backport on every invocation, not just on
# fresh extracts — idempotent and cheap, and lets a build dir carrying
# a pre-patch tools.json get fixed up without forcing a clean.
_patch_tools_json_for_linux_arm64(framework_path)
# 3. Check if the framework tools are the same and correctly installed
if not install:
install = True
@@ -1127,7 +1008,6 @@ def check_esp_idf_install(
tools: list[str] | None = None,
features: list[str] | None = None,
force: bool = False,
source_url: str | None = None,
) -> tuple[Path, Path]:
"""
Check and install ESP-IDF framework and Python environment.
@@ -1138,10 +1018,6 @@ def check_esp_idf_install(
tools: list of tools to install
features: Features to install
force: If True, force reinstallation
source_url: Optional override URL for the framework tarball. When
set, it replaces the default mirror list (no fallback). Forwarded
to ``_check_esphome_idf_framework_install``; supports the same URL
substitutions.
Returns:
tuple of (framework_path, python_env_path)
@@ -1164,7 +1040,7 @@ def check_esp_idf_install(
# 1) Framework
framework_path, installed = _check_esphome_idf_framework_install(
version, targets, tools, force=force, env=env, source_url=source_url
version, targets, tools, force=force, env=env
)
features = features or ESPHOME_IDF_DEFAULT_FEATURES
-6
View File
@@ -66,12 +66,6 @@ FILTER_IDF_LINES: list[str] = [
# Drop the blank line rich emits after the note so the build log
# doesn't end with an orphan gap before ESPHome's own status lines.
r"\s*$",
# ESP-IDF shells out to ``git rev-parse`` to embed a commit hash;
# esphome-libs strips ``.git`` from the tarball so those probes fail
# noisily without affecting the build.
r"-- git rev-parse returned ",
r"fatal: not a git repository",
r"Stopping at filesystem boundary",
]
+3 -4
View File
@@ -94,10 +94,9 @@ def print_summary(size_json: Path, partitions_csv: Path | None) -> None:
_LOGGER.debug("Skipping size summary: %s", e)
return
memory_types = data.get("memory_types", {})
ram_region = memory_types.get("DRAM") or memory_types.get("DIRAM") or {}
ram_used = ram_region.get("used")
ram_total = ram_region.get("size")
dram = data.get("memory_types", {}).get("DRAM") or {}
ram_used = dram.get("used")
ram_total = dram.get("size")
if ram_total and ram_used is not None:
print(f"RAM: {_format_bar(ram_used, ram_total)}")
+1 -16
View File
@@ -10,7 +10,6 @@ import shutil
import subprocess
from esphome.components.esp32.const import KEY_ESP32, KEY_FLASH_SIZE, KEY_IDF_VERSION
from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.core import CORE, EsphomeError
from esphome.espidf.framework import check_esp_idf_install, get_framework_env
from esphome.espidf.size_summary import print_summary
@@ -38,27 +37,13 @@ def _get_core_framework_version():
return str(CORE.data[KEY_ESP32][KEY_IDF_VERSION])
def _get_framework_source_override() -> str | None:
"""Return the user-supplied esp32.framework.source override, if any.
The override lets a user point the IDF tarball download at a custom URL
(mirror, fork, local server). Substitutions like ``{VERSION}`` /
``{MAJOR}`` etc. work the same as in the default mirror list.
"""
if CORE.config is None:
return None
return CORE.config.get(KEY_ESP32, {}).get(CONF_FRAMEWORK, {}).get(CONF_SOURCE)
def _get_esphome_esp_idf_paths(
version: str | None = None,
) -> tuple[os.PathLike, os.PathLike]:
version = version or _get_core_framework_version()
paths = _cache().paths
if version not in paths:
paths[version] = check_esp_idf_install(
version, source_url=_get_framework_source_override()
)
paths[version] = check_esp_idf_install(version)
return paths[version]
+2 -2
View File
@@ -2,7 +2,7 @@ dependencies:
bblanchon/arduinojson:
version: "7.4.2"
esphome/esp-audio-libs:
version: 3.1.0
version: 3.0.0
esphome/esp-micro-speech-features:
version: 1.2.3
esphome/micro-decoder:
@@ -100,6 +100,6 @@ dependencies:
esp32async/asynctcp:
version: 3.4.91
sendspin/sendspin-cpp:
version: 0.6.1
version: 0.5.0
lvgl/lvgl:
version: 9.5.0
+1 -32
View File
@@ -14,7 +14,6 @@ from esphome.const import (
KEY_CORE,
KEY_TARGET_FRAMEWORK,
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE
from esphome.helpers import write_file_if_changed
@@ -99,7 +98,6 @@ class StorageJSON:
no_mdns: bool,
framework: str | None = None,
core_platform: str | None = None,
toolchain: str | None = None,
) -> None:
# Version of the storage JSON schema
assert storage_version is None or isinstance(storage_version, int)
@@ -136,8 +134,6 @@ class StorageJSON:
self.framework = framework
# The core platform of this firmware. Like "esp32", "rp2040", "host" etc.
self.core_platform = core_platform
# The toolchain used for the build ("platformio" / "esp-idf")
self.toolchain = toolchain
def as_dict(self):
return {
@@ -157,7 +153,6 @@ class StorageJSON:
"no_mdns": self.no_mdns,
"framework": self.framework,
"core_platform": self.core_platform,
"toolchain": self.toolchain,
}
def to_json(self):
@@ -194,7 +189,6 @@ class StorageJSON:
),
framework=esph.target_framework,
core_platform=esph.target_platform,
toolchain=esph.toolchain.value if esph.toolchain is not None else None,
)
@staticmethod
@@ -242,7 +236,6 @@ class StorageJSON:
no_mdns = storage.get("no_mdns", False)
framework = storage.get("framework")
core_platform = storage.get("core_platform")
toolchain = storage.get("toolchain")
return StorageJSON(
storage_version,
name,
@@ -260,7 +253,6 @@ class StorageJSON:
no_mdns,
framework,
core_platform,
toolchain,
)
@staticmethod
@@ -281,33 +273,10 @@ class StorageJSON:
"""
CORE.name = self.name
CORE.build_path = self.build_path
# Restore toolchain so upload/logs picks the right firmware_bin path.
# An unknown value (corrupt sidecar, or written by a newer ESPHome)
# just leaves CORE.toolchain None — the fallback then picks PlatformIO.
if self.toolchain and CORE.toolchain is None:
try:
CORE.toolchain = Toolchain(self.toolchain)
except ValueError:
_LOGGER.debug(
"Ignoring unknown toolchain %r from %s",
self.toolchain,
storage_path(),
)
target_platform = self.core_platform or self.target_platform.lower()
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: target_platform,
KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(),
KEY_TARGET_FRAMEWORK: self.framework,
}
# The compile pipeline populates CORE.data[KEY_ESP32] when esp32's
# validator runs; on the cache fast path that validator is skipped,
# so populate the variant upload_using_esptool reads via
# esp32.get_esp32_variant(). target_platform on disk is the variant
# (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32").
if target_platform == const.PLATFORM_ESP32:
from esphome.components.esp32.const import KEY_ESP32
from esphome.const import KEY_VARIANT
CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform}
def __eq__(self, o) -> bool:
return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict()
-15
View File
@@ -87,21 +87,6 @@ def replace_file_content(text, pattern, repl):
def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
"""Return True when the build tree must be wiped before reuse.
Predicate is True when *old* is missing (first build),
``src_version`` differs, ``build_path`` differs, or a previously
loaded integration was removed in *new*. Adding integrations or
changing unrelated fields (friendly name, esphome version, etc.)
does not trigger a clean.
Used by esphome-device-builder (esphome/device-builder) to gate
its remote-build artifact materialiser so a local remote local
cycle preserves PlatformIO's local object cache instead of wiping
it on every cycle. The signature, semantics, and ``None`` handling
for *old* are part of the public contract; keep them stable so the
offloader's wipe decision tracks core's.
"""
if old is None:
return True
+2 -152
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
from collections.abc import Callable, Generator
from contextlib import contextmanager, suppress
from dataclasses import dataclass, field
import functools
import inspect
from io import BytesIO, TextIOBase, TextIOWrapper
@@ -234,130 +233,6 @@ class IncludeFile:
return has_substitution_or_expression(str(self.file))
def force_load_include_files(
obj: Any,
*,
warn_on_unresolved: bool = True,
_seen: set[int] | None = None,
) -> None:
"""Recursively resolve any deferred ``IncludeFile`` instances in a YAML tree.
Nested ``!include`` returns a deferred ``IncludeFile`` that is only resolved
later (substitution / packages pass). Callers that need every referenced
file to actually load bundle discovery, on-device YAML recovery invoke
this while a :func:`track_yaml_loads` listener is active so the underlying
loader fires and records every reachable file.
``IncludeFile`` instances whose path contains unresolved substitution
variables cannot be loaded. By default a warning is logged for each one;
pass ``warn_on_unresolved=False`` (used by discovery paths that run on a
fresh re-parse where substitutions haven't been applied yet) to demote it
to a debug log.
"""
if _seen is None:
_seen = set()
if isinstance(obj, IncludeFile):
if id(obj) in _seen:
return
_seen.add(id(obj))
if obj.has_unresolved_expressions():
log = _LOGGER.warning if warn_on_unresolved else _LOGGER.debug
log(
"Cannot resolve !include %s (referenced from %s) with substitutions in path",
obj.file,
obj.parent_file,
)
return
try:
loaded = obj.load()
except EsphomeError as err:
_LOGGER.warning(
"Failed to load !include %s (referenced from %s): %s",
obj.file,
obj.parent_file,
err,
)
return
force_load_include_files(
loaded, warn_on_unresolved=warn_on_unresolved, _seen=_seen
)
elif isinstance(obj, dict):
if id(obj) in _seen:
return
_seen.add(id(obj))
for value in obj.values():
force_load_include_files(
value, warn_on_unresolved=warn_on_unresolved, _seen=_seen
)
elif isinstance(obj, (list, tuple)):
if id(obj) in _seen:
return
_seen.add(id(obj))
for item in obj:
force_load_include_files(
item, warn_on_unresolved=warn_on_unresolved, _seen=_seen
)
@dataclass(slots=True)
class DiscoveredYamlFiles:
"""Result of :func:`discover_user_yaml_files`.
``files`` contains every resolved path the YAML loader touched while we
were re-parsing the user's config; ``secrets`` is the subset whose
*un-resolved* filename matched :data:`esphome.const.SECRETS_FILES` (so
a ``secrets.yaml`` symlinked to a differently-named target is still
flagged as secrets).
"""
files: list[Path] = field(default_factory=list)
secrets: set[Path] = field(default_factory=set)
def discover_user_yaml_files(config_path: Path) -> DiscoveredYamlFiles:
"""Fresh-re-parse ``config_path`` and report every file the YAML loader
pulled in, plus which of them came in under a secrets filename.
Does NOT run schema validation, substitutions, or package resolution so
component-internal YAML loaded by validators (LVGL helpers, dashboard
imports, etc.) is *not* captured. Deferred ``!include`` references whose
paths don't depend on substitutions are force-loaded here so they're
captured too.
Must run on a fresh parse because :meth:`IncludeFile.load` caches its
result; on an already-resolved tree :meth:`load` returns without invoking
the loader and the listener would not fire for the referenced files.
"""
from esphome.const import SECRETS_FILES
secrets: set[Path] = set()
def _capture_secret(fname: Path) -> None:
if Path(fname).name in SECRETS_FILES:
secrets.add(Path(fname).resolve())
with track_yaml_loads() as loaded:
_load_listeners.append(_capture_secret)
try:
try:
data = load_yaml(config_path)
except EsphomeError:
return DiscoveredYamlFiles(list(loaded), secrets)
force_load_include_files(data, warn_on_unresolved=False)
finally:
_load_listeners.remove(_capture_secret)
# Deduplicate while preserving first-seen order.
seen: set[Path] = set()
unique: list[Path] = []
for path in loaded:
if path not in seen:
seen.add(path)
unique.append(path)
return DiscoveredYamlFiles(unique, secrets)
def _add_data_ref(fn):
@functools.wraps(fn)
def wrapped(loader, node):
@@ -768,35 +643,10 @@ def _load_yaml_internal_with_type(
content: TextIOWrapper,
yaml_loader: Callable[[Path], dict[str, Any]],
) -> Any:
"""Load a YAML file.
Supports an optional leading YAML frontmatter document: when the file
contains two YAML documents separated by ``---``, the first document is
treated as metadata and stored in :attr:`CORE.frontmatter` keyed by the
resolved file path, while the second document is returned as the actual
configuration. Frontmatter is ignored by config validation and code
generation.
"""
"""Load a YAML file."""
loader = loader_type(content, fname, yaml_loader)
try:
documents: list[Any] = []
while loader.check_data():
documents.append(loader.get_data())
if len(documents) > 2:
raise EsphomeError(
f"YAML file '{fname}' contains {len(documents)} documents but "
f"at most two are supported (an optional frontmatter document "
f"followed by the configuration)."
)
if len(documents) == 2:
frontmatter = documents[0]
config = documents[1]
if frontmatter is not None:
CORE.frontmatter[Path(fname).resolve()] = frontmatter
return config if config is not None else OrderedDict()
if len(documents) == 1:
return documents[0] or OrderedDict()
return OrderedDict()
return loader.get_single_data() or OrderedDict()
except yaml.YAMLError as exc:
raise EsphomeError(exc) from exc
finally:
+4 -4
View File
@@ -12,19 +12,19 @@ platformio==6.1.19
esptool==5.2.0
click==8.3.3
esphome-dashboard==20260425.0
aioesphomeapi==45.0.4
zeroconf==0.149.16
aioesphomeapi==45.0.0
zeroconf==0.148.0
puremagic==1.30
ruamel.yaml==0.19.1 # dashboard_import
ruamel.yaml.clib==0.2.15 # dashboard_import
esphome-glyphsets==0.2.0
pillow==12.2.0
resvg-py==0.3.2
resvg-py==0.3.1
freetype-py==2.5.1
jinja2==3.1.6
bleak==2.1.1
smpclient==6.0.0
requests==2.34.2
requests==2.34.1
# esp-idf >= 5.0 requires this
pyparsing >= 3.3.2
+2 -2
View File
@@ -1,6 +1,6 @@
pylint==4.0.5
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.15.14 # also change in .pre-commit-config.yaml when updating
ruff==0.15.13 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit
@@ -16,7 +16,7 @@ hypothesis==6.92.1
# CodSpeed benchmarks under tests/benchmarks/python/
# (skipped via pytest.importorskip when missing -- only required for the
# benchmarks job in .github/workflows/ci.yml)
pytest-codspeed==5.0.3
pytest-codspeed==5.0.1
# Used by the import-time regression check (.github/workflows/ci.yml → import-time job)
importtime-waterfall==1.0.0
-80
View File
@@ -5,7 +5,6 @@ This script is a centralized way to determine which CI jobs need to run based on
what files have changed. It outputs JSON with the following structure:
{
"core_ci": true/false,
"integration_tests": true/false,
"integration_test_buckets": [{"name": "1/3", "tests": ["tests/integration/test_foo.py", ...]}, ...],
"clang_tidy": true/false,
@@ -23,11 +22,6 @@ what files have changed. It outputs JSON with the following structure:
}
The CI workflow uses this information to:
- Gate the unconditional jobs (ci-custom, pytest, pre-commit-ci-lite) via core_ci;
false when a pull_request only touches CI-irrelevant meta paths (other workflow
files, .github/actions/build-image/*, .yamllint, .github/dependabot.yml, docker/**)
so workflow-only PRs satisfy the required CI Status check without running the
unconditional jobs. Always true on non-pull_request events and under --force-all.
- Skip or run integration tests
- Skip or run clang-tidy (and whether to do a full scan)
- Skip or run clang-format
@@ -718,69 +712,6 @@ def should_run_benchmarks(branch: str | None = None) -> bool:
return any(get_component_from_path(f) in benchmarked_components for f in files)
# Files / path patterns whose changes alone don't warrant running the
# unconditional CI jobs (`ci-custom`, `pytest`, `pre-commit-ci-lite`).
# Single source of truth for what we treat as "CI-irrelevant" on
# pull_request events; ci.yml used to encode this in its own
# `pull_request.paths` filter, but that hid the required `CI Status`
# check on PRs that only touched these files (dependabot Action bumps,
# dependabot.yml edits, docker/ changes, etc.) and forced admin
# force-merges.
#
# ci.yml itself is deliberately *not* ignored — editing the CI workflow
# must still run CI. Workflows that have their own dedicated triggers
# (codeql.yml, ci-docker.yml, ...) are matched via the
# `.github/workflows/*.yml` prefix below and exclude ci.yml explicitly.
CI_IRRELEVANT_EXACT_FILES = frozenset(
{
".yamllint",
".github/dependabot.yml",
}
)
def _is_ci_irrelevant_path(path: str) -> bool:
"""Whether a single changed path is irrelevant to the unconditional CI jobs."""
if path in CI_IRRELEVANT_EXACT_FILES:
return True
# docker/** — all descendants
if path.startswith("docker/"):
return True
# .github/workflows/*.yml — top-level workflow files other than ci.yml
# (ci.yml itself must still trigger full CI when edited).
if path.startswith(".github/workflows/") and path.endswith(".yml"):
if path == ".github/workflows/ci.yml":
return False
if "/" not in path[len(".github/workflows/") :]:
return True
# .github/actions/build-image/* — direct children only, matches the
# single-star glob the workflow used to encode.
if path.startswith(".github/actions/build-image/"):
rest = path[len(".github/actions/build-image/") :]
if rest and "/" not in rest:
return True
return False
def should_run_core_ci(branch: str | None = None) -> bool:
"""Determine if the unconditional CI jobs (ci-custom/pytest/pre-commit-ci-lite) should run.
Returns False only when every changed file is in the CI-irrelevant set
above (see ``_is_ci_irrelevant_path``). Empty diffs return True so we
never accidentally skip CI when the diff probe fails.
Args:
branch: Branch to compare against. If None, uses default.
Returns:
True if the unconditional CI jobs should run, False otherwise.
"""
files = changed_files(branch)
if not files:
return True
return any(not _is_ci_irrelevant_path(f) for f in files)
def _any_changed_file_endswith(branch: str | None, extensions: tuple[str, ...]) -> bool:
"""Check if a changed file ends with any of the specified extensions."""
return any(file.endswith(extensions) for file in changed_files(branch))
@@ -1144,16 +1075,6 @@ def main() -> None:
args = parser.parse_args()
# Determine what should run
# core_ci gates the unconditional jobs in ci.yml (ci-custom, pytest,
# pre-commit-ci-lite). Non-pull_request events (push to dev/beta/release
# and merge_group) always run them so behavior like venv-cache saves on
# push to dev is preserved.
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
run_core_ci = (
True
if args.force_all or event_name != "pull_request"
else should_run_core_ci(args.branch)
)
if args.force_all:
integration_run_all, integration_test_files = True, []
run_clang_tidy = True
@@ -1334,7 +1255,6 @@ def main() -> None:
component_test_batches = []
output: dict[str, Any] = {
"core_ci": run_core_ci,
"integration_tests": run_integration,
"integration_test_buckets": integration_test_buckets,
"clang_tidy": run_clang_tidy,
@@ -9,17 +9,13 @@ import pytest
from esphome import config_validation as cv
from esphome.components.light import (
EffectCycleRef,
EffectRef,
_final_validate,
_get_data,
available_effects_str,
find_effect_index,
)
from esphome.components.light.automation import (
_record_effect_cycle_ref,
_record_effect_ref,
)
from esphome.components.light.automation import _record_effect_ref
from esphome.config import Config, path_context
from esphome.const import CONF_EFFECT, CONF_EFFECTS, CONF_ID, CONF_NAME
from esphome.core import ID, Lambda
@@ -219,111 +215,6 @@ def test_final_validate_drains_refs() -> None:
fv.full_config.reset(token)
# --- _final_validate: EffectCycleRef ---
def _setup_cycle_final_validate(
cycle_refs: list[EffectCycleRef],
light_configs: list[ConfigType],
declare_ids: list[tuple[ID, list[str | int]]],
) -> Token:
"""Set up CORE.data and fv.full_config for EffectCycleRef final_validate tests."""
data = _get_data()
data.effect_cycle_refs = cycle_refs
full_conf = Config()
full_conf["light"] = light_configs
for id_, path in declare_ids:
full_conf.declare_ids.append((id_, path))
return fv.full_config.set(full_conf)
def test_final_validate_cycle_accepts_light_with_effects() -> None:
"""Cycle ref against a light with effects should not raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_rejects_light_without_effects_key() -> None:
"""Cycle ref against a light with no CONF_EFFECTS key should raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_rejects_light_with_empty_effects() -> None:
"""Cycle ref against a light with empty effects list should raise."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: []}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
with pytest.raises(cv.FinalExternalInvalid, match="no effects configured"):
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_cycle_unknown_light_id_skipped() -> None:
"""Cycle refs to unknown light IDs should be silently skipped."""
data = _get_data()
data.effect_cycle_refs = [
EffectCycleRef(
light_id=ID("nonexistent", is_declaration=True),
component_path=["esphome"],
)
]
full_conf = Config()
token = fv.full_config.set(full_conf)
try:
_final_validate({})
finally:
fv.full_config.reset(token)
def test_final_validate_drains_cycle_refs() -> None:
"""Cycle refs should be drained after validation to avoid redundant runs."""
light_id = ID("led1", is_declaration=True)
token = _setup_cycle_final_validate(
cycle_refs=[
EffectCycleRef(light_id=light_id, component_path=["esphome"]),
],
light_configs=[{CONF_ID: light_id, CONF_EFFECTS: _make_effects("Fast Pulse")}],
declare_ids=[(light_id, ["light", 0, CONF_ID])],
)
try:
_final_validate({})
assert _get_data().effect_cycle_refs == []
finally:
fv.full_config.reset(token)
# --- _record_effect_ref ---
@@ -387,19 +278,3 @@ def test_record_effect_ref_skips_no_effect_key() -> None:
config: ConfigType = {CONF_ID: ID("led1", is_declaration=True)}
_record_effect_ref(config)
assert _get_data().effect_refs == []
# --- _record_effect_cycle_ref ---
@pytest.mark.usefixtures("_path_ctx")
def test_record_effect_cycle_ref() -> None:
"""Cycle-action config should be recorded with light_id and path."""
light_id = ID("led1", is_declaration=True)
config: ConfigType = {CONF_ID: light_id}
result = _record_effect_cycle_ref(config)
assert result is config
data = _get_data()
assert len(data.effect_cycle_refs) == 1
assert data.effect_cycle_refs[0].light_id is light_id
assert data.effect_cycle_refs[0].component_path == ["esphome"]
@@ -1,87 +0,0 @@
"""Tests for container_schema() memoization and lazy build."""
from __future__ import annotations
from collections.abc import Generator
from unittest.mock import patch
import pytest
from esphome import config_validation as cv
import esphome.components.lvgl # noqa: F401
from esphome.components.lvgl import schemas as lvgl_schemas
from esphome.components.lvgl.schemas import WIDGET_TYPES, container_schema
@pytest.fixture(autouse=True)
def _clear_container_schema_cache() -> Generator[None]:
cache = getattr(lvgl_schemas, "_CONTAINER_SCHEMA_CACHE", None)
if cache is not None:
cache.clear()
yield
if cache is not None:
cache.clear()
def _widget_type(name: str = "obj"):
wt = WIDGET_TYPES.get(name)
assert wt is not None, f"widget type {name!r} not registered"
return wt
def test_same_args_return_same_validator() -> None:
wt = _widget_type("obj")
assert container_schema(wt) is container_schema(wt)
def test_extras_none_vs_truthy_get_different_validators() -> None:
wt = _widget_type("obj")
no_extras = container_schema(wt)
extras = {cv.Optional("custom_extra"): cv.string}
assert no_extras is not container_schema(wt, extras)
def test_different_widget_types_get_different_validators() -> None:
assert container_schema(_widget_type("obj")) is not container_schema(
_widget_type("label")
)
def test_schema_build_is_deferred_until_first_validation() -> None:
wt = _widget_type("obj")
with patch.object(
lvgl_schemas, "obj_schema", wraps=lvgl_schemas.obj_schema
) as obj_schema_mock:
validator = container_schema(wt)
assert obj_schema_mock.call_count == 0
validator({})
assert obj_schema_mock.call_count == 1
validator({})
assert obj_schema_mock.call_count == 1
def test_cached_validator_produces_equivalent_output() -> None:
wt = _widget_type("obj")
cached = container_schema(wt)
cached_result = cached({})
lvgl_schemas._CONTAINER_SCHEMA_CACHE.clear()
reference = container_schema(wt)
assert cached is not reference
assert cached_result == reference({})
def test_id_recycling_is_caught_by_identity_guard() -> None:
wt = _widget_type("obj")
real_extras = {cv.Optional("a"): cv.int_}
validator_a = container_schema(wt, real_extras)
cache_key = (id(wt), id(real_extras))
cached_entry = lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key]
sentinel = {cv.Optional("a"): cv.int_}
lvgl_schemas._CONTAINER_SCHEMA_CACHE[cache_key] = (
cached_entry[0],
sentinel,
cached_entry[2],
)
assert container_schema(wt, real_extras) is not validator_a
@@ -1,53 +0,0 @@
"""Tests for lvgl.<widget>.update lazy schema build."""
from __future__ import annotations
from unittest.mock import patch
from esphome.automation import ACTION_REGISTRY
import esphome.components.lvgl # noqa: F401
from esphome.components.lvgl.schemas import WIDGET_TYPES
from esphome.components.lvgl.widgets import _update_action_schema
from esphome.config_validation import Schema
def _widget_type(name: str = "obj"):
wt = WIDGET_TYPES.get(name)
assert wt is not None, f"widget type {name!r} not registered"
return wt
def test_registry_entry_uses_lazy_validator() -> None:
entry = ACTION_REGISTRY["lvgl.label.update"]
assert callable(entry.raw_schema)
assert not isinstance(entry.raw_schema, Schema)
def test_lazy_validator_defers_build_until_first_call() -> None:
wt = _widget_type("label")
with patch(
"esphome.components.lvgl.widgets._build_update_schema",
wraps=lambda w: Schema({}),
) as build_mock:
validator = _update_action_schema(wt)
assert build_mock.call_count == 0
validator({})
assert build_mock.call_count == 1
validator({})
assert build_mock.call_count == 1
def test_eager_build_when_schema_extraction_enabled() -> None:
wt = _widget_type("label")
with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True):
result = _update_action_schema(wt)
assert isinstance(result, Schema)
def test_lazy_and_eager_produce_equivalent_validation() -> None:
wt = _widget_type("label")
with patch("esphome.components.lvgl.widgets.EnableSchemaExtraction", True):
eager = _update_action_schema(wt)
lazy = _update_action_schema(wt)
sample = {"id": "label_id"}
assert lazy(sample) == eager(sample)
@@ -1,8 +1,6 @@
<<: !include common.yaml
esp32_ble_tracker:
esp32_ble:
max_connections: 9
bluetooth_proxy:
@@ -1,8 +0,0 @@
substitutions:
i2s_bclk_pin: GPIO27
i2s_lrclk_pin: GPIO26
i2s_mclk_pin: GPIO25
i2s_dout_pin: GPIO12
spdif_data_pin: GPIO4
<<: !include common-spdif_mode.yaml

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