mirror of
https://github.com/esphome/esphome.git
synced 2026-06-29 20:16:08 +00:00
Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70c6021986 | |||
| 1485675928 | |||
| 3ed1356bb6 | |||
| 5cb145a8c3 | |||
| 74001ccf05 | |||
| 58931f2610 | |||
| f616103621 | |||
| 188ff7ebfd | |||
| d6bc4fea1c | |||
| be99553fd4 | |||
| b0dc688c14 | |||
| 2b422cbd99 | |||
| 9930b3c216 | |||
| 55f4e5cb75 | |||
| 71550bb3be | |||
| a58b4edb6a | |||
| f85fdb475a | |||
| 4a78c8d45a | |||
| c3bef24389 | |||
| 7182b1a8ae | |||
| 64e32ebe04 | |||
| 94b10981e1 | |||
| 680c9fc9c0 | |||
| 99de741f99 | |||
| ac530c33b0 | |||
| 0b5e7ae8fa | |||
| f818bddac8 | |||
| 1f0af903ea | |||
| ed289390df | |||
| 8f3010ac64 | |||
| 0b2eb6481f | |||
| 1d3eea098e | |||
| 4ff8eb4b15 | |||
| aea1e4d136 | |||
| 38b8b41ccc | |||
| 96eced0378 | |||
| 1ea95264bd | |||
| d2bda0a402 | |||
| 56fd77e4c8 | |||
| 3719ea740a | |||
| 750ae56778 | |||
| 01494f7431 | |||
| 233a60f106 | |||
| e0076cb1a8 | |||
| b619e3e8c7 | |||
| f2bfe5cd17 | |||
| 90715373f2 | |||
| 52e7d3ccfb | |||
| a70e358cea | |||
| 43a1c2067e | |||
| 9d9af645ac | |||
| 11760307f7 | |||
| 49bfa12eb7 | |||
| 870f628637 | |||
| 52c9a2d07b | |||
| 60afad442c | |||
| fbe212944b | |||
| 8927ade789 | |||
| 9bfae9e782 | |||
| eb64707d94 | |||
| 7814e99b6f | |||
| 8ad6813d44 | |||
| 1aa0a489f6 | |||
| 63fe977adb | |||
| be3ccd29f6 | |||
| 0912122634 | |||
| 9924d998f1 | |||
| e979d461f0 | |||
| 863af482ec | |||
| 80ed541032 | |||
| 1d0ddfac5d | |||
| c0e71fc713 | |||
| 73b8491936 | |||
| 1332ebe729 | |||
| 7ecfe4b5c9 | |||
| 028a54422e | |||
| de53e7a6b1 | |||
| 36fc36071d | |||
| cb581271ed | |||
| b0af4a9f0d | |||
| 0d1d00b654 | |||
| edb59476b1 | |||
| 9c696f5de1 | |||
| 6804965bd8 | |||
| 42ad2a6272 | |||
| 6690725860 | |||
| 155232875a | |||
| 01c0d3163e | |||
| 7c5d5f75dc | |||
| fb0bfea1c8 | |||
| 48d17571c8 | |||
| df100681e0 | |||
| 1a287bf785 | |||
| ff34e1061b | |||
| b78b78cbbb | |||
| 4c090c6b85 | |||
| ec6669fa67 | |||
| 59f8c1019f | |||
| fb70095ba1 | |||
| 65d6bb18ed | |||
| 47eb2adbf2 | |||
| 35631be260 | |||
| 96106d25bc | |||
| 1674ed9744 | |||
| 46be0f4f62 | |||
| ec1826a6ed | |||
| 8b3bc47547 | |||
| 4381a8baaa | |||
| 4189979391 | |||
| 1b1e21d470 | |||
| 5b6c54c961 | |||
| ff968a4629 | |||
| d832ce51cd | |||
| d663d80fde | |||
| c5c627d534 | |||
| d046dd7276 | |||
| 56983f414f | |||
| a92b607754 | |||
| 313d974983 | |||
| 1d86d856d1 | |||
| 1bb191aa77 | |||
| 5d9d6e83f7 | |||
| f3d7743460 | |||
| f291dc8d2f | |||
| a8e69a15e4 | |||
| 7436d1c199 | |||
| 348b92910e | |||
| f89a6f4f9c | |||
| c3ee962b83 | |||
| e593cb6efc | |||
| d2107e40c8 | |||
| 78b60ac6fa | |||
| 578196ab85 | |||
| d9b712ee5f | |||
| 1a61cd622e | |||
| 26bdf58daf | |||
| c915a2b8f5 | |||
| 7fdb95c2ef | |||
| e44365abca | |||
| 532641d523 | |||
| bc36892e7d | |||
| 3a02c2f8af | |||
| 4a1f9af319 | |||
| 9e29bdfdad | |||
| 20c975103b | |||
| 549b9f85ae | |||
| 0fe2310db4 | |||
| 5af3e5caef | |||
| 47854ff9de | |||
| 8a1ddfb1cc | |||
| cde89212fc | |||
| 0a518c1e4c | |||
| 8c7d2d984e | |||
| 8390a98614 |
@@ -116,7 +116,6 @@ Checks: >-
|
||||
-portability-template-virtual-member-function,
|
||||
-readability-ambiguous-smartptr-reset-call,
|
||||
-readability-avoid-nested-conditional-operator,
|
||||
-readability-container-contains,
|
||||
-readability-container-data-pointer,
|
||||
-readability-convert-member-functions-to-static,
|
||||
-readability-else-after-return,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c
|
||||
27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f
|
||||
|
||||
@@ -47,7 +47,7 @@ runs:
|
||||
|
||||
- name: Build and push to ghcr by digest
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
@@ -27,6 +27,18 @@ runs:
|
||||
path: venv
|
||||
# yamllint disable-line rule:line-length
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }}
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv. ``uv pip install``
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
|
||||
# downstream jobs rely on is preserved.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@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
|
||||
@@ -34,8 +46,8 @@ runs:
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
python --version
|
||||
pip install -r requirements.txt -r requirements_test.txt
|
||||
pip install -e .
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
|
||||
shell: bash
|
||||
@@ -43,5 +55,5 @@ runs:
|
||||
python -m venv venv
|
||||
source ./venv/Scripts/activate
|
||||
python --version
|
||||
pip install -r requirements.txt -r requirements_test.txt
|
||||
pip install -e .
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
|
||||
@@ -26,6 +26,16 @@ jobs:
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Set up uv
|
||||
# ``--system`` (below) installs into the setup-python interpreter;
|
||||
# no venv is created or restored by this workflow.
|
||||
uses: astral-sh/setup-uv@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: |
|
||||
@@ -34,7 +44,7 @@ jobs:
|
||||
sudo apt install -y protobuf-compiler
|
||||
protoc --version
|
||||
- name: Install python dependencies
|
||||
run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt
|
||||
run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
|
||||
- name: Generate files
|
||||
run: script/api_protobuf/api_protobuf.py
|
||||
- name: Check for changes
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Set TAG
|
||||
run: |
|
||||
|
||||
+75
-25
@@ -6,14 +6,6 @@ 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:
|
||||
@@ -52,14 +44,26 @@ jobs:
|
||||
path: venv
|
||||
# yamllint disable-line rule:line-length
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }}
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv. ``uv pip install``
|
||||
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
|
||||
# that ``. venv/bin/activate`` see an identical layout.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@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: |
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
|
||||
pip install -e .
|
||||
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
|
||||
uv pip install -e .
|
||||
|
||||
pylint:
|
||||
name: Check pylint
|
||||
@@ -89,6 +93,8 @@ 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
|
||||
@@ -167,6 +173,10 @@ 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
|
||||
@@ -181,7 +191,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=10 --no-cov --ignore=tests/benchmarks
|
||||
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks
|
||||
|
||||
pytest:
|
||||
name: Run pytest
|
||||
@@ -207,6 +217,8 @@ 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
|
||||
@@ -222,14 +234,14 @@ jobs:
|
||||
if: matrix.os == 'windows-latest'
|
||||
run: |
|
||||
. ./venv/Scripts/activate.ps1
|
||||
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
- name: Run pytest
|
||||
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Save Python virtual environment cache
|
||||
@@ -245,10 +257,12 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
outputs:
|
||||
core-ci: ${{ steps.determine.outputs.core-ci }}
|
||||
integration-tests: ${{ steps.determine.outputs.integration-tests }}
|
||||
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
|
||||
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
|
||||
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
|
||||
clang-tidy-full-scan: ${{ steps.determine.outputs.clang-tidy-full-scan }}
|
||||
python-linters: ${{ steps.determine.outputs.python-linters }}
|
||||
import-time: ${{ steps.determine.outputs.import-time }}
|
||||
device-builder: ${{ steps.determine.outputs.device-builder }}
|
||||
@@ -287,15 +301,22 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
output=$(python script/determine-jobs.py)
|
||||
EXTRA_ARGS=""
|
||||
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-run-all') }}" == "true" ]]; then
|
||||
EXTRA_ARGS="--force-all"
|
||||
echo "::notice::ci-run-all label detected -- forcing every CI job to run"
|
||||
fi
|
||||
output=$(python script/determine-jobs.py $EXTRA_ARGS)
|
||||
echo "Test determination output:"
|
||||
echo "$output" | jq
|
||||
|
||||
# Extract individual fields
|
||||
echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
|
||||
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
|
||||
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy-full-scan=$(echo "$output" | jq -r '.clang_tidy_full_scan')" >> $GITHUB_OUTPUT
|
||||
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
|
||||
echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
|
||||
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
|
||||
@@ -344,14 +365,24 @@ jobs:
|
||||
with:
|
||||
path: venv
|
||||
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
|
||||
- name: Set up uv
|
||||
# Only needed on cache miss to populate the venv.
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
uses: astral-sh/setup-uv@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: |
|
||||
python -m venv venv
|
||||
. venv/bin/activate
|
||||
python --version
|
||||
pip install -r requirements.txt -r requirements_test.txt
|
||||
pip install -e .
|
||||
uv pip install -r requirements.txt -r requirements_test.txt
|
||||
uv pip install -e .
|
||||
- name: Register matcher
|
||||
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
|
||||
- name: Run integration tests
|
||||
@@ -363,7 +394,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 -n auto "${test_files[@]}"
|
||||
pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
|
||||
|
||||
cpp-unit-tests:
|
||||
name: Run C++ unit tests
|
||||
@@ -500,7 +531,13 @@ jobs:
|
||||
id: check_full_scan
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
if python script/clang_tidy_hash.py --check; then
|
||||
# determine-jobs.clang-tidy-full-scan is true when core C++ changed
|
||||
# OR the ci-run-all label forced --force-all. Independent of the
|
||||
# hash check, both must produce a full scan in the job itself.
|
||||
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=determine_jobs" >> $GITHUB_OUTPUT
|
||||
elif python script/clang_tidy_hash.py --check; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=hash_changed" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -512,7 +549,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
|
||||
echo "Running FULL clang-tidy scan (hash changed)"
|
||||
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
|
||||
script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
|
||||
else
|
||||
echo "Running clang-tidy on changed files only"
|
||||
@@ -572,7 +609,13 @@ jobs:
|
||||
id: check_full_scan
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
if python script/clang_tidy_hash.py --check; then
|
||||
# determine-jobs.clang-tidy-full-scan is true when core C++ changed
|
||||
# OR the ci-run-all label forced --force-all. Independent of the
|
||||
# hash check, both must produce a full scan in the job itself.
|
||||
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=determine_jobs" >> $GITHUB_OUTPUT
|
||||
elif python script/clang_tidy_hash.py --check; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=hash_changed" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -584,7 +627,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
|
||||
echo "Running FULL clang-tidy scan (hash changed)"
|
||||
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
|
||||
script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy
|
||||
else
|
||||
echo "Running clang-tidy on changed files only"
|
||||
@@ -661,7 +704,13 @@ jobs:
|
||||
id: check_full_scan
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
if python script/clang_tidy_hash.py --check; then
|
||||
# determine-jobs.clang-tidy-full-scan is true when core C++ changed
|
||||
# OR the ci-run-all label forced --force-all. Independent of the
|
||||
# hash check, both must produce a full scan in the job itself.
|
||||
if [ "${{ needs.determine-jobs.outputs.clang-tidy-full-scan }}" = "true" ]; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=determine_jobs" >> $GITHUB_OUTPUT
|
||||
elif python script/clang_tidy_hash.py --check; then
|
||||
echo "full_scan=true" >> $GITHUB_OUTPUT
|
||||
echo "reason=hash_changed" >> $GITHUB_OUTPUT
|
||||
else
|
||||
@@ -673,7 +722,7 @@ jobs:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
|
||||
echo "Running FULL clang-tidy scan (hash changed)"
|
||||
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
|
||||
script/clang-tidy --all-headers --fix ${{ matrix.options }}
|
||||
else
|
||||
echo "Running clang-tidy on changed files only"
|
||||
@@ -918,7 +967,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- common
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -12,6 +12,12 @@ jobs:
|
||||
dashboard-deprecation-comment:
|
||||
name: Dashboard deprecation comment
|
||||
runs-on: ubuntu-latest
|
||||
# Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably
|
||||
# roll up everything merged into dev since the last cut, which can
|
||||
# include dashboard changes that have already been reviewed once.
|
||||
# The bot's purpose is to warn new contributors before they invest
|
||||
# time -- that only applies to PRs entering dev.
|
||||
if: github.event.pull_request.base.ref == 'dev'
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
|
||||
@@ -29,10 +29,11 @@ jobs:
|
||||
} = require('./.github/scripts/detect-tags.js');
|
||||
|
||||
const title = context.payload.pull_request.title;
|
||||
const author = context.payload.pull_request.user.login;
|
||||
const user = context.payload.pull_request.user;
|
||||
|
||||
// Skip bot PRs (e.g. dependabot) - they have their own title format
|
||||
if (author === 'dependabot[bot]') {
|
||||
// Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) -
|
||||
// they have their own title formats.
|
||||
if (user.type === 'Bot') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,14 +69,15 @@ jobs:
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for angle brackets not wrapped in backticks.
|
||||
// Astro docs MDX treats bare < as JSX component opening tags.
|
||||
// Check for MDX syntax characters not wrapped in backticks.
|
||||
// Astro docs MDX treats bare `<` as JSX component opening tags and
|
||||
// bare `{` as JS expressions, so both must be escaped in changelog entries.
|
||||
const stripped = title.replace(/`[^`]*`/g, '');
|
||||
if (/[<>]/.test(stripped)) {
|
||||
if (/[<>{}]/.test(stripped)) {
|
||||
core.setFailed(
|
||||
'PR title contains `<` or `>` not wrapped in backticks.\n' +
|
||||
'Astro docs MDX interprets bare `<` as JSX components.\n' +
|
||||
'Please wrap angle brackets with backticks, e.g.: [component] Add `<feature>` support'
|
||||
'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' +
|
||||
'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' +
|
||||
'Please wrap these characters with backticks, e.g.: [component] Add `<feature>` support'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -99,15 +99,15 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
|
||||
remove-stale-when-updated: true
|
||||
|
||||
@@ -41,19 +41,56 @@ jobs:
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Set up uv
|
||||
# An order of magnitude faster than pip on cold boots, with its
|
||||
# own wheel cache. ``--system`` (below) installs into the
|
||||
# setup-python interpreter so subsequent ``pre-commit`` /
|
||||
# ``script/run-in-env.py`` steps find the deps without a
|
||||
# ``uv run`` prefix.
|
||||
uses: astral-sh/setup-uv@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: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e lib/home-assistant
|
||||
pip install -r requirements_test.txt pre-commit
|
||||
uv pip install --system -e lib/home-assistant
|
||||
uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
|
||||
|
||||
- name: Sync
|
||||
run: |
|
||||
python ./script/sync-device_class.py
|
||||
|
||||
- name: Run pre-commit hooks
|
||||
run: |
|
||||
python script/run-in-env.py pre-commit run --all-files
|
||||
- name: Apply pre-commit auto-fixes
|
||||
# First pass: let formatters (ruff, end-of-file-fixer, etc.) modify
|
||||
# files. pre-commit exits non-zero whenever a hook touches anything,
|
||||
# which would otherwise abort the workflow before the auto-fixes
|
||||
# can flow into the sync PR.
|
||||
#
|
||||
# SKIP:
|
||||
# - no-commit-to-branch is a local guard against committing on
|
||||
# dev/release/beta; CI runs on dev by definition, and
|
||||
# peter-evans/create-pull-request creates the branch itself.
|
||||
# - pylint surfaces import-error / relative-beyond-top-level
|
||||
# noise here because this workflow installs only a subset of
|
||||
# the runtime deps (HA + requirements*.txt); main CI already
|
||||
# gates pylint on real PRs.
|
||||
env:
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files || true
|
||||
|
||||
- name: Verify pre-commit clean
|
||||
# Second pass: re-run all hooks against the now-fixed tree.
|
||||
# Auto-fixers exit 0 (nothing to change); any remaining failure
|
||||
# from a check-only hook (flake8 / yamllint / ci-custom) is a
|
||||
# real issue and fails the workflow loudly. Same SKIP list as
|
||||
# above for the same reasons.
|
||||
env:
|
||||
SKIP: pylint,no-commit-to-branch
|
||||
run: python script/run-in-env.py pre-commit run --all-files
|
||||
|
||||
- name: Commit changes
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.12
|
||||
rev: v0.15.14
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.5.3
|
||||
PROJECT_NUMBER = 2026.6.0-dev
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
+12
-85
@@ -260,42 +260,20 @@ class ConfigBundleCreator:
|
||||
def _discover_yaml_includes(self) -> None:
|
||||
"""Discover YAML files loaded during config parsing.
|
||||
|
||||
Deliberately uses a fresh re-parse and force-loads every deferred
|
||||
``IncludeFile`` to include *all* potentially-reachable includes,
|
||||
even branches not selected by the local substitutions. Bundles are
|
||||
meant to be compiled on another system where command-line
|
||||
substitution overrides may choose a different branch — e.g.
|
||||
``!include network/${eth_model}/config.yaml`` must ship every
|
||||
candidate so the remote build can pick any one.
|
||||
|
||||
Entries with unresolved substitution variables in the filename
|
||||
path are skipped with a warning (they cannot be resolved without
|
||||
the substitution pass).
|
||||
|
||||
Secrets files are tracked separately so we can filter them to
|
||||
only include the keys this config actually references.
|
||||
Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a
|
||||
fresh re-parse and force-loads every deferred ``IncludeFile`` so that
|
||||
*all* potentially-reachable includes are captured (even branches not
|
||||
selected by local substitutions). Bundles are meant to be compiled on
|
||||
another system where command-line substitution overrides may choose a
|
||||
different branch — e.g. ``!include network/${eth_model}/config.yaml``
|
||||
must ship every candidate so the remote build can pick any one.
|
||||
"""
|
||||
# Must be a fresh parse: IncludeFile.load() caches its result in
|
||||
# _content, and we discover files by listening for loader calls. On
|
||||
# an already-parsed tree the cache is populated, .load() returns
|
||||
# without calling the loader, the listener never fires, and the
|
||||
# referenced files would be silently dropped from the bundle.
|
||||
with yaml_util.track_yaml_loads() as loaded_files:
|
||||
try:
|
||||
data = yaml_util.load_yaml(self._config_path)
|
||||
except EsphomeError:
|
||||
_LOGGER.debug(
|
||||
"Bundle: re-loading YAML for include discovery failed, "
|
||||
"proceeding with partial file list"
|
||||
)
|
||||
else:
|
||||
_force_load_include_files(data)
|
||||
|
||||
for fpath in loaded_files:
|
||||
if fpath == self._config_path.resolve():
|
||||
discovered = yaml_util.discover_user_yaml_files(self._config_path)
|
||||
self._secrets_paths.update(discovered.secrets)
|
||||
config_resolved = self._config_path.resolve()
|
||||
for fpath in discovered.files:
|
||||
if fpath == config_resolved:
|
||||
continue # Already added as config
|
||||
if fpath.name in const.SECRETS_FILES:
|
||||
self._secrets_paths.add(fpath)
|
||||
self._add_file(fpath)
|
||||
|
||||
def _discover_component_files(self) -> None:
|
||||
@@ -625,57 +603,6 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
|
||||
|
||||
def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
|
||||
"""Recursively resolve any ``IncludeFile`` instances in a YAML tree.
|
||||
|
||||
Nested ``!include`` returns a deferred ``IncludeFile`` that is only
|
||||
resolved during the substitution pass. During bundle discovery we need
|
||||
the referenced files to actually load so the ``track_yaml_loads``
|
||||
listener fires for them.
|
||||
|
||||
``IncludeFile`` instances with unresolved substitution variables in the
|
||||
filename cannot be loaded — we skip and warn about those.
|
||||
"""
|
||||
if _seen is None:
|
||||
_seen = set()
|
||||
|
||||
if isinstance(obj, yaml_util.IncludeFile):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
if obj.has_unresolved_expressions():
|
||||
_LOGGER.warning(
|
||||
"Bundle: cannot resolve !include %s (referenced from %s) "
|
||||
"with substitutions in path",
|
||||
obj.file,
|
||||
obj.parent_file,
|
||||
)
|
||||
return
|
||||
try:
|
||||
loaded = obj.load()
|
||||
except EsphomeError as err:
|
||||
_LOGGER.warning(
|
||||
"Bundle: failed to load !include %s (referenced from %s): %s",
|
||||
obj.file,
|
||||
obj.parent_file,
|
||||
err,
|
||||
)
|
||||
return
|
||||
_force_load_include_files(loaded, _seen)
|
||||
elif isinstance(obj, dict):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
for value in obj.values():
|
||||
_force_load_include_files(value, _seen)
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
if id(obj) in _seen:
|
||||
return
|
||||
_seen.add(id(obj))
|
||||
for item in obj:
|
||||
_force_load_include_files(item, _seen)
|
||||
|
||||
|
||||
def _resolve_include_path(include_path: Any) -> Path | None:
|
||||
"""Resolve an include path to absolute, skipping system includes."""
|
||||
if isinstance(include_path, str) and include_path.startswith("<"):
|
||||
|
||||
@@ -1306,9 +1306,6 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
|
||||
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
|
||||
VoiceAssistantConfigurationResponse resp;
|
||||
if (!this->check_voice_assistant_api_connection_()) {
|
||||
// send_message encodes synchronously, so this stack local outlives the encode
|
||||
const std::vector<std::string> empty_wake_words;
|
||||
resp.active_wake_words = &empty_wake_words;
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
|
||||
@@ -335,7 +335,7 @@ async def to_code(config):
|
||||
|
||||
add_idf_component(
|
||||
name="esphome/esp-audio-libs",
|
||||
ref="3.0.0",
|
||||
ref="3.1.0",
|
||||
)
|
||||
|
||||
data = _get_data()
|
||||
|
||||
@@ -252,6 +252,22 @@ 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,6 +250,10 @@ 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
|
||||
|
||||
@@ -46,7 +46,7 @@ from esphome.const import (
|
||||
Toolchain,
|
||||
__version__,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError, HexInt, Library
|
||||
from esphome.core import CORE, HexInt, Library
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.espidf.component import generate_idf_component
|
||||
@@ -1816,11 +1816,8 @@ async def to_code(config):
|
||||
Path(__file__).parent / "iram_fix.py.script",
|
||||
)
|
||||
else:
|
||||
# Demote IDF's blanket -Werror to warnings so third-party libs
|
||||
# and user lambdas don't need a -Wno-error=<class> per warning.
|
||||
# The sdkconfig knob disables IDF's rewrite to -Werror=all (which
|
||||
# can't be globally undone); -Wno-error then handles the demotion.
|
||||
add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False)
|
||||
# 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")
|
||||
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
|
||||
cg.add_build_flag("-Wno-missing-field-initializers")
|
||||
@@ -2011,7 +2008,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", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", 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)
|
||||
@@ -2053,7 +2050,8 @@ 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]:
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0)
|
||||
# Kconfig range is [1,63]; 0 gets clamped to the default.
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1)
|
||||
|
||||
_configure_lwip_max_sockets(conf)
|
||||
|
||||
@@ -2145,7 +2143,6 @@ 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)
|
||||
@@ -2300,7 +2297,8 @@ 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)
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0)
|
||||
# Kconfig range is [1,10]; 0 gets clamped to the default.
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1)
|
||||
|
||||
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
|
||||
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
|
||||
@@ -2657,29 +2655,13 @@ def copy_files():
|
||||
|
||||
|
||||
def _decode_pc(config, addr):
|
||||
# _decode_pc runs from the api log processor's asyncio callback, which
|
||||
# only catches EsphomeError. Any other exception escaping here tears down
|
||||
# the protocol and triggers an infinite reconnect/replay loop. Convert
|
||||
# toolchain-resolution errors (e.g. missing build dir / cmake cache) into
|
||||
# EsphomeError so the caller can disable decoding cleanly.
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain as idf_toolchain
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
try:
|
||||
addr2line_path = idf_toolchain.get_addr2line_path()
|
||||
firmware_elf_path = idf_toolchain.get_elf_path()
|
||||
except RuntimeError as err:
|
||||
raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
addr2line_path = idedata.addr2line_path
|
||||
firmware_elf_path = idedata.firmware_elf_path
|
||||
if not addr2line_path or not firmware_elf_path:
|
||||
idedata = toolchain.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
_LOGGER.debug("decode_pc no addr2line")
|
||||
return
|
||||
command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr]
|
||||
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||
try:
|
||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
|
||||
@@ -62,26 +62,6 @@ MANUFACTURER_NAME_CHARACTERISTIC_UUID = 0x2A29
|
||||
MODEL_CHARACTERISTIC_UUID = 0x2A24
|
||||
FIRMWARE_VERSION_CHARACTERISTIC_UUID = 0x2A26
|
||||
|
||||
# Suffix of the Bluetooth Base UUID used to expand 16/32 bit UUIDs to 128 bit.
|
||||
_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB"
|
||||
|
||||
|
||||
def uuid_is(uuid: int | str, uuid16: int) -> bool:
|
||||
"""Return True if a validated UUID refers to the given 16-bit short UUID.
|
||||
|
||||
A service/characteristic UUID may be an ``int`` (from ``cv.hex_uint32_t``) or an
|
||||
uppercase string in 16, 32 or 128 bit form (from ``bt_uuid``), so every
|
||||
representation of the same UUID must be considered equivalent.
|
||||
"""
|
||||
if isinstance(uuid, int):
|
||||
return uuid == uuid16
|
||||
return uuid.upper() in (
|
||||
f"{uuid16:04X}",
|
||||
f"{uuid16:08X}",
|
||||
f"{uuid16:08X}{_BASE_UUID_SUFFIX}",
|
||||
)
|
||||
|
||||
|
||||
# Core key to store the global configuration
|
||||
KEY_NOTIFY_REQUIRED = "notify_required"
|
||||
KEY_SET_VALUE = "set_value"
|
||||
@@ -215,7 +195,7 @@ def create_description_cud(char_config):
|
||||
return char_config
|
||||
# If the config displays a description, there cannot be a descriptor with the CUD UUID
|
||||
for desc in char_config[CONF_DESCRIPTORS]:
|
||||
if uuid_is(desc[CONF_UUID], CUD_DESCRIPTOR_UUID):
|
||||
if desc[CONF_UUID] == CUD_DESCRIPTOR_UUID:
|
||||
raise cv.Invalid(
|
||||
f"Characteristic {char_config[CONF_UUID]} has a description, but a CUD descriptor is already present"
|
||||
)
|
||||
@@ -238,7 +218,7 @@ def create_notify_cccd(char_config):
|
||||
return char_config
|
||||
# If the CCCD descriptor is already present, return the config
|
||||
for desc in char_config[CONF_DESCRIPTORS]:
|
||||
if uuid_is(desc[CONF_UUID], CCCD_DESCRIPTOR_UUID):
|
||||
if desc[CONF_UUID] == CCCD_DESCRIPTOR_UUID:
|
||||
# Check if the WRITE property is set
|
||||
if not desc[CONF_WRITE]:
|
||||
raise cv.Invalid(
|
||||
@@ -264,7 +244,7 @@ def create_device_information_service(config):
|
||||
# If there is already a device information service,
|
||||
# there cannot be CONF_MODEL, CONF_MANUFACTURER or CONF_FIRMWARE_VERSION properties
|
||||
for service in config[CONF_SERVICES]:
|
||||
if uuid_is(service[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID):
|
||||
if service[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID:
|
||||
if (
|
||||
CONF_MODEL in config
|
||||
or CONF_MANUFACTURER in config
|
||||
@@ -612,7 +592,7 @@ async def to_code(config):
|
||||
)
|
||||
for char_conf in service_config[CONF_CHARACTERISTICS]:
|
||||
await to_code_characteristic(service_var, char_conf)
|
||||
if uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID):
|
||||
if service_config[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID:
|
||||
cg.add(var.set_device_information_service(service_var))
|
||||
else:
|
||||
cg.add(var.enqueue_start_service(service_var))
|
||||
|
||||
@@ -196,42 +196,35 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt
|
||||
(*this->on_read_callback_)(param->read.conn_id);
|
||||
}
|
||||
|
||||
uint16_t max_offset = 22;
|
||||
|
||||
// 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;
|
||||
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.offset = offset;
|
||||
|
||||
if (offset > this->value_.size()) {
|
||||
status = ESP_GATT_INVALID_OFFSET;
|
||||
response.attr_value.len = 0;
|
||||
} 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();
|
||||
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;
|
||||
}
|
||||
memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len);
|
||||
response.attr_value.len = remaining;
|
||||
memcpy(response.attr_value.value, this->value_.data() + offset, remaining);
|
||||
}
|
||||
|
||||
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, ESP_GATT_OK, &response);
|
||||
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ 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_;
|
||||
|
||||
|
||||
@@ -58,6 +58,12 @@ __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); }
|
||||
|
||||
@@ -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,12 +149,6 @@ 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 {
|
||||
@@ -174,8 +168,6 @@ 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));
|
||||
|
||||
@@ -3,7 +3,11 @@ import logging
|
||||
|
||||
from esphome import automation, pins
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.network import ip_address_literal
|
||||
from esphome.components.network import (
|
||||
KEY_NETWORK_PRIORITY,
|
||||
get_network_priority,
|
||||
ip_address_literal,
|
||||
)
|
||||
from esphome.config_helpers import filter_source_files_from_platform
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -13,6 +17,7 @@ from esphome.const import (
|
||||
CONF_DNS1,
|
||||
CONF_DNS2,
|
||||
CONF_DOMAIN,
|
||||
CONF_ENABLE_ON_BOOT,
|
||||
CONF_GATEWAY,
|
||||
CONF_ID,
|
||||
CONF_INTERRUPT_PIN,
|
||||
@@ -27,6 +32,7 @@ from esphome.const import (
|
||||
CONF_PAGE_ID,
|
||||
CONF_PIN,
|
||||
CONF_POLLING_INTERVAL,
|
||||
CONF_PRIORITY,
|
||||
CONF_RESET_PIN,
|
||||
CONF_SPI,
|
||||
CONF_STATIC_IP,
|
||||
@@ -48,7 +54,6 @@ 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__)
|
||||
|
||||
@@ -348,6 +353,7 @@ 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),
|
||||
}
|
||||
@@ -487,6 +493,11 @@ 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:
|
||||
@@ -494,6 +505,9 @@ 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:
|
||||
@@ -576,10 +590,16 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
|
||||
)
|
||||
cg.add(var.add_phy_register(reg))
|
||||
|
||||
# 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 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)
|
||||
|
||||
# Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time)
|
||||
include_builtin_idf_component("esp_eth")
|
||||
@@ -665,6 +685,17 @@ 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,6 +124,17 @@ 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);
|
||||
@@ -194,6 +205,16 @@ 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
|
||||
@@ -287,6 +308,17 @@ 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,6 +5,7 @@
|
||||
#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>
|
||||
@@ -137,6 +138,24 @@ 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
|
||||
@@ -163,11 +182,7 @@ void EthernetComponent::setup() {
|
||||
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
|
||||
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
|
||||
#endif
|
||||
|
||||
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");
|
||||
// Network interface setup handled by network component
|
||||
|
||||
esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH();
|
||||
this->eth_netif_ = esp_netif_new(&cfg);
|
||||
@@ -207,6 +222,10 @@ void EthernetComponent::setup() {
|
||||
#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
|
||||
@@ -370,9 +389,41 @@ void EthernetComponent::setup() {
|
||||
ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error");
|
||||
#endif /* USE_NETWORK_IPV6 */
|
||||
|
||||
/* start Ethernet driver state machine */
|
||||
err = esp_eth_start(this->eth_handle_);
|
||||
ESPHL_ERROR_CHECK(err, "ETH start error");
|
||||
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.
|
||||
}
|
||||
|
||||
void EthernetComponent::dump_config() {
|
||||
@@ -486,6 +537,8 @@ 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) {
|
||||
@@ -708,6 +761,10 @@ 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;
|
||||
@@ -775,6 +832,13 @@ 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");
|
||||
@@ -794,6 +858,8 @@ 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);
|
||||
@@ -802,6 +868,8 @@ 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,6 +361,23 @@ 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
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
#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
|
||||
@@ -0,0 +1,35 @@
|
||||
#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
|
||||
@@ -17,9 +17,9 @@ void HomeassistantSensor::setup() {
|
||||
}
|
||||
|
||||
if (this->attribute_ != nullptr) {
|
||||
ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
|
||||
ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
|
||||
ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
|
||||
}
|
||||
this->publish_state(*val);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <driver/gpio.h>
|
||||
#include <driver/i2s_std.h>
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
@@ -299,6 +300,15 @@ 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();
|
||||
}
|
||||
|
||||
@@ -11,19 +11,11 @@
|
||||
#include "esphome/core/time_64.h"
|
||||
|
||||
// IRAM_ATTR places a function in executable RAM so it is callable from an
|
||||
// ISR even while flash is busy (XIP stall, OTA, logger flash write). All
|
||||
// LibreTiny families that need it share the same .sram.text input section
|
||||
// name; how that section is routed into RAM differs per family:
|
||||
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
|
||||
// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the
|
||||
// top of .ram_image2.data (which IS in ltchiptool's
|
||||
// sections_ram). The stock linker has KEEP(*(.image2.ram.text*))
|
||||
// in .ram_image2.text but that output section is NOT in
|
||||
// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed
|
||||
// there is dropped from the flashed binary.
|
||||
// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into
|
||||
// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors))
|
||||
// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR.
|
||||
// ISR even while flash is busy (XIP stall, OTA, logger flash write).
|
||||
// Each family uses a section its stock linker already routes to RAM:
|
||||
// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the
|
||||
// exception: its stock linker has no matching glob, so patch_linker.py
|
||||
// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link.
|
||||
//
|
||||
// BK72xx (all variants) are left as a no-op: their SDK wraps flash
|
||||
// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for
|
||||
@@ -34,7 +26,13 @@
|
||||
// layer.
|
||||
#if defined(USE_BK72XX)
|
||||
#define IRAM_ATTR
|
||||
#elif defined(USE_LIBRETINY_VARIANT_RTL8710B)
|
||||
// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM).
|
||||
#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text")))
|
||||
#else
|
||||
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
|
||||
// LN882H: patch_linker.py.script injects *(.sram.text*) into
|
||||
// .flash_copysection (> RAM0 AT> FLASH).
|
||||
#define IRAM_ATTR __attribute__((noinline, section(".sram.text")))
|
||||
#endif
|
||||
#define PROGMEM
|
||||
|
||||
@@ -6,18 +6,12 @@ import re
|
||||
import subprocess
|
||||
|
||||
# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family
|
||||
# section routed into RAM-executable memory (see esphome/core/hal.h). The
|
||||
# input section name is always .sram.text; only the output section it lands
|
||||
# in differs per family.
|
||||
# section routed into RAM-executable memory (see esphome/core/hal.h).
|
||||
#
|
||||
# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK
|
||||
# masks FIQ+IRQ around flash writes). On the remaining families:
|
||||
# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
|
||||
# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text,
|
||||
# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list
|
||||
# .ram_image2.text in sections_ram, so code there is silently dropped from
|
||||
# the flashed image. Inject KEEP(*(.sram.text*)) at the top of
|
||||
# .ram_image2.data (which IS extracted) instead.
|
||||
# - 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
|
||||
@@ -40,20 +34,6 @@ _KEEP_LINE = (
|
||||
# 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)")
|
||||
# Inject at the top of .ram_image2.data, before __data_start__ so our code
|
||||
# does not fall inside the data range markers. .ram_image2.data is one of the
|
||||
# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is
|
||||
# executable. AmbZ has no C runtime .data copy loop (the bootloader loads
|
||||
# image2 into BD_RAM whole) so the inline code is not clobbered after boot.
|
||||
#
|
||||
# The regex is intentionally strict (no attribute / ALIGN between the section
|
||||
# name and the opening brace, brace on its own line). If a future AmbZ SDK
|
||||
# linker template changes this format, _pre_link raises RuntimeError on the
|
||||
# unpatched .ld file(s), and the RTL8710B CI compile job in
|
||||
# tests/test_build_components fails on the PR, surfacing the mismatch loudly
|
||||
# rather than silently shipping a binary with IRAM_ATTR code dropped from
|
||||
# one or both OTA slots.
|
||||
_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)")
|
||||
|
||||
|
||||
def _detect(env):
|
||||
@@ -91,11 +71,12 @@ def _inject_keep(host_section):
|
||||
|
||||
|
||||
# Variants not listed here intentionally have no .ld patcher:
|
||||
# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text.
|
||||
# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker
|
||||
# already routes into .ram_image2.text (> BD_RAM).
|
||||
# - RTL8720C: stock linker already consumes *(.sram.text*).
|
||||
# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op.
|
||||
_PATCHERS_BY_VARIANT = {
|
||||
"LN882H": (_inject_keep(_LN_COPY),),
|
||||
"RTL8710B": (_inject_keep(_AMBZ_DATA),),
|
||||
}
|
||||
|
||||
|
||||
@@ -106,14 +87,13 @@ def _patchers_for(variant):
|
||||
def _pre_link(target, source, env):
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")]
|
||||
patched = []
|
||||
unpatched = []
|
||||
patched = 0
|
||||
for name in ld_files:
|
||||
path = os.path.join(build_dir, name)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
original = fh.read()
|
||||
if _MARKER in original:
|
||||
patched.append(name)
|
||||
patched += 1
|
||||
continue
|
||||
content = original
|
||||
for fn in _patchers:
|
||||
@@ -122,9 +102,7 @@ def _pre_link(target, source, env):
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
print("ESPHome: patched {} for IRAM_ATTR placement".format(name))
|
||||
patched.append(name)
|
||||
else:
|
||||
unpatched.append(name)
|
||||
patched += 1
|
||||
if not patched:
|
||||
raise RuntimeError(
|
||||
"ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the "
|
||||
@@ -132,20 +110,6 @@ def _pre_link(target, source, env):
|
||||
build_dir
|
||||
)
|
||||
)
|
||||
# Every .ld in the build must be patched. RTL8710B generates one .ld per
|
||||
# OTA slot (xip1, xip2); if only one matches, the unpatched slot would
|
||||
# ship with IRAM_ATTR code dropped to zeros and brick the device on the
|
||||
# boot after an OTA into that slot.
|
||||
if unpatched:
|
||||
raise RuntimeError(
|
||||
"ESPHome: {} of {} .ld file(s) in {} were not patched for "
|
||||
"IRAM_ATTR: {}. The regex in patch_linker.py.script "
|
||||
"(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not "
|
||||
"these. Update the regex to cover all linker scripts.".format(
|
||||
len(unpatched), len(ld_files), build_dir,
|
||||
", ".join(unpatched), _variant,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Substrings matched against demangled names as a fallback on RTL8720C,
|
||||
|
||||
@@ -86,10 +86,22 @@ 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:
|
||||
@@ -160,13 +172,15 @@ 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:
|
||||
if not data.effect_refs and not data.effect_cycle_refs:
|
||||
return config
|
||||
|
||||
# Drain the list so we only validate once even though
|
||||
# Drain the lists 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()
|
||||
|
||||
@@ -188,6 +202,21 @@ 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
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +104,47 @@ 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) {}
|
||||
|
||||
@@ -26,8 +26,8 @@ from esphome.const import (
|
||||
CONF_WARM_WHITE,
|
||||
CONF_WHITE,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError, Lambda
|
||||
from esphome.cpp_generator import LambdaExpression
|
||||
from esphome.core import CORE, ID, EsphomeError, Lambda
|
||||
from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .types import (
|
||||
@@ -39,12 +39,15 @@ from .types import (
|
||||
DimRelativeAction,
|
||||
LightCall,
|
||||
LightControlAction,
|
||||
LightEffectCycleAction,
|
||||
LightIsOffCondition,
|
||||
LightIsOnCondition,
|
||||
LightState,
|
||||
ToggleAction,
|
||||
)
|
||||
|
||||
CONF_INCLUDE_NONE = "include_none"
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.toggle",
|
||||
@@ -253,6 +256,75 @@ 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(
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ 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)
|
||||
|
||||
@@ -572,7 +572,7 @@ void LvButtonMatrixType::set_obj(lv_obj_t *lv_obj) {
|
||||
auto key_idx = lv_buttonmatrix_get_selected_button(self->obj);
|
||||
if (key_idx == LV_BUTTONMATRIX_BUTTON_NONE)
|
||||
return;
|
||||
if (self->key_map_.count(key_idx) != 0) {
|
||||
if (self->key_map_.contains(key_idx)) {
|
||||
self->send_key_(self->key_map_[key_idx]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.automation import Trigger, validate_automation
|
||||
@@ -534,7 +535,16 @@ def strip_defaults(schema: cv.Schema):
|
||||
return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()})
|
||||
|
||||
|
||||
def container_schema(widget_type: WidgetType, extras=None):
|
||||
# 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]:
|
||||
"""
|
||||
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.
|
||||
@@ -542,19 +552,31 @@ def container_schema(widget_type: WidgetType, extras=None):
|
||||
:param extras: Additional options to be made available, e.g. layout properties for children
|
||||
:return: The schema for this type of widget.
|
||||
"""
|
||||
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
|
||||
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 = schema.extend(widget_type.schema)
|
||||
cached_schema: cv.Schema | None = None
|
||||
|
||||
def validator(value):
|
||||
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:
|
||||
value = value or {}
|
||||
return append_layout_schema(schema, value)(value)
|
||||
return append_layout_schema(get_schema(), value)(value)
|
||||
|
||||
_CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator)
|
||||
return validator
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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
|
||||
@@ -15,6 +17,7 @@ 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 (
|
||||
@@ -73,6 +76,34 @@ 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"
|
||||
@@ -113,18 +144,17 @@ class WidgetType:
|
||||
|
||||
# Local import to avoid circular import
|
||||
from ..automation import update_to_code
|
||||
from ..schemas import WIDGET_TYPES, base_update_schema
|
||||
from ..schemas import WIDGET_TYPES
|
||||
|
||||
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,
|
||||
base_update_schema(self, self.parts).extend(self.modify_schema),
|
||||
_update_action_schema(self),
|
||||
synchronous=True,
|
||||
)(update_to_code)
|
||||
|
||||
|
||||
@@ -5,8 +5,15 @@ 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_MIN_IPV6_ADDR_COUNT
|
||||
from esphome.const import (
|
||||
CONF_ENABLE_IPV6,
|
||||
CONF_ID,
|
||||
CONF_MIN_IPV6_ADDR_COUNT,
|
||||
CONF_PRIORITY,
|
||||
CONF_TIMEOUT,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
import esphome.final_validate as fv
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
AUTO_LOAD = ["mdns"]
|
||||
@@ -18,7 +25,30 @@ _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")
|
||||
|
||||
|
||||
@@ -105,8 +135,160 @@ 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,
|
||||
@@ -130,15 +312,53 @@ 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)
|
||||
@@ -224,3 +444,22 @@ 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))
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
#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
|
||||
@@ -0,0 +1,54 @@
|
||||
#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
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import display, esp32, uart
|
||||
@@ -39,6 +41,8 @@ from .base_component import (
|
||||
CONF_WAKE_UP_PAGE,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CODEOWNERS = ["@senexcrenshaw", "@edwardtfn"]
|
||||
DEPENDENCIES = ["uart"]
|
||||
|
||||
@@ -55,6 +59,15 @@ NextionSetBrightnessAction = nextion_ns.class_(
|
||||
)
|
||||
|
||||
|
||||
def _deprecated_dump_device_info(value):
|
||||
_LOGGER.warning(
|
||||
"'dump_device_info' is deprecated and will be removed in ESPHome 2026.11.0. "
|
||||
"Device info is now always logged at connection time. "
|
||||
"Please remove this option from your configuration."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _validate_tft_upload(config):
|
||||
has_tft_url = CONF_TFT_URL in config
|
||||
for conf_key in (
|
||||
@@ -81,7 +94,10 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=TimePeriod(milliseconds=255)),
|
||||
),
|
||||
cv.Optional(CONF_DUMP_DEVICE_INFO, default=False): cv.boolean,
|
||||
# Deprecated — device info is now always logged. Remove before 2026.11.0.
|
||||
cv.Optional(CONF_DUMP_DEVICE_INFO): cv.All(
|
||||
cv.boolean, _deprecated_dump_device_info
|
||||
),
|
||||
cv.Optional(CONF_EXIT_REPARSE_ON_START, default=False): cv.boolean,
|
||||
cv.Optional(CONF_MAX_QUEUE_AGE, default="8000ms"): cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
@@ -277,9 +293,6 @@ async def to_code(config):
|
||||
|
||||
cg.add(var.set_auto_wake_on_touch(config[CONF_AUTO_WAKE_ON_TOUCH]))
|
||||
|
||||
if config[CONF_DUMP_DEVICE_INFO]:
|
||||
cg.add_define("USE_NEXTION_CONFIG_DUMP_DEVICE_INFO")
|
||||
|
||||
if config[CONF_EXIT_REPARSE_ON_START]:
|
||||
cg.add_define("USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START")
|
||||
|
||||
|
||||
@@ -117,30 +117,41 @@ bool Nextion::check_connect_() {
|
||||
|
||||
ESP_LOGN(TAG, "connect: %s", response.c_str());
|
||||
|
||||
size_t start;
|
||||
// Parse comok response fields directly
|
||||
// Format: comok <touch>,<reserved>,<model>,<fw>,<mcu_code>,<serial>,<flash>
|
||||
size_t field_count = 0;
|
||||
size_t start = 0;
|
||||
size_t end = 0;
|
||||
std::vector<std::string> connect_info;
|
||||
auto copy_field = [&](char *dst, size_t cap) {
|
||||
size_t len = (end == std::string::npos ? response.size() : end) - start;
|
||||
size_t n = len < cap ? len : cap;
|
||||
std::memcpy(dst, response.data() + start, n);
|
||||
dst[n] = '\0';
|
||||
};
|
||||
while ((start = response.find_first_not_of(',', end)) != std::string::npos) {
|
||||
end = response.find(',', start);
|
||||
connect_info.push_back(response.substr(start, end - start));
|
||||
switch (field_count) {
|
||||
case 2:
|
||||
copy_field(this->device_model_, this->NEXTION_MODEL_MAX);
|
||||
break;
|
||||
case 3:
|
||||
copy_field(this->firmware_version_, this->NEXTION_FW_MAX);
|
||||
break;
|
||||
case 5:
|
||||
copy_field(this->serial_number_, this->NEXTION_SERIAL_MAX);
|
||||
break;
|
||||
case 6:
|
||||
this->flash_size_ = static_cast<uint32_t>(std::strtoul(response.data() + start, nullptr, 10));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
++field_count;
|
||||
}
|
||||
|
||||
this->is_detected_ = (connect_info.size() == 7);
|
||||
this->is_detected_ = (field_count == 7);
|
||||
if (this->is_detected_) {
|
||||
ESP_LOGN(TAG, "Connect info: %zu", connect_info.size());
|
||||
#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
|
||||
this->device_model_ = connect_info[2];
|
||||
this->firmware_version_ = connect_info[3];
|
||||
this->serial_number_ = connect_info[5];
|
||||
this->flash_size_ = connect_info[6];
|
||||
#else // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
|
||||
ESP_LOGI(TAG,
|
||||
" Device Model: %s\n"
|
||||
" FW Version: %s\n"
|
||||
" Serial Number: %s\n"
|
||||
" Flash Size: %s\n",
|
||||
connect_info[2].c_str(), connect_info[3].c_str(), connect_info[5].c_str(), connect_info[6].c_str());
|
||||
#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
|
||||
ESP_LOGN(TAG, "Connect info: %zu fields", field_count);
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Bad connect value: '%s'", response.c_str());
|
||||
}
|
||||
@@ -178,24 +189,26 @@ void Nextion::dump_config() {
|
||||
#ifdef USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE
|
||||
ESP_LOGCONFIG(TAG, " Skip handshake: YES");
|
||||
#else // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE
|
||||
#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
|
||||
if (this->is_setup()) {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Device Model: %s\n"
|
||||
" FW Version: %s\n"
|
||||
" Serial Number: %s\n"
|
||||
" Flash Size: %" PRIu32 " bytes",
|
||||
this->device_model_, this->firmware_version_, this->serial_number_, this->flash_size_);
|
||||
} else {
|
||||
ESP_LOGCONFIG(TAG, " Device info: not yet detected");
|
||||
}
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Device Model: %s\n"
|
||||
" FW Version: %s\n"
|
||||
" Serial Number: %s\n"
|
||||
" Flash Size: %s\n"
|
||||
" Max queue age: %u ms\n"
|
||||
" Startup override: %u ms\n",
|
||||
this->device_model_.c_str(), this->firmware_version_.c_str(), this->serial_number_.c_str(),
|
||||
this->flash_size_.c_str(), this->max_q_age_ms_, this->startup_override_ms_);
|
||||
#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
|
||||
#ifdef USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START
|
||||
ESP_LOGCONFIG(TAG, " Exit reparse: YES\n");
|
||||
" Exit reparse: YES\n"
|
||||
#endif // USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Max queue age: %u ms\n"
|
||||
" Startup override: %u ms\n"
|
||||
" Wake On Touch: %s\n"
|
||||
" Touch Timeout: %" PRIu16,
|
||||
YESNO(this->connection_state_.auto_wake_on_touch_), this->touch_sleep_timeout_);
|
||||
this->max_q_age_ms_, this->startup_override_ms_, YESNO(this->connection_state_.auto_wake_on_touch_),
|
||||
this->touch_sleep_timeout_);
|
||||
#endif // USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE
|
||||
|
||||
#ifdef USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||
|
||||
@@ -1610,12 +1610,15 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe
|
||||
nextion_writer_t writer_;
|
||||
optional<float> brightness_;
|
||||
|
||||
#ifdef USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
|
||||
std::string device_model_;
|
||||
std::string firmware_version_;
|
||||
std::string serial_number_;
|
||||
std::string flash_size_;
|
||||
#endif // USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
|
||||
// Device info populated from comok response (fixed-size, no heap allocation).
|
||||
// Sizes derived from Nextion Upload Protocol documentation and observed hardware.
|
||||
static constexpr size_t NEXTION_MODEL_MAX = 24; ///< Max observed ~18 chars from product numbering rules
|
||||
static constexpr size_t NEXTION_FW_MAX = 7; ///< 'S' prefix + integer (e.g. 'S99' or `123`)
|
||||
static constexpr size_t NEXTION_SERIAL_MAX = 20; ///< Consistently 16 hex chars across all documented examples
|
||||
char device_model_[NEXTION_MODEL_MAX + 1]{};
|
||||
char firmware_version_[NEXTION_FW_MAX + 1]{};
|
||||
char serial_number_[NEXTION_SERIAL_MAX + 1]{};
|
||||
uint32_t flash_size_ = 0; ///< Flash size in bytes — plain integer, no string needed
|
||||
|
||||
void remove_front_no_sensors_();
|
||||
|
||||
|
||||
@@ -35,9 +35,8 @@ 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(
|
||||
|
||||
@@ -215,7 +215,7 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
|
||||
If loading fails after cloning, attempts a revert and retry in case
|
||||
a prior cached checkout is stale.
|
||||
"""
|
||||
repo_root, revert = git.clone_or_update(
|
||||
repo_dir, revert = git.clone_or_update(
|
||||
url=config[CONF_URL],
|
||||
ref=config.get(CONF_REF),
|
||||
refresh=config[CONF_REFRESH],
|
||||
@@ -225,10 +225,6 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
|
||||
)
|
||||
files: list[dict[str, Any]] = []
|
||||
|
||||
# ``repo_root`` is the directory containing ``.git`` and must be passed
|
||||
# to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a
|
||||
# subdirectory via the user's CONF_PATH and is used for file lookups.
|
||||
repo_dir = repo_root
|
||||
if base_path := config.get(CONF_PATH):
|
||||
repo_dir = repo_dir / base_path
|
||||
|
||||
@@ -240,37 +236,13 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def _load_package_yaml(yaml_file: Path, filename: str) -> dict:
|
||||
"""Load a YAML file from a remote package, validating min_version."""
|
||||
|
||||
def _load(path: Path) -> dict | str | None:
|
||||
try:
|
||||
return yaml_util.load_yaml(path)
|
||||
except EsphomeError as e:
|
||||
raise cv.Invalid(
|
||||
f"{filename} is not a valid YAML file."
|
||||
f" Please check the file contents.\n{e}"
|
||||
) from e
|
||||
|
||||
new_yaml = _load(yaml_file)
|
||||
if not isinstance(new_yaml, dict):
|
||||
# On Windows, git defaults to core.symlinks=false unless the user
|
||||
# has Developer Mode enabled or is running elevated. Files stored
|
||||
# in the repo as symlinks (tree mode 120000) are then checked out
|
||||
# as plain text files containing the symlink target path, so
|
||||
# parsing them as YAML yields a bare scalar instead of a mapping.
|
||||
# Best-effort: follow the symlink target ourselves and re-load.
|
||||
target = git.resolve_symlink_stub(repo_root, yaml_file)
|
||||
if target is not None:
|
||||
new_yaml = _load(target)
|
||||
if not isinstance(new_yaml, dict):
|
||||
try:
|
||||
new_yaml = yaml_util.load_yaml(yaml_file)
|
||||
except EsphomeError as e:
|
||||
raise cv.Invalid(
|
||||
f"{filename} does not contain a YAML mapping at the top level "
|
||||
f"(got {type(new_yaml).__name__}). "
|
||||
f"If this file is a git symlink in the source repository, it "
|
||||
f"may not have been materialized correctly on your platform "
|
||||
f"(this is a known issue with git on Windows without Developer "
|
||||
f"Mode enabled). Try pointing your package at the real file "
|
||||
f"path instead."
|
||||
)
|
||||
f"{filename} is not a valid YAML file."
|
||||
f" Please check the file contents.\n{e}"
|
||||
) from e
|
||||
esphome_config = new_yaml.get(CONF_ESPHOME) or {}
|
||||
min_version = esphome_config.get(CONF_MIN_VERSION)
|
||||
if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse(
|
||||
|
||||
@@ -7,7 +7,6 @@ static const char *const TAG = "remote.rc5";
|
||||
|
||||
static constexpr uint32_t BIT_TIME_US = 889;
|
||||
static constexpr uint8_t NBITS = 14;
|
||||
static constexpr uint8_t NHALFBITS = NBITS * 2;
|
||||
|
||||
void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) {
|
||||
static bool toggle = false;
|
||||
@@ -36,63 +35,52 @@ void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) {
|
||||
}
|
||||
toggle = !toggle;
|
||||
}
|
||||
|
||||
optional<RC5Data> RC5Protocol::decode(RemoteReceiveData src) {
|
||||
// Expand the runs into half-bit levels (true = mark). Each run is exactly one
|
||||
// half-bit (BIT_TIME_US) or two (2 * BIT_TIME_US); stop at anything else.
|
||||
//
|
||||
// halfbits[0] is reserved for the leading half-bit, which is always dropped --
|
||||
// S1 is 1, so its first half sits at the idle level (at either polarity) and
|
||||
// merges into the pre-frame idle. Captured half-bits start at index 1.
|
||||
bool halfbits[NHALFBITS + 2];
|
||||
uint8_t n = 1;
|
||||
for (uint32_t i = 0; n <= NHALFBITS && src.is_valid(i); i++) {
|
||||
if (src.peek_mark(BIT_TIME_US, i)) {
|
||||
halfbits[n++] = true;
|
||||
} else if (src.peek_space(BIT_TIME_US, i)) {
|
||||
halfbits[n++] = false;
|
||||
} else if (src.peek_mark(2 * BIT_TIME_US, i)) {
|
||||
halfbits[n++] = true;
|
||||
halfbits[n++] = true;
|
||||
} else if (src.peek_space(2 * BIT_TIME_US, i)) {
|
||||
halfbits[n++] = false;
|
||||
halfbits[n++] = false;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
RC5Data out{
|
||||
.address = 0,
|
||||
.command = 0,
|
||||
};
|
||||
uint8_t field_bit;
|
||||
|
||||
// Expect a full frame once the leading half is restored: 27 captured halves
|
||||
// (n == 28) or 26 when the final bit also ends on idle and its trailing half
|
||||
// is dropped too (n == 27). A dropped edge half is the inverse of its partner
|
||||
// (a Manchester bit always transitions mid-bit), so reconstruct the leading
|
||||
// half (always) and the trailing half (only when it was dropped).
|
||||
if (n != NHALFBITS && n != NHALFBITS - 1) {
|
||||
if (src.expect_space(BIT_TIME_US) && src.expect_mark(BIT_TIME_US)) {
|
||||
field_bit = 1;
|
||||
} else if (src.expect_space(2 * BIT_TIME_US)) {
|
||||
field_bit = 0;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
halfbits[0] = !halfbits[1];
|
||||
if (n == NHALFBITS - 1) {
|
||||
halfbits[n] = !halfbits[n - 1];
|
||||
|
||||
if (!(((src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US)) ||
|
||||
(src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) &&
|
||||
(((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) &&
|
||||
(src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) ||
|
||||
((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) &&
|
||||
(src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US)))))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const bool carrier = halfbits[1];
|
||||
uint16_t bits = 0;
|
||||
for (uint8_t i = 0; i < NBITS; i++) {
|
||||
const bool first = halfbits[2 * i];
|
||||
const bool second = halfbits[2 * i + 1];
|
||||
if (first == second) {
|
||||
return {}; // no midpoint transition -> not a valid Manchester bit
|
||||
uint32_t out_data = 0;
|
||||
for (int bit = NBITS - 4; bit >= 1; bit--) {
|
||||
if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) &&
|
||||
(src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) {
|
||||
out_data |= 0 << bit;
|
||||
} else if ((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) &&
|
||||
(src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) {
|
||||
out_data |= 1 << bit;
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
bits = (bits << 1) | (second == carrier ? 1 : 0);
|
||||
}
|
||||
if (src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) {
|
||||
out_data |= 0;
|
||||
} else if (src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) {
|
||||
out_data |= 1;
|
||||
}
|
||||
|
||||
const bool field_bit = bits & (1 << 12); // S2: the inverted 7th command bit
|
||||
return RC5Data{
|
||||
.address = static_cast<uint8_t>((bits >> 6) & 0x1F),
|
||||
.command = static_cast<uint8_t>((bits & 0x3F) | (field_bit ? 0 : 0x40)),
|
||||
};
|
||||
out.command = (uint8_t) (out_data & 0x3F) + (1 - field_bit) * 64u;
|
||||
out.address = (out_data >> 6) & 0x1F;
|
||||
return out;
|
||||
}
|
||||
|
||||
void RC5Protocol::dump(const RC5Data &data) {
|
||||
ESP_LOGI(TAG, "Received RC5: address=0x%02X, command=0x%02X", data.address, data.command);
|
||||
}
|
||||
|
||||
@@ -402,21 +402,18 @@ def _generate_lwipopts_h() -> None:
|
||||
in the build directory, and a pre-build script injects this directory
|
||||
into the compiler include path before the framework's own include dir.
|
||||
"""
|
||||
from jinja2 import Environment
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
|
||||
lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS)
|
||||
if not lwip_defines:
|
||||
return
|
||||
|
||||
# Read the template via pathlib and render from a string rather than using
|
||||
# FileSystemLoader. jinja2's loader joins the search path with posixpath, which
|
||||
# breaks on Windows extended-length paths (\\?\C:\...) where forward slashes are
|
||||
# not accepted, causing a spurious TemplateNotFound (see issue #16732).
|
||||
template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text(
|
||||
encoding="utf-8"
|
||||
template_dir = Path(__file__).parent
|
||||
jinja_env = Environment(
|
||||
loader=FileSystemLoader(str(template_dir)),
|
||||
keep_trailing_newline=True,
|
||||
)
|
||||
jinja_env = Environment(keep_trailing_newline=True)
|
||||
template = jinja_env.from_string(template_text)
|
||||
template = jinja_env.get_template("lwipopts.h.jinja")
|
||||
content = template.render(**lwip_defines)
|
||||
|
||||
lwip_dir = CORE.relative_build_path("lwip_override")
|
||||
|
||||
@@ -96,6 +96,7 @@ 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,
|
||||
@@ -174,6 +175,7 @@ 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,
|
||||
|
||||
@@ -16,7 +16,7 @@ GPIOPin *const NullPin::NULL_PIN = new NullPin(); // NOLINT(cppcoreguidelines-a
|
||||
|
||||
SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIBitOrder bit_order, uint32_t data_rate,
|
||||
GPIOPin *cs_pin, bool release_device, bool write_only) {
|
||||
if (this->devices_.count(device) != 0) {
|
||||
if (this->devices_.contains(device)) {
|
||||
ESP_LOGE(TAG, "Device already registered");
|
||||
return this->devices_[device];
|
||||
}
|
||||
@@ -27,7 +27,7 @@ SPIDelegate *SPIComponent::register_device(SPIClient *device, SPIMode mode, SPIB
|
||||
}
|
||||
|
||||
void SPIComponent::unregister_device(SPIClient *device) {
|
||||
if (this->devices_.count(device) == 0) {
|
||||
if (!this->devices_.contains(device)) {
|
||||
esph_log_e(TAG, "Device not registered");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ void SX126x::run_image_cal() {
|
||||
buf[1] = 0xE9;
|
||||
} else if (this->frequency_ > 850000000) {
|
||||
buf[0] = 0xD7;
|
||||
buf[1] = 0xDB;
|
||||
buf[1] = 0xD8;
|
||||
} else if (this->frequency_ > 770000000) {
|
||||
buf[0] = 0xC1;
|
||||
buf[1] = 0xC5;
|
||||
|
||||
@@ -78,7 +78,7 @@ void Touchscreen::add_raw_touch_position_(uint8_t id, int16_t x_raw, int16_t y_r
|
||||
if (this->swap_x_y_) {
|
||||
std::swap(x_raw, y_raw);
|
||||
}
|
||||
if (this->touches_.count(id) == 0) {
|
||||
if (!this->touches_.contains(id)) {
|
||||
tp.state = STATE_PRESSED;
|
||||
tp.id = id;
|
||||
} else {
|
||||
|
||||
@@ -154,7 +154,7 @@ bool UponorSmatrixComponent::parse_byte_(uint8_t byte) {
|
||||
}
|
||||
|
||||
// Log unknown device addresses
|
||||
if (!found && !this->unknown_devices_.count(device_address)) {
|
||||
if (!found && !this->unknown_devices_.contains(device_address)) {
|
||||
ESP_LOGI(TAG, "Received packet for unknown device address 0x%08" PRIX32 " ", device_address);
|
||||
this->unknown_devices_.insert(device_address);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_TIME_ID,
|
||||
DEVICE_CLASS_DURATION,
|
||||
DEVICE_CLASS_TIMESTAMP,
|
||||
DEVICE_CLASS_UPTIME,
|
||||
ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
ICON_TIMER,
|
||||
STATE_CLASS_TOTAL_INCREASING,
|
||||
@@ -33,9 +33,8 @@ 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_TIMESTAMP,
|
||||
device_class=DEVICE_CLASS_UPTIME,
|
||||
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
|
||||
)
|
||||
.extend(
|
||||
|
||||
@@ -2638,9 +2638,9 @@ bool WebServer::isRequestHandlerTrivial() const { return false; }
|
||||
|
||||
void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) {
|
||||
#ifdef USE_WEBSERVER_SORTING
|
||||
if (this->sorting_entitys_.find(entity) != this->sorting_entitys_.end()) {
|
||||
if (this->sorting_entitys_.contains(entity)) {
|
||||
root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight;
|
||||
if (this->sorting_groups_.find(this->sorting_entitys_[entity].group_id) != this->sorting_groups_.end()) {
|
||||
if (this->sorting_groups_.contains(this->sorting_entitys_[entity].group_id)) {
|
||||
root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ from esphome.components.esp32 import (
|
||||
only_on_variant,
|
||||
)
|
||||
from esphome.components.network import (
|
||||
get_network_priority,
|
||||
has_high_performance_networking,
|
||||
ip_address_literal,
|
||||
)
|
||||
@@ -42,7 +43,6 @@ from esphome.const import (
|
||||
CONF_ON_CONNECT,
|
||||
CONF_ON_DISCONNECT,
|
||||
CONF_ON_ERROR,
|
||||
CONF_OUTPUT_POWER,
|
||||
CONF_PASSWORD,
|
||||
CONF_POWER_SAVE_MODE,
|
||||
CONF_PRIORITY,
|
||||
@@ -440,6 +440,7 @@ def _validate(config):
|
||||
return config
|
||||
|
||||
|
||||
CONF_OUTPUT_POWER = "output_power"
|
||||
CONF_PASSIVE_SCAN = "passive_scan"
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
@@ -573,6 +574,10 @@ 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
|
||||
|
||||
@@ -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,6 +1278,11 @@ 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();
|
||||
}
|
||||
|
||||
@@ -2193,7 +2198,15 @@ bool WiFiComponent::request_high_performance() {
|
||||
}
|
||||
|
||||
// Give the semaphore (non-blocking). This increments the count.
|
||||
return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;
|
||||
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;
|
||||
}
|
||||
|
||||
bool WiFiComponent::release_high_performance() {
|
||||
|
||||
@@ -694,6 +694,12 @@ 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 &&
|
||||
@@ -889,6 +895,12 @@ 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,23 +145,15 @@ void WiFiComponent::wifi_pre_setup_() {
|
||||
get_mac_address_raw(mac);
|
||||
set_mac_address(mac);
|
||||
}
|
||||
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;
|
||||
}
|
||||
// Network interface setup handled by network component
|
||||
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;
|
||||
err = esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &event_handler, nullptr, &instance_wifi_id);
|
||||
esp_err_t 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;
|
||||
@@ -171,6 +163,16 @@ 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();
|
||||
|
||||
@@ -183,7 +185,7 @@ void WiFiComponent::wifi_pre_setup_() {
|
||||
ESP_LOGW(TAG, "starting wifi without nvs");
|
||||
cfg.nvs_enable = false;
|
||||
}
|
||||
err = esp_wifi_init(&cfg);
|
||||
esp_err_t err = esp_wifi_init(&cfg);
|
||||
if (err != ERR_OK) {
|
||||
ESP_LOGE(TAG, "esp_wifi_init failed: %s", esp_err_to_name(err));
|
||||
return;
|
||||
@@ -193,6 +195,7 @@ void WiFiComponent::wifi_pre_setup_() {
|
||||
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
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.5.3"
|
||||
__version__ = "2026.6.0-dev"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
@@ -1367,6 +1367,7 @@ 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"
|
||||
|
||||
@@ -5,7 +5,7 @@ import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from esphome.const import (
|
||||
CONF_COMMENT,
|
||||
@@ -569,6 +569,12 @@ 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)
|
||||
@@ -634,6 +640,7 @@ 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 = {}
|
||||
|
||||
@@ -711,7 +711,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
# Process areas
|
||||
all_areas: list[dict[str, str | core.ID]] = []
|
||||
if CONF_AREA in config:
|
||||
CORE.area = config[CONF_AREA][CONF_NAME]
|
||||
all_areas.append(config[CONF_AREA])
|
||||
all_areas.extend(config[CONF_AREAS])
|
||||
|
||||
|
||||
@@ -134,7 +134,6 @@
|
||||
#define USE_MEDIA_SOURCE
|
||||
#define USE_NEXTION_COMMAND_SPACING
|
||||
#define USE_NEXTION_CONF_START_UP_PAGE
|
||||
#define USE_NEXTION_CONFIG_DUMP_DEVICE_INFO
|
||||
#define USE_NEXTION_CONFIG_EXIT_REPARSE_ON_START
|
||||
#define USE_NEXTION_CONFIG_SKIP_CONNECTION_HANDSHAKE
|
||||
#define USE_NEXTION_MAX_COMMANDS_PER_LOOP
|
||||
|
||||
+5
-1
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include "gpio.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/time_64.h"
|
||||
@@ -42,6 +43,9 @@ 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
|
||||
|
||||
@@ -27,16 +27,18 @@ _LOGGER = logging.getLogger(__name__)
|
||||
_SCRIPTS_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def _str_to_lst_of_str(a: str) -> list[str]:
|
||||
def _str_to_lst_of_str(a: str | list[str]) -> list[str]:
|
||||
"""
|
||||
Convert a string to a list of string
|
||||
|
||||
Args:
|
||||
a: A string containing semicolon-separated values
|
||||
a: A string containing semicolon-separated values, or an already-split list
|
||||
|
||||
Returns:
|
||||
list of strings
|
||||
"""
|
||||
if isinstance(a, list):
|
||||
return a
|
||||
return list(f.strip() for f in a.split(";") if f.strip())
|
||||
|
||||
|
||||
@@ -68,10 +70,11 @@ 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",
|
||||
"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")
|
||||
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",
|
||||
]
|
||||
)
|
||||
|
||||
ESP_IDF_CONSTRAINTS_MIRRORS = _str_to_lst_of_str(
|
||||
|
||||
@@ -6,7 +6,6 @@ import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
import esphome.config_validation as cv
|
||||
@@ -94,92 +93,6 @@ def _compute_destination_path(key: str, domain: str) -> Path:
|
||||
return base_dir / h.hexdigest()[:8]
|
||||
|
||||
|
||||
def resolve_symlink_stub(repo_dir: Path, file_path: Path) -> Path | None:
|
||||
"""Return the symlink target if ``file_path`` is a Windows-checked-out symlink stub.
|
||||
|
||||
On Windows, when ``core.symlinks=false`` (the default unless the user has
|
||||
SeCreateSymbolicLinkPrivilege — i.e. Developer Mode or running elevated),
|
||||
git materializes files with tree mode ``120000`` as plain text files
|
||||
whose content is the literal symlink target path. Opening such a file
|
||||
yields the target path string instead of the target's content.
|
||||
|
||||
If ``file_path`` is one of those stubs, return the resolved target Path
|
||||
inside ``repo_dir``. Otherwise return ``None`` and the caller should use
|
||||
``file_path`` as-is.
|
||||
|
||||
Designed to be called *only* when normal access has already produced an
|
||||
unexpected result (e.g. YAML parsed as a top-level scalar), so the
|
||||
per-file ``git ls-files`` subprocess cost is paid only on the failure
|
||||
path. Returns ``None`` on any error or check failure — it's purely a
|
||||
best-effort recovery, never raises.
|
||||
"""
|
||||
# On non-Windows, git creates real symlinks; ordinary file access already
|
||||
# transparently follows them.
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
if file_path.is_symlink():
|
||||
return None
|
||||
if not file_path.is_file():
|
||||
return None
|
||||
|
||||
try:
|
||||
rel = file_path.relative_to(repo_dir)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
try:
|
||||
# ``git ls-files -s <path>`` prints "<mode> <sha> <stage>\t<path>"
|
||||
# for that single entry, or empty if untracked.
|
||||
out = run_git_command(
|
||||
["git", "ls-files", "-s", "--", rel.as_posix()],
|
||||
git_dir=repo_dir,
|
||||
)
|
||||
except GitException:
|
||||
return None
|
||||
|
||||
parts = out.split()
|
||||
if not parts or parts[0] != "120000":
|
||||
return None
|
||||
|
||||
# Stubs are short ASCII relative paths. Decode defensively, and only
|
||||
# strip the trailing newline git's checkout may append — preserving any
|
||||
# whitespace that could be part of a valid target name.
|
||||
try:
|
||||
raw = file_path.read_bytes()
|
||||
except OSError:
|
||||
return None
|
||||
try:
|
||||
target_str = raw.decode("utf-8").rstrip("\r\n")
|
||||
except UnicodeDecodeError:
|
||||
return None
|
||||
|
||||
# ``Path()`` and ``Path.resolve()`` can raise on malformed inputs (e.g.
|
||||
# embedded NUL bytes from a hostile symlink blob, paths too long for the
|
||||
# OS, or temporary I/O errors). Catch broadly — this helper is purely a
|
||||
# best-effort recovery and must never raise.
|
||||
try:
|
||||
target_path = (file_path.parent / target_str).resolve()
|
||||
repo_root_resolved = repo_dir.resolve()
|
||||
except (OSError, ValueError, RuntimeError):
|
||||
return None
|
||||
|
||||
# ``Path.resolve()`` follows ``..``; re-verify containment afterwards.
|
||||
try:
|
||||
target_path.relative_to(repo_root_resolved)
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
"Refusing to follow symlink %s -> %s (escapes repository)",
|
||||
file_path,
|
||||
target_str,
|
||||
)
|
||||
return None
|
||||
|
||||
if not target_path.is_file():
|
||||
return None
|
||||
|
||||
return target_path
|
||||
|
||||
|
||||
def clone_or_update(
|
||||
*,
|
||||
url: str,
|
||||
|
||||
@@ -2,7 +2,7 @@ dependencies:
|
||||
bblanchon/arduinojson:
|
||||
version: "7.4.2"
|
||||
esphome/esp-audio-libs:
|
||||
version: 3.0.0
|
||||
version: 3.1.0
|
||||
esphome/esp-micro-speech-features:
|
||||
version: 1.2.3
|
||||
esphome/micro-decoder:
|
||||
|
||||
+3
-33
@@ -16,7 +16,7 @@ from esphome.const import (
|
||||
KEY_TARGET_PLATFORM,
|
||||
Toolchain,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import write_file_if_changed
|
||||
from esphome.types import CoreType
|
||||
|
||||
@@ -100,8 +100,6 @@ class StorageJSON:
|
||||
framework: str | None = None,
|
||||
core_platform: str | None = None,
|
||||
toolchain: str | None = None,
|
||||
area: str | None = None,
|
||||
framework_version: str | None = None,
|
||||
) -> None:
|
||||
# Version of the storage JSON schema
|
||||
assert storage_version is None or isinstance(storage_version, int)
|
||||
@@ -140,10 +138,6 @@ class StorageJSON:
|
||||
self.core_platform = core_platform
|
||||
# The toolchain used for the build ("platformio" / "esp-idf")
|
||||
self.toolchain = toolchain
|
||||
# The area of the node
|
||||
self.area = area
|
||||
# The framework version the build used (for esp32, the resolved ESP-IDF version)
|
||||
self.framework_version = framework_version
|
||||
|
||||
def as_dict(self):
|
||||
return {
|
||||
@@ -164,8 +158,6 @@ class StorageJSON:
|
||||
"framework": self.framework,
|
||||
"core_platform": self.core_platform,
|
||||
"toolchain": self.toolchain,
|
||||
"area": self.area,
|
||||
"framework_version": self.framework_version,
|
||||
}
|
||||
|
||||
def to_json(self):
|
||||
@@ -177,12 +169,10 @@ class StorageJSON:
|
||||
@staticmethod
|
||||
def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON:
|
||||
hardware = esph.target_platform.upper()
|
||||
framework_version: str | None = None
|
||||
if esph.is_esp32:
|
||||
from esphome.components import esp32
|
||||
|
||||
hardware = esp32.get_esp32_variant(esph)
|
||||
framework_version = str(esp32.idf_version())
|
||||
return StorageJSON(
|
||||
storage_version=1,
|
||||
name=esph.name,
|
||||
@@ -205,8 +195,6 @@ class StorageJSON:
|
||||
framework=esph.target_framework,
|
||||
core_platform=esph.target_platform,
|
||||
toolchain=esph.toolchain.value if esph.toolchain is not None else None,
|
||||
area=esph.area,
|
||||
framework_version=framework_version,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -255,8 +243,6 @@ class StorageJSON:
|
||||
framework = storage.get("framework")
|
||||
core_platform = storage.get("core_platform")
|
||||
toolchain = storage.get("toolchain")
|
||||
area = storage.get("area")
|
||||
framework_version = storage.get("framework_version")
|
||||
return StorageJSON(
|
||||
storage_version,
|
||||
name,
|
||||
@@ -275,8 +261,6 @@ class StorageJSON:
|
||||
framework,
|
||||
core_platform,
|
||||
toolchain,
|
||||
area,
|
||||
framework_version,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -320,24 +304,10 @@ class StorageJSON:
|
||||
# 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, KEY_IDF_VERSION
|
||||
from esphome.components.esp32.const import KEY_ESP32
|
||||
from esphome.const import KEY_VARIANT
|
||||
|
||||
esp32_data = {KEY_VARIANT: self.target_platform}
|
||||
if self.framework_version:
|
||||
import esphome.config_validation as cv
|
||||
|
||||
try:
|
||||
esp32_data[KEY_IDF_VERSION] = cv.Version.parse(
|
||||
self.framework_version
|
||||
)
|
||||
except ValueError as err:
|
||||
raise EsphomeError(
|
||||
f"Could not parse the framework version "
|
||||
f"{self.framework_version!r} from {storage_path()}. "
|
||||
f"Please clean the build files and recompile."
|
||||
) from err
|
||||
CORE.data[KEY_ESP32] = esp32_data
|
||||
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()
|
||||
|
||||
+4
-19
@@ -90,15 +90,10 @@ 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, the build
|
||||
``toolchain`` differs (e.g. switching between the PlatformIO and
|
||||
native ESP-IDF toolchains, which produce incompatible build trees),
|
||||
the ``framework`` or ``framework_version`` differs (e.g. switching
|
||||
arduino <-> esp-idf, or bumping the ESP-IDF version, which also
|
||||
produce incompatible build trees), 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.
|
||||
``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
|
||||
@@ -114,12 +109,6 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
|
||||
return True
|
||||
if old.build_path != new.build_path:
|
||||
return True
|
||||
if old.toolchain != new.toolchain:
|
||||
return True
|
||||
if old.framework != new.framework:
|
||||
return True
|
||||
if old.framework_version != new.framework_version:
|
||||
return True
|
||||
# Check if any components have been removed
|
||||
return bool(old.loaded_integrations - new.loaded_integrations)
|
||||
|
||||
@@ -516,10 +505,6 @@ def clean_build(clear_pio_cache: bool = True):
|
||||
if dependencies_lock.is_file():
|
||||
_LOGGER.info("Deleting %s", dependencies_lock)
|
||||
dependencies_lock.unlink()
|
||||
idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
|
||||
if idedata_cache.is_file():
|
||||
_LOGGER.info("Deleting %s", idedata_cache)
|
||||
idedata_cache.unlink()
|
||||
# Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir
|
||||
# and the Component Manager's fetched managed components live under
|
||||
# the project's build path, not under .pioenvs / .piolibdeps.
|
||||
|
||||
+152
-2
@@ -2,6 +2,7 @@ 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
|
||||
@@ -233,6 +234,130 @@ 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):
|
||||
@@ -643,10 +768,35 @@ def _load_yaml_internal_with_type(
|
||||
content: TextIOWrapper,
|
||||
yaml_loader: Callable[[Path], dict[str, Any]],
|
||||
) -> Any:
|
||||
"""Load a YAML file."""
|
||||
"""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.
|
||||
"""
|
||||
loader = loader_type(content, fname, yaml_loader)
|
||||
try:
|
||||
return loader.get_single_data() or OrderedDict()
|
||||
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()
|
||||
except yaml.YAMLError as exc:
|
||||
raise EsphomeError(exc) from exc
|
||||
finally:
|
||||
|
||||
+2
-2
@@ -19,12 +19,12 @@ 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.1
|
||||
resvg-py==0.3.2
|
||||
freetype-py==2.5.1
|
||||
jinja2==3.1.6
|
||||
bleak==2.1.1
|
||||
smpclient==6.0.0
|
||||
requests==2.34.1
|
||||
requests==2.34.2
|
||||
|
||||
# esp-idf >= 5.0 requires this
|
||||
pyparsing >= 3.3.2
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pylint==4.0.5
|
||||
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.15.12 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.15.14 # 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.1
|
||||
pytest-codspeed==5.0.3
|
||||
|
||||
# Used by the import-time regression check (.github/workflows/ci.yml → import-time job)
|
||||
importtime-waterfall==1.0.0
|
||||
|
||||
+139
-18
@@ -5,6 +5,7 @@ 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,
|
||||
@@ -22,6 +23,11 @@ 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
|
||||
@@ -712,6 +718,69 @@ 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))
|
||||
@@ -1062,22 +1131,52 @@ def main() -> None:
|
||||
parser.add_argument(
|
||||
"-b", "--branch", help="Branch to compare changed files against"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force-all",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Force every job to run regardless of what changed. Used by CI "
|
||||
"when the ci-run-all label is applied to a PR (escape hatch for "
|
||||
"changes that need full-matrix validation but don't touch enough "
|
||||
"files to trigger it organically)."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine what should run
|
||||
integration_run_all, integration_test_files = determine_integration_tests(
|
||||
args.branch
|
||||
# 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
|
||||
run_clang_format = True
|
||||
run_python_linters = True
|
||||
run_import_time = True
|
||||
run_device_builder = True
|
||||
native_idf_components = sorted(NATIVE_IDF_TEST_COMPONENTS)
|
||||
run_native_idf = True
|
||||
else:
|
||||
integration_run_all, integration_test_files = determine_integration_tests(
|
||||
args.branch
|
||||
)
|
||||
run_clang_tidy = should_run_clang_tidy(args.branch)
|
||||
run_clang_format = should_run_clang_format(args.branch)
|
||||
run_python_linters = should_run_python_linters(args.branch)
|
||||
run_import_time = should_run_import_time(args.branch)
|
||||
run_device_builder = should_run_device_builder(args.branch)
|
||||
native_idf_components = native_idf_components_to_test(args.branch)
|
||||
run_native_idf = bool(native_idf_components)
|
||||
run_integration, integration_test_buckets = _compute_integration_test_buckets(
|
||||
integration_run_all, integration_test_files
|
||||
)
|
||||
run_clang_tidy = should_run_clang_tidy(args.branch)
|
||||
run_clang_format = should_run_clang_format(args.branch)
|
||||
run_python_linters = should_run_python_linters(args.branch)
|
||||
run_import_time = should_run_import_time(args.branch)
|
||||
run_device_builder = should_run_device_builder(args.branch)
|
||||
native_idf_components = native_idf_components_to_test(args.branch)
|
||||
run_native_idf = bool(native_idf_components)
|
||||
changed_cpp_file_count = count_changed_cpp_files(args.branch)
|
||||
|
||||
# Get changed components
|
||||
@@ -1106,11 +1205,27 @@ def main() -> None:
|
||||
changed_components = changed_components_result
|
||||
is_core_change = False
|
||||
|
||||
# Filter to only components that have test files
|
||||
# Components without tests shouldn't generate CI test jobs
|
||||
changed_components_with_tests = [
|
||||
component for component in changed_components if _component_has_tests(component)
|
||||
]
|
||||
if args.force_all:
|
||||
# Force every component with tests into the CI matrix. Each disk entry
|
||||
# under tests/components/<name> is treated as a component; filtered
|
||||
# below by _component_has_tests so components without YAML tests are
|
||||
# still excluded.
|
||||
tests_root = Path(root_path) / ESPHOME_TESTS_COMPONENTS_PATH
|
||||
all_components = sorted(d.name for d in tests_root.iterdir() if d.is_dir())
|
||||
changed_components_with_tests = [
|
||||
component for component in all_components if _component_has_tests(component)
|
||||
]
|
||||
# Treat as a core change so downstream logic (clang-tidy full scan,
|
||||
# dep expansion) sees the same world as when esphome/core/ changes.
|
||||
is_core_change = True
|
||||
else:
|
||||
# Filter to only components that have test files
|
||||
# Components without tests shouldn't generate CI test jobs
|
||||
changed_components_with_tests = [
|
||||
component
|
||||
for component in changed_components
|
||||
if _component_has_tests(component)
|
||||
]
|
||||
|
||||
# Get directly changed components with tests (for isolated testing)
|
||||
# These will be tested WITHOUT --testing-mode in CI to enable full validation
|
||||
@@ -1143,8 +1258,10 @@ def main() -> None:
|
||||
memory_impact = detect_memory_impact_config(args.branch)
|
||||
|
||||
# Determine clang-tidy mode based on actual files that will be checked
|
||||
is_full_scan = False
|
||||
if run_clang_tidy:
|
||||
# Full scan needed if: hash changed OR core files changed
|
||||
# (is_core_change is forced True under --force-all)
|
||||
is_full_scan = _is_clang_tidy_full_scan() or is_core_change
|
||||
|
||||
if is_full_scan:
|
||||
@@ -1177,10 +1294,12 @@ def main() -> None:
|
||||
|
||||
# Build output
|
||||
# Determine which C++ unit tests to run
|
||||
cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch)
|
||||
|
||||
# Determine if benchmarks should run
|
||||
run_benchmarks = should_run_benchmarks(args.branch)
|
||||
if args.force_all:
|
||||
cpp_run_all, cpp_components = True, []
|
||||
run_benchmarks = True
|
||||
else:
|
||||
cpp_run_all, cpp_components = determine_cpp_unit_tests(args.branch)
|
||||
run_benchmarks = should_run_benchmarks(args.branch)
|
||||
|
||||
# Split components into batches for CI testing
|
||||
# This intelligently groups components with similar bus configurations
|
||||
@@ -1215,10 +1334,12 @@ 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,
|
||||
"clang_tidy_mode": clang_tidy_mode,
|
||||
"clang_tidy_full_scan": is_full_scan,
|
||||
"clang_format": run_clang_format,
|
||||
"python_linters": run_python_linters,
|
||||
"import_time": run_import_time,
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Tests for esp32_ble_server configuration helpers."""
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32_ble_server import (
|
||||
CCCD_DESCRIPTOR_UUID,
|
||||
CUD_DESCRIPTOR_UUID,
|
||||
DEVICE_INFORMATION_SERVICE_UUID,
|
||||
uuid_is,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"uuid",
|
||||
[
|
||||
DEVICE_INFORMATION_SERVICE_UUID, # int form (cv.hex_uint32_t)
|
||||
"180A", # 16 bit short form (bt_uuid)
|
||||
"180a", # lowercase is normalized by bt_uuid but guard anyway
|
||||
"0000180A", # 32 bit form
|
||||
"0000180A-0000-1000-8000-00805F9B34FB", # full 128 bit form
|
||||
],
|
||||
)
|
||||
def test_uuid_is_matches_all_representations(uuid) -> None:
|
||||
"""All representations of the same 16 bit UUID must compare equal."""
|
||||
assert uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"uuid",
|
||||
[
|
||||
0x1818, # Cycling Power Service (different int)
|
||||
"1818", # different 16 bit short form
|
||||
"0000180B", # adjacent UUID
|
||||
"0000180A-0000-1000-8000-00805F9B34FC", # wrong base UUID suffix
|
||||
],
|
||||
)
|
||||
def test_uuid_is_rejects_other_uuids(uuid) -> None:
|
||||
"""A different UUID must not be mistaken for the device information service."""
|
||||
assert not uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("uuid16", [CUD_DESCRIPTOR_UUID, CCCD_DESCRIPTOR_UUID])
|
||||
def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None:
|
||||
"""Reserved descriptor UUIDs match whether given as int or short string."""
|
||||
assert uuid_is(uuid16, uuid16)
|
||||
assert uuid_is(f"{uuid16:04X}", uuid16)
|
||||
assert uuid_is(f"{uuid16:08X}", uuid16)
|
||||
@@ -9,13 +9,17 @@ 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_ref
|
||||
from esphome.components.light.automation import (
|
||||
_record_effect_cycle_ref,
|
||||
_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
|
||||
@@ -215,6 +219,111 @@ 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 ---
|
||||
|
||||
|
||||
@@ -278,3 +387,19 @@ 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"]
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""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
|
||||
@@ -0,0 +1,53 @@
|
||||
"""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,6 +1,8 @@
|
||||
<<: !include common.yaml
|
||||
|
||||
esp32_ble_tracker:
|
||||
|
||||
esp32_ble:
|
||||
max_connections: 9
|
||||
|
||||
bluetooth_proxy:
|
||||
|
||||
@@ -103,6 +103,16 @@ esphome:
|
||||
- light.turn_on:
|
||||
id: test_monochromatic_light
|
||||
effect: !lambda 'return iteration > 1 ? "Strobe" : "none";'
|
||||
# Cycle through configured effects (skip "None")
|
||||
- light.effect.next: test_monochromatic_light
|
||||
- light.effect.previous: test_monochromatic_light
|
||||
# Cycle through effects including "None"
|
||||
- light.effect.next:
|
||||
id: test_monochromatic_light
|
||||
include_none: true
|
||||
- light.effect.previous:
|
||||
id: test_monochromatic_light
|
||||
include_none: true
|
||||
- light.dim_relative:
|
||||
id: test_monochromatic_light
|
||||
relative_brightness: 5%
|
||||
|
||||
@@ -276,7 +276,6 @@ display:
|
||||
auto_wake_on_touch: true
|
||||
brightness: 80%
|
||||
command_spacing: 5ms
|
||||
dump_device_info: true
|
||||
exit_reparse_on_start: true
|
||||
lambda: |-
|
||||
ESP_LOGD("display","Display is being tested!");
|
||||
|
||||
@@ -775,6 +775,88 @@ def test_should_run_import_time_with_branch() -> None:
|
||||
mock_changed.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("path", "expected_result"),
|
||||
[
|
||||
# Exact-file matches in the CI-irrelevant set.
|
||||
(".yamllint", True),
|
||||
(".github/dependabot.yml", True),
|
||||
# Other top-level workflow files are irrelevant; ci.yml itself is not.
|
||||
(".github/workflows/codeql.yml", True),
|
||||
(".github/workflows/release.yml", True),
|
||||
(".github/workflows/ci.yml", False),
|
||||
# Nested files under workflows/ are not matched by the single-star glob.
|
||||
(".github/workflows/matchers/gcc.json", False),
|
||||
# build-image action: direct children only (single-star glob).
|
||||
(".github/actions/build-image/action.yml", True),
|
||||
(".github/actions/build-image/nested/file.yml", False),
|
||||
# Other actions are CI-relevant.
|
||||
(".github/actions/restore-python/action.yml", False),
|
||||
# docker/** covers everything under docker/.
|
||||
("docker/Dockerfile", True),
|
||||
("docker/scripts/run.sh", True),
|
||||
# Regular source files are CI-relevant.
|
||||
("esphome/__main__.py", False),
|
||||
("esphome/components/wifi/wifi_component.cpp", False),
|
||||
("README.md", False),
|
||||
("tests/script/test_determine_jobs.py", False),
|
||||
],
|
||||
)
|
||||
def test_is_ci_irrelevant_path(path: str, expected_result: bool) -> None:
|
||||
"""Test _is_ci_irrelevant_path mirrors the historic ci.yml path filter."""
|
||||
assert determine_jobs._is_ci_irrelevant_path(path) == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
# Empty diffs default to True — don't accidentally skip CI on a
|
||||
# broken probe.
|
||||
([], True),
|
||||
# Any CI-relevant file flips the result to True.
|
||||
(["esphome/__main__.py"], True),
|
||||
(["esphome/components/wifi/wifi_component.cpp"], True),
|
||||
(["README.md"], True),
|
||||
# All-irrelevant diffs return False.
|
||||
([".github/workflows/codeql.yml"], False),
|
||||
(
|
||||
[".github/workflows/codeql.yml", ".github/workflows/release.yml"],
|
||||
False,
|
||||
),
|
||||
([".yamllint"], False),
|
||||
([".github/dependabot.yml"], False),
|
||||
(["docker/Dockerfile"], False),
|
||||
(
|
||||
[
|
||||
".github/workflows/codeql.yml",
|
||||
".github/dependabot.yml",
|
||||
"docker/Dockerfile",
|
||||
],
|
||||
False,
|
||||
),
|
||||
# Mixed diffs always trigger CI.
|
||||
(
|
||||
[".github/workflows/codeql.yml", "esphome/__main__.py"],
|
||||
True,
|
||||
),
|
||||
# ci.yml itself is treated as CI-relevant.
|
||||
([".github/workflows/ci.yml"], True),
|
||||
],
|
||||
)
|
||||
def test_should_run_core_ci(changed_files: list[str], expected_result: bool) -> None:
|
||||
"""Test should_run_core_ci function."""
|
||||
with patch.object(determine_jobs, "changed_files", return_value=changed_files):
|
||||
assert determine_jobs.should_run_core_ci() == expected_result
|
||||
|
||||
|
||||
def test_should_run_core_ci_with_branch() -> None:
|
||||
"""Test should_run_core_ci passes the branch through to changed_files."""
|
||||
with patch.object(determine_jobs, "changed_files") as mock_changed:
|
||||
mock_changed.return_value = []
|
||||
determine_jobs.should_run_core_ci("release")
|
||||
mock_changed.assert_called_once_with("release")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changed_files", "expected_result"),
|
||||
[
|
||||
@@ -1518,6 +1600,7 @@ def test_clang_tidy_mode_full_scan(
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
mock_changed_files: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -1529,6 +1612,9 @@ def test_clang_tidy_mode_full_scan(
|
||||
mock_should_run_clang_tidy.return_value = True
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
# Without this mock, main() runs the real determine_cpp_unit_tests
|
||||
# which loads the full component graph (~5s import of every component).
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# Mock changed_files to return no component files
|
||||
mock_changed_files.return_value = []
|
||||
@@ -1584,6 +1670,7 @@ def test_clang_tidy_mode_targeted_scan(
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
mock_changed_files: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -1595,6 +1682,9 @@ def test_clang_tidy_mode_targeted_scan(
|
||||
mock_should_run_clang_tidy.return_value = True
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
# Without this mock, main() runs the real determine_cpp_unit_tests
|
||||
# which loads the full component graph (~5s import of every component).
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
|
||||
# Create component names
|
||||
components = [f"comp{i}" for i in range(component_count)]
|
||||
@@ -2602,3 +2692,151 @@ def test_main_validate_only_excludes_transitive_components(
|
||||
# Only foo (directly changed, validate-only). bar is a transitive dep
|
||||
# and still needs compile despite no source change of its own.
|
||||
assert output["validate_only_components"] == ["foo"]
|
||||
|
||||
|
||||
def test_main_force_all_overrides_detection(
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_native_idf_components_to_test: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
mock_changed_files: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""--force-all bypasses per-feature detection and runs every job.
|
||||
|
||||
Detection mocks all return False/empty (which would normally skip
|
||||
everything) -- the flag must override them. Also verifies clang-tidy
|
||||
goes to ``split`` (full scan) and the component-test matrix is
|
||||
populated from disk rather than from changed-files.
|
||||
"""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_should_run_device_builder.return_value = False
|
||||
mock_native_idf_components_to_test.return_value = []
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
mock_changed_files.return_value = []
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["determine-jobs.py", "--force-all"]),
|
||||
patch.object(determine_jobs, "get_changed_components", return_value=[]),
|
||||
patch.object(
|
||||
determine_jobs, "filter_component_and_test_files", return_value=False
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs, "get_components_with_dependencies", return_value=[]
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"detect_memory_impact_config",
|
||||
return_value={"should_run": "false"},
|
||||
),
|
||||
patch.object(determine_jobs, "should_run_benchmarks", return_value=False),
|
||||
# create_intelligent_batches scans every tests/components/<name>/*.yaml
|
||||
# under --force-all (~2500 YAML loads, ~10s in CI). This test only
|
||||
# asserts that main() routes to it and returns non-empty -- the
|
||||
# batching logic itself has its own dedicated tests.
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"create_intelligent_batches",
|
||||
return_value=([["fake_batch"]], None),
|
||||
),
|
||||
):
|
||||
determine_jobs.main()
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
|
||||
assert output["integration_tests"] is True
|
||||
assert output["clang_tidy"] is True
|
||||
assert output["clang_tidy_mode"] == "split"
|
||||
assert output["clang_tidy_full_scan"] is True
|
||||
assert output["clang_format"] is True
|
||||
assert output["python_linters"] is True
|
||||
assert output["import_time"] is True
|
||||
assert output["device_builder"] is True
|
||||
assert output["native_idf"] is True
|
||||
# native_idf_components is a CSV of NATIVE_IDF_TEST_COMPONENTS
|
||||
assert "esp32" in output["native_idf_components"].split(",")
|
||||
assert output["cpp_unit_tests_run_all"] is True
|
||||
assert output["cpp_unit_tests_components"] == []
|
||||
assert output["benchmarks"] is True
|
||||
# Detection helpers must not be consulted when --force-all is set
|
||||
mock_determine_integration_tests.assert_not_called()
|
||||
mock_should_run_clang_tidy.assert_not_called()
|
||||
mock_should_run_clang_format.assert_not_called()
|
||||
mock_should_run_python_linters.assert_not_called()
|
||||
mock_should_run_import_time.assert_not_called()
|
||||
mock_should_run_device_builder.assert_not_called()
|
||||
mock_native_idf_components_to_test.assert_not_called()
|
||||
mock_determine_cpp_unit_tests.assert_not_called()
|
||||
# Component matrix is populated from disk (tests/components/ in the repo)
|
||||
assert output["component_test_count"] > 0
|
||||
assert len(output["component_test_batches"]) > 0
|
||||
|
||||
|
||||
def test_main_force_all_off_uses_detection(
|
||||
mock_determine_integration_tests: Mock,
|
||||
mock_should_run_clang_tidy: Mock,
|
||||
mock_should_run_clang_format: Mock,
|
||||
mock_should_run_python_linters: Mock,
|
||||
mock_should_run_import_time: Mock,
|
||||
mock_should_run_device_builder: Mock,
|
||||
mock_native_idf_components_to_test: Mock,
|
||||
mock_determine_cpp_unit_tests: Mock,
|
||||
mock_changed_files: Mock,
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Without --force-all, detection helpers drive the decision (regression guard)."""
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
|
||||
mock_determine_integration_tests.return_value = (False, [])
|
||||
mock_should_run_clang_tidy.return_value = False
|
||||
mock_should_run_clang_format.return_value = False
|
||||
mock_should_run_python_linters.return_value = False
|
||||
mock_should_run_import_time.return_value = False
|
||||
mock_should_run_device_builder.return_value = False
|
||||
mock_native_idf_components_to_test.return_value = []
|
||||
mock_determine_cpp_unit_tests.return_value = (False, [])
|
||||
mock_changed_files.return_value = []
|
||||
|
||||
with (
|
||||
patch("sys.argv", ["determine-jobs.py"]),
|
||||
patch.object(determine_jobs, "get_changed_components", return_value=[]),
|
||||
patch.object(
|
||||
determine_jobs, "filter_component_and_test_files", return_value=False
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs, "get_components_with_dependencies", return_value=[]
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs,
|
||||
"detect_memory_impact_config",
|
||||
return_value={"should_run": "false"},
|
||||
),
|
||||
patch.object(
|
||||
determine_jobs, "create_intelligent_batches", return_value=([], {})
|
||||
),
|
||||
patch.object(determine_jobs, "should_run_benchmarks", return_value=False),
|
||||
):
|
||||
determine_jobs.main()
|
||||
|
||||
output = json.loads(capsys.readouterr().out)
|
||||
|
||||
assert output["integration_tests"] is False
|
||||
assert output["clang_tidy"] is False
|
||||
assert output["clang_format"] is False
|
||||
assert output["python_linters"] is False
|
||||
assert output["native_idf"] is False
|
||||
assert output["component_test_count"] == 0
|
||||
mock_determine_integration_tests.assert_called_once()
|
||||
mock_should_run_clang_tidy.assert_called_once()
|
||||
|
||||
@@ -4,6 +4,7 @@ esphome:
|
||||
|
||||
esp32:
|
||||
board: esp32-c6-devkitc-1
|
||||
flash_size: 8MB
|
||||
framework:
|
||||
type: esp-idf
|
||||
|
||||
|
||||
@@ -140,33 +140,6 @@ def test_multiple_areas_and_devices(yaml_file: Callable[[str], str]) -> None:
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
|
||||
@pytest.mark.parametrize(
|
||||
("fixture", "expected_area"),
|
||||
[
|
||||
("legacy_string_area.yaml", "Living Room"),
|
||||
("multiple_areas_devices.yaml", "Main Area"),
|
||||
],
|
||||
)
|
||||
async def test_to_code_records_core_area(
|
||||
yaml_file: Callable[[str], Path],
|
||||
fixture: str,
|
||||
expected_area: str,
|
||||
) -> None:
|
||||
"""``to_code`` records the node's area name on CORE for StorageJSON."""
|
||||
result = load_config_from_fixture(yaml_file, fixture, FIXTURES_DIR)
|
||||
assert result is not None
|
||||
assert CORE.area is None
|
||||
|
||||
with patch("esphome.core.config.cg") as mock_cg:
|
||||
mock_cg.RawStatement.side_effect = lambda *args, **kwargs: MagicMock()
|
||||
mock_cg.RawExpression.side_effect = lambda *args, **kwargs: MagicMock()
|
||||
await config.to_code(result[CONF_ESPHOME])
|
||||
|
||||
assert CORE.area == expected_area
|
||||
|
||||
|
||||
def test_legacy_string_area(
|
||||
yaml_file: Callable[[str], str], caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
|
||||
@@ -22,13 +22,13 @@ from esphome.bundle import (
|
||||
_add_bytes_to_tar,
|
||||
_default_target_dir,
|
||||
_find_used_secret_keys,
|
||||
_force_load_include_files,
|
||||
extract_bundle,
|
||||
is_bundle_path,
|
||||
prepare_bundle_for_compile,
|
||||
read_bundle_manifest,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.yaml_util import force_load_include_files
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
@@ -947,7 +947,7 @@ def test_discover_files_nested_include_load_failure(
|
||||
paths = [f.path for f in files]
|
||||
assert "test.yaml" in paths
|
||||
assert any(
|
||||
"failed to load !include" in r.message and "missing.yaml" in r.message
|
||||
"failed to load !include" in r.message.lower() and "missing.yaml" in r.message
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
@@ -974,8 +974,8 @@ def test_force_load_skips_duplicate_include_file() -> None:
|
||||
# Same instance appears twice — second visit must hit the _seen guard.
|
||||
tree = {"a": stub, "b": [stub]}
|
||||
|
||||
with patch("esphome.bundle.yaml_util.IncludeFile", _StubInclude):
|
||||
_force_load_include_files(tree)
|
||||
with patch("esphome.yaml_util.IncludeFile", _StubInclude):
|
||||
force_load_include_files(tree)
|
||||
|
||||
assert stub.load_calls == 1
|
||||
|
||||
@@ -989,8 +989,8 @@ def test_force_load_handles_cyclic_containers() -> None:
|
||||
cyclic_list.append(cyclic_list)
|
||||
|
||||
# Should return without recursing forever
|
||||
_force_load_include_files(cyclic_dict)
|
||||
_force_load_include_files(cyclic_list)
|
||||
force_load_include_files(cyclic_dict)
|
||||
force_load_include_files(cyclic_list)
|
||||
|
||||
|
||||
def test_discover_files_yaml_reload_failure(
|
||||
|
||||
@@ -56,12 +56,3 @@ def test_get_esphome_esp_idf_paths_no_override():
|
||||
) as mock_install:
|
||||
toolchain._get_esphome_esp_idf_paths("5.5.4")
|
||||
mock_install.assert_called_once_with("5.5.4", source_url=None)
|
||||
|
||||
|
||||
def test_get_core_framework_version_from_core_data():
|
||||
"""The version is read from CORE.data when validation populated it."""
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
|
||||
import esphome.config_validation as cv
|
||||
|
||||
CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}}
|
||||
assert toolchain._get_core_framework_version() == "5.5.4"
|
||||
|
||||
@@ -4,7 +4,7 @@ from datetime import datetime, timedelta
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1001,304 +1001,3 @@ def test_refresh_picks_up_new_remote_commits(
|
||||
"--hard",
|
||||
"old_sha",
|
||||
]
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_on_non_windows(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""On non-Windows, resolve_symlink_stub returns None without calling git."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
stub = repo_dir / "file.yaml"
|
||||
stub.write_text("static/file.yaml")
|
||||
|
||||
with patch("esphome.git.sys.platform", "linux"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
mock_run_git_command.assert_not_called()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_target_for_mode_120000(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A mode-120000 file is recognised as a stub; its target Path is returned."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / "static").mkdir()
|
||||
|
||||
target = repo_dir / "static" / "real.yaml"
|
||||
target.write_text("esphome:\n name: real\n")
|
||||
|
||||
stub = repo_dir / "real.yaml"
|
||||
stub.write_text("static/real.yaml")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\treal.yaml"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result == target.resolve()
|
||||
# Stub file itself was not modified — only inspected.
|
||||
assert stub.read_text() == "static/real.yaml"
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_resolves_relative_parent_paths(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""Symlink targets with ``..`` segments resolve correctly within the repo."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
(repo_dir / "subdir").mkdir(parents=True)
|
||||
(repo_dir / "static").mkdir()
|
||||
|
||||
target = repo_dir / "static" / "shared.yaml"
|
||||
target.write_text("shared content")
|
||||
|
||||
stub = repo_dir / "subdir" / "shared.yaml"
|
||||
stub.write_text("../static/shared.yaml")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tsubdir/shared.yaml"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result == target.resolve()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_refuses_escape_outside_repo(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A symlink pointing outside the repository is not followed."""
|
||||
outside = tmp_path / "outside.yaml"
|
||||
outside.write_text("sensitive")
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "escape.yaml"
|
||||
stub.write_text("../outside.yaml")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tescape.yaml"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_for_real_symlink(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A real symlink already opens transparently, so the helper short-circuits.
|
||||
|
||||
Skipped on Windows where symlink creation requires
|
||||
SeCreateSymbolicLinkPrivilege.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
pytest.skip("Requires symlink-creation privilege on Windows")
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
target = repo_dir / "real.yaml"
|
||||
target.write_text("real content")
|
||||
|
||||
real_link = repo_dir / "link.yaml"
|
||||
real_link.symlink_to("real.yaml")
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, real_link)
|
||||
|
||||
assert result is None
|
||||
# No git call needed for real symlinks.
|
||||
mock_run_git_command.assert_not_called()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_for_regular_file(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A regular file (mode 100644) whose content looks path-shaped is not
|
||||
followed."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
regular = repo_dir / "looks_like_path.txt"
|
||||
regular.write_text("static/something.yaml")
|
||||
|
||||
mock_run_git_command.return_value = "100644 abc123 0\tlooks_like_path.txt"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, regular)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_git_fails(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""If ``git ls-files`` fails (e.g. not a repo), the helper returns None."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "real.yaml"
|
||||
stub.write_text("static/real.yaml")
|
||||
|
||||
mock_run_git_command.side_effect = GitCommandError("ls-files exploded")
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_for_non_utf8_content(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A file whose bytes are not valid UTF-8 must not raise — return None."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "binary.bin"
|
||||
stub.write_bytes(b"\xff\xfe\x00\xff")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tbinary.bin"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_preserves_whitespace_in_target(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""Only trailing CR/LF is stripped — internal whitespace is preserved."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
target_dir = repo_dir / "dir with spaces"
|
||||
target_dir.mkdir()
|
||||
target = target_dir / "real.yaml"
|
||||
target.write_text("hello")
|
||||
|
||||
stub = repo_dir / "link.yaml"
|
||||
# Trailing newline (as git's checkout may append) is stripped, but
|
||||
# whitespace inside the target path itself must survive.
|
||||
stub.write_bytes(b"dir with spaces/real.yaml\n")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tlink.yaml"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result == target.resolve()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_for_directory_target(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A symlink pointing at a directory has no file content to load."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / "dir_target").mkdir()
|
||||
|
||||
stub = repo_dir / "link_to_dir"
|
||||
stub.write_text("dir_target")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tlink_to_dir"
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_resolve_raises(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""Path.resolve() raising (e.g. on a malformed target) must not propagate."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "broken.yaml"
|
||||
stub.write_text("ignored")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tbroken.yaml"
|
||||
|
||||
with (
|
||||
patch("esphome.git.sys.platform", "win32"),
|
||||
patch.object(Path, "resolve", side_effect=OSError("bad path")),
|
||||
):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_file_missing(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A file path that doesn't exist is rejected before git is consulted."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
missing = repo_dir / "ghost.yaml" # not created
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, missing)
|
||||
|
||||
assert result is None
|
||||
mock_run_git_command.assert_not_called()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_path_outside_repo(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""A file path that isn't under repo_dir is rejected (ValueError from relative_to)."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
outside = tmp_path / "stray.yaml"
|
||||
outside.write_text("something")
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, outside)
|
||||
|
||||
assert result is None
|
||||
mock_run_git_command.assert_not_called()
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_untracked(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""Empty `git ls-files` output (untracked file) makes the helper return None."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "untracked.yaml"
|
||||
stub.write_text("static/foo.yaml")
|
||||
|
||||
mock_run_git_command.return_value = ""
|
||||
|
||||
with patch("esphome.git.sys.platform", "win32"):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_symlink_stub_returns_none_when_read_bytes_raises(
|
||||
tmp_path: Path, mock_run_git_command: Mock
|
||||
) -> None:
|
||||
"""An OSError from read_bytes() (e.g. file vanished mid-call) must not propagate."""
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
|
||||
stub = repo_dir / "racy.yaml"
|
||||
stub.write_text("static/racy.yaml")
|
||||
|
||||
mock_run_git_command.return_value = "120000 abc123 0\tracy.yaml"
|
||||
|
||||
with (
|
||||
patch("esphome.git.sys.platform", "win32"),
|
||||
patch.object(Path, "read_bytes", side_effect=OSError("vanished")),
|
||||
):
|
||||
result = git.resolve_symlink_stub(repo_dir, stub)
|
||||
|
||||
assert result is None
|
||||
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv, storage_json
|
||||
from esphome import storage_json
|
||||
from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain
|
||||
from esphome.core import CORE
|
||||
|
||||
@@ -205,8 +205,6 @@ def test_storage_json_as_dict() -> None:
|
||||
no_mdns=True,
|
||||
framework="arduino",
|
||||
core_platform="esp32",
|
||||
area="Living Room",
|
||||
framework_version="5.3.1",
|
||||
)
|
||||
|
||||
result = storage.as_dict()
|
||||
@@ -235,8 +233,6 @@ def test_storage_json_as_dict() -> None:
|
||||
assert result["no_mdns"] is True
|
||||
assert result["framework"] == "arduino"
|
||||
assert result["core_platform"] == "esp32"
|
||||
assert result["area"] == "Living Room"
|
||||
assert result["framework_version"] == "5.3.1"
|
||||
|
||||
|
||||
def test_storage_json_to_json() -> None:
|
||||
@@ -313,14 +309,9 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
|
||||
mock_core.config = {CONF_MDNS: {CONF_DISABLED: True}}
|
||||
mock_core.target_framework = "esp-idf"
|
||||
mock_core.toolchain = Toolchain.ESP_IDF
|
||||
mock_core.area = "Living Room"
|
||||
|
||||
with (
|
||||
patch("esphome.components.esp32.get_esp32_variant") as mock_variant,
|
||||
patch("esphome.components.esp32.idf_version") as mock_idf_version,
|
||||
):
|
||||
with patch("esphome.components.esp32.get_esp32_variant") as mock_variant:
|
||||
mock_variant.return_value = "ESP32-C3"
|
||||
mock_idf_version.return_value = cv.Version(5, 3, 1)
|
||||
|
||||
result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None)
|
||||
|
||||
@@ -338,8 +329,6 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
|
||||
assert result.framework == "esp-idf"
|
||||
assert result.core_platform == "esp32"
|
||||
assert result.toolchain == "esp-idf"
|
||||
assert result.area == "Living Room"
|
||||
assert result.framework_version == "5.3.1"
|
||||
|
||||
|
||||
def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
|
||||
@@ -552,51 +541,6 @@ def test_storage_json_apply_to_core_ignores_unknown_toolchain(
|
||||
assert CORE.toolchain is None
|
||||
|
||||
|
||||
def test_storage_json_framework_version_round_trip(setup_core: Path) -> None:
|
||||
"""Sidecar framework_version restores CORE.data[esp32][idf_version]."""
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
|
||||
|
||||
storage = _make_storage_with_toolchain("esp-idf")
|
||||
storage.framework_version = "5.3.1"
|
||||
path = setup_core / "storage.json"
|
||||
path.write_text(storage.to_json())
|
||||
|
||||
assert json.loads(path.read_text())["framework_version"] == "5.3.1"
|
||||
|
||||
loaded = storage_json.StorageJSON.load(path)
|
||||
assert loaded is not None
|
||||
assert loaded.framework_version == "5.3.1"
|
||||
|
||||
loaded.apply_to_core()
|
||||
assert CORE.data[KEY_ESP32][KEY_IDF_VERSION] == cv.Version(5, 3, 1)
|
||||
|
||||
|
||||
def test_storage_json_apply_to_core_without_framework_version(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""Older sidecars lacking framework_version don't populate idf_version."""
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
|
||||
|
||||
loaded = _make_storage_with_toolchain("esp-idf")
|
||||
assert loaded.framework_version is None
|
||||
|
||||
loaded.apply_to_core()
|
||||
assert KEY_IDF_VERSION not in CORE.data[KEY_ESP32]
|
||||
|
||||
|
||||
def test_storage_json_apply_to_core_raises_on_invalid_framework_version(
|
||||
setup_core: Path,
|
||||
) -> None:
|
||||
"""A malformed version string fails with an actionable error at parse time."""
|
||||
from esphome.core import EsphomeError
|
||||
|
||||
loaded = _make_storage_with_toolchain("esp-idf")
|
||||
loaded.framework_version = "not-a-version"
|
||||
|
||||
with pytest.raises(EsphomeError, match="clean the build"):
|
||||
loaded.apply_to_core()
|
||||
|
||||
|
||||
def test_esphome_storage_json_as_dict() -> None:
|
||||
"""Test EsphomeStorageJSON.as_dict returns correct dictionary."""
|
||||
storage = storage_json.EsphomeStorageJSON(
|
||||
@@ -785,37 +729,3 @@ def test_storage_json_load_legacy_esphomeyaml_version(tmp_path: Path) -> None:
|
||||
|
||||
assert result is not None
|
||||
assert result.esphome_version == "1.14.0" # Should map to esphome_version
|
||||
|
||||
|
||||
def test_storage_json_load_area(tmp_path: Path) -> None:
|
||||
"""``area`` round-trips through load; absence loads as None."""
|
||||
file_path = tmp_path / "with_area.json"
|
||||
file_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"storage_version": 1,
|
||||
"name": "lamp",
|
||||
"friendly_name": "Lamp",
|
||||
"esp_platform": "ESP32",
|
||||
"area": "Living Room",
|
||||
}
|
||||
)
|
||||
)
|
||||
result = storage_json.StorageJSON.load(file_path)
|
||||
assert result is not None
|
||||
assert result.area == "Living Room"
|
||||
|
||||
legacy_path = tmp_path / "no_area.json"
|
||||
legacy_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"storage_version": 1,
|
||||
"name": "lamp",
|
||||
"friendly_name": "Lamp",
|
||||
"esp_platform": "ESP32",
|
||||
}
|
||||
)
|
||||
)
|
||||
legacy = storage_json.StorageJSON.load(legacy_path)
|
||||
assert legacy is not None
|
||||
assert legacy.area is None
|
||||
|
||||
@@ -838,86 +838,3 @@ def test_include_vars_applied_to_lambda_value(tmp_path: Path) -> None:
|
||||
|
||||
assert isinstance(result["value"], Lambda)
|
||||
assert result["value"].value == 'return "bar";'
|
||||
|
||||
|
||||
@patch("esphome.git.resolve_symlink_stub")
|
||||
@patch("esphome.git.clone_or_update")
|
||||
def test_remote_package_symlink_stub_is_followed(
|
||||
mock_clone_or_update: MagicMock,
|
||||
mock_resolve_symlink_stub: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""When a package YAML is a scalar (symlink stub) and resolve_symlink_stub
|
||||
returns a target, the loader follows the target and uses its content."""
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
(repo_dir / "static").mkdir()
|
||||
|
||||
# Stub file: content is the target path string (simulating Windows behavior).
|
||||
stub = repo_dir / "file1.yaml"
|
||||
stub.write_text("static/file1.yaml")
|
||||
|
||||
# Real target with valid YAML mapping.
|
||||
target = repo_dir / "static" / "file1.yaml"
|
||||
target.write_text("substitutions:\n hello: world\n")
|
||||
|
||||
mock_clone_or_update.return_value = (repo_dir, None)
|
||||
mock_resolve_symlink_stub.return_value = target
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"packages": {
|
||||
"test_package": {
|
||||
"url": "https://github.com/esphome/repo1",
|
||||
"ref": "main",
|
||||
"files": ["file1.yaml"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Must succeed (does not raise the helpful cv.Invalid) because the stub
|
||||
# was followed and a valid mapping was loaded from the target.
|
||||
do_packages_pass(config)
|
||||
assert mock_resolve_symlink_stub.called
|
||||
|
||||
|
||||
@patch("esphome.git.clone_or_update")
|
||||
def test_remote_package_scalar_yaml_raises_helpful_error(
|
||||
mock_clone_or_update: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A remote package YAML that is a top-level scalar (e.g. an unmaterialized
|
||||
git symlink on Windows) raises a clear cv.Invalid, not AttributeError.
|
||||
|
||||
Regression test for the case where a repo containing a YAML symlink,
|
||||
checked out on Windows without symlink privilege, lands as a short text
|
||||
file containing the symlink target path. PyYAML parses that as a bare
|
||||
string scalar; the package loader must reject it with a human-readable
|
||||
error instead of dying inside ``.get()``.
|
||||
"""
|
||||
CORE.config_path = tmp_path / "test.yaml"
|
||||
|
||||
repo_dir = tmp_path / "repo"
|
||||
repo_dir.mkdir()
|
||||
# Simulate the broken-symlink state: a YAML file whose entire content is
|
||||
# the symlink target string. PyYAML parses this as a top-level scalar.
|
||||
(repo_dir / "file1.yaml").write_text("static/file1.yaml")
|
||||
|
||||
mock_clone_or_update.return_value = (repo_dir, None)
|
||||
|
||||
config: dict[str, Any] = {
|
||||
"packages": {
|
||||
"test_package": {
|
||||
"url": "https://github.com/esphome/repo1",
|
||||
"ref": "main",
|
||||
"files": ["file1.yaml"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
with pytest.raises(cv.Invalid) as exc_info:
|
||||
do_packages_pass(config)
|
||||
|
||||
msg = str(exc_info.value)
|
||||
assert "mapping at the top level" in msg
|
||||
assert "file1.yaml" in msg
|
||||
|
||||
@@ -75,8 +75,6 @@ def create_storage() -> Callable[..., StorageJSON]:
|
||||
no_mdns=kwargs.get("no_mdns", False),
|
||||
framework=kwargs.get("framework", "arduino"),
|
||||
core_platform=kwargs.get("core_platform", "esp32"),
|
||||
toolchain=kwargs.get("toolchain", "platformio"),
|
||||
framework_version=kwargs.get("framework_version"),
|
||||
)
|
||||
|
||||
return _create
|
||||
@@ -108,46 +106,6 @@ def test_storage_should_clean_when_build_path_changes(
|
||||
assert storage_should_clean(old, new) is True
|
||||
|
||||
|
||||
def test_storage_should_clean_when_toolchain_changes(
|
||||
create_storage: Callable[..., StorageJSON],
|
||||
) -> None:
|
||||
"""Test that clean is triggered when the build toolchain changes.
|
||||
|
||||
Switching between the PlatformIO and native ESP-IDF toolchains produces
|
||||
incompatible build trees (and toolchain-specific idedata), so the build
|
||||
must be wiped.
|
||||
"""
|
||||
old = create_storage(loaded_integrations=["api", "wifi"], toolchain="platformio")
|
||||
new = create_storage(loaded_integrations=["api", "wifi"], toolchain="esp-idf")
|
||||
assert storage_should_clean(old, new) is True
|
||||
|
||||
|
||||
def test_storage_should_clean_when_framework_changes(
|
||||
create_storage: Callable[..., StorageJSON],
|
||||
) -> None:
|
||||
"""Test that clean is triggered when the framework changes.
|
||||
|
||||
Switching between arduino and esp-idf produces incompatible build trees
|
||||
even on the same toolchain, so the build must be wiped.
|
||||
"""
|
||||
old = create_storage(loaded_integrations=["api", "wifi"], framework="arduino")
|
||||
new = create_storage(loaded_integrations=["api", "wifi"], framework="esp-idf")
|
||||
assert storage_should_clean(old, new) is True
|
||||
|
||||
|
||||
def test_storage_should_clean_when_framework_version_changes(
|
||||
create_storage: Callable[..., StorageJSON],
|
||||
) -> None:
|
||||
"""Test that clean is triggered when the framework version changes.
|
||||
|
||||
A different framework/ESP-IDF version compiles against a different SDK, so
|
||||
the stale build tree must be wiped.
|
||||
"""
|
||||
old = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.3.1")
|
||||
new = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.4.0")
|
||||
assert storage_should_clean(old, new) is True
|
||||
|
||||
|
||||
def test_storage_should_clean_when_component_removed(
|
||||
create_storage: Callable[..., StorageJSON],
|
||||
) -> None:
|
||||
@@ -485,11 +443,6 @@ def test_clean_build(
|
||||
dependencies_lock = tmp_path / "dependencies.lock"
|
||||
dependencies_lock.write_text("lock file")
|
||||
|
||||
# idedata cache lives under the data dir, not the build path.
|
||||
idedata_cache = tmp_path / "idedata" / "test.json"
|
||||
idedata_cache.parent.mkdir()
|
||||
idedata_cache.write_text("{}")
|
||||
|
||||
# Native ESP-IDF toolchain artifacts.
|
||||
idf_build_dir = tmp_path / "build"
|
||||
idf_build_dir.mkdir()
|
||||
@@ -510,14 +463,11 @@ def test_clean_build(
|
||||
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
|
||||
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
|
||||
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
|
||||
mock_core.name = "test"
|
||||
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
|
||||
|
||||
# Verify all exist before
|
||||
assert pioenvs_dir.exists()
|
||||
assert piolibdeps_dir.exists()
|
||||
assert dependencies_lock.exists()
|
||||
assert idedata_cache.exists()
|
||||
assert idf_build_dir.exists()
|
||||
assert managed_components_dir.exists()
|
||||
assert platformio_cache_dir.exists()
|
||||
@@ -542,7 +492,6 @@ def test_clean_build(
|
||||
assert not pioenvs_dir.exists()
|
||||
assert not piolibdeps_dir.exists()
|
||||
assert not dependencies_lock.exists()
|
||||
assert not idedata_cache.exists()
|
||||
assert not idf_build_dir.exists()
|
||||
assert not managed_components_dir.exists()
|
||||
assert not platformio_cache_dir.exists()
|
||||
@@ -552,7 +501,6 @@ def test_clean_build(
|
||||
assert ".pioenvs" in caplog.text
|
||||
assert ".piolibdeps" in caplog.text
|
||||
assert "dependencies.lock" in caplog.text
|
||||
assert str(idedata_cache) in caplog.text
|
||||
assert str(idf_build_dir) in caplog.text
|
||||
assert str(managed_components_dir) in caplog.text
|
||||
assert "PlatformIO cache" in caplog.text
|
||||
|
||||
@@ -12,11 +12,15 @@ import esphome.config_validation as cv
|
||||
from esphome.core import DocumentLocation, DocumentRange, EsphomeError
|
||||
from esphome.util import OrderedDict
|
||||
from esphome.yaml_util import (
|
||||
DiscoveredYamlFiles,
|
||||
ESPHomeDataBase,
|
||||
ESPLiteralValue,
|
||||
discover_user_yaml_files,
|
||||
force_load_include_files,
|
||||
format_path,
|
||||
make_data_base,
|
||||
make_literal,
|
||||
track_yaml_loads,
|
||||
)
|
||||
|
||||
|
||||
@@ -30,6 +34,14 @@ def clear_secrets_cache() -> None:
|
||||
yaml_util._SECRET_CACHE.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_core_frontmatter() -> None:
|
||||
"""Reset CORE.frontmatter between tests."""
|
||||
core.CORE.frontmatter = {}
|
||||
yield
|
||||
core.CORE.frontmatter = {}
|
||||
|
||||
|
||||
def test_include_with_vars(fixture_path: Path) -> None:
|
||||
yaml_file = fixture_path / "yaml_util" / "includetest.yaml"
|
||||
|
||||
@@ -966,3 +978,365 @@ def test_make_literal_blocks_substitution() -> None:
|
||||
# undefined in the context.
|
||||
assert result == {"pin": "${PIN}"}
|
||||
assert isinstance(result, ESPLiteralValue)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# force_load_include_files / discover_user_yaml_files
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _StubInclude:
|
||||
"""Stand-in for `IncludeFile` that records how `load()` was called.
|
||||
|
||||
Patched in via `esphome.yaml_util.IncludeFile` so the recursion in
|
||||
`force_load_include_files` treats instances as deferred includes without
|
||||
needing an actual on-disk file.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
file: str = "stub.yaml",
|
||||
parent_file: Path | None = None,
|
||||
*,
|
||||
unresolved: bool = False,
|
||||
load_result: object = None,
|
||||
raise_on_load: EsphomeError | None = None,
|
||||
) -> None:
|
||||
self.file = Path(file)
|
||||
self.parent_file = parent_file or Path("/tmp/parent.yaml")
|
||||
self._unresolved = unresolved
|
||||
self._load_result = load_result if load_result is not None else {}
|
||||
self._raise = raise_on_load
|
||||
self.load_calls = 0
|
||||
|
||||
def has_unresolved_expressions(self) -> bool:
|
||||
return self._unresolved
|
||||
|
||||
def load(self) -> object:
|
||||
self.load_calls += 1
|
||||
if self._raise is not None:
|
||||
raise self._raise
|
||||
return self._load_result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patch_include_file():
|
||||
"""Replace `IncludeFile` with `_StubInclude` so isinstance checks in
|
||||
`force_load_include_files` match the stubs constructed by tests."""
|
||||
with patch("esphome.yaml_util.IncludeFile", _StubInclude):
|
||||
yield
|
||||
|
||||
|
||||
def test_force_load_include_files_resolves_nested_includes(
|
||||
patch_include_file: None,
|
||||
) -> None:
|
||||
"""A tree of dict/list/IncludeFile is walked and every IncludeFile is loaded."""
|
||||
inner = _StubInclude("inner.yaml")
|
||||
outer = _StubInclude("outer.yaml", load_result={"nested": inner})
|
||||
force_load_include_files([{"a": outer}, "scalar"])
|
||||
assert outer.load_calls == 1
|
||||
assert inner.load_calls == 1
|
||||
|
||||
|
||||
def test_force_load_include_files_seen_guard_prevents_double_load(
|
||||
patch_include_file: None,
|
||||
) -> None:
|
||||
"""The same IncludeFile referenced from two branches loads once."""
|
||||
stub = _StubInclude("once.yaml")
|
||||
force_load_include_files({"a": stub, "b": [stub]})
|
||||
assert stub.load_calls == 1
|
||||
|
||||
|
||||
def test_force_load_include_files_handles_cyclic_containers() -> None:
|
||||
"""Cyclic dict/list references don't trigger infinite recursion."""
|
||||
cyclic_dict: dict[str, object] = {}
|
||||
cyclic_dict["self"] = cyclic_dict
|
||||
cyclic_list: list[object] = []
|
||||
cyclic_list.append(cyclic_list)
|
||||
# Both calls must return without recursing forever.
|
||||
force_load_include_files(cyclic_dict)
|
||||
force_load_include_files(cyclic_list)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("warn_on_unresolved", "expect_level"),
|
||||
[
|
||||
pytest.param(True, "WARNING", id="default-warns"),
|
||||
pytest.param(False, "DEBUG", id="opt-in-demotes"),
|
||||
],
|
||||
)
|
||||
def test_force_load_include_files_unresolved_log_level(
|
||||
patch_include_file: None,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
warn_on_unresolved: bool,
|
||||
expect_level: str,
|
||||
) -> None:
|
||||
"""Substitution-templated include paths skip the load and log at the
|
||||
level chosen by `warn_on_unresolved`."""
|
||||
stub = _StubInclude("${var}.yaml", unresolved=True)
|
||||
with caplog.at_level("DEBUG", logger="esphome.yaml_util"):
|
||||
force_load_include_files({"k": stub}, warn_on_unresolved=warn_on_unresolved)
|
||||
assert stub.load_calls == 0
|
||||
matching = [
|
||||
r.levelname for r in caplog.records if "Cannot resolve !include" in r.message
|
||||
]
|
||||
assert matching == [expect_level]
|
||||
|
||||
|
||||
def test_force_load_include_files_warns_on_load_failure(
|
||||
patch_include_file: None,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""An `EsphomeError` raised by `load()` is caught and logged, not propagated."""
|
||||
stub = _StubInclude("missing.yaml", raise_on_load=EsphomeError("boom"))
|
||||
with caplog.at_level("WARNING", logger="esphome.yaml_util"):
|
||||
force_load_include_files({"k": stub})
|
||||
assert any(
|
||||
"Failed to load !include" in r.message and "missing.yaml" in r.message
|
||||
for r in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_discovered_yaml_files_holds_files_and_secrets() -> None:
|
||||
"""`DiscoveredYamlFiles` is a small data carrier; both fields are mandatory."""
|
||||
files = [Path("/tmp/a.yaml")]
|
||||
secrets = {Path("/tmp/a.yaml")}
|
||||
discovered = DiscoveredYamlFiles(files, secrets)
|
||||
assert discovered.files is files
|
||||
assert discovered.secrets is secrets
|
||||
|
||||
|
||||
def _write(tmp_path: Path, name: str, content: str) -> Path:
|
||||
"""Write `content` to `tmp_path/name`, creating parent dirs as needed."""
|
||||
path = tmp_path / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
return path
|
||||
|
||||
|
||||
def _write_entry_including(tmp_path: Path, included_name: str) -> Path:
|
||||
"""Write a minimal entry yaml that `!include`s `included_name`."""
|
||||
return _write(
|
||||
tmp_path,
|
||||
"entry.yaml",
|
||||
f"esphome:\n name: test\nwifi: !include {included_name}\n",
|
||||
)
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_captures_includes(tmp_path: Path) -> None:
|
||||
"""A `!include` in the entry yaml is force-loaded so the listener fires."""
|
||||
_write(tmp_path, "wifi.yaml", "ssid: my_ssid\npassword: my_pw\n")
|
||||
discovered = discover_user_yaml_files(_write_entry_including(tmp_path, "wifi.yaml"))
|
||||
names = {p.name for p in discovered.files}
|
||||
assert names == {"entry.yaml", "wifi.yaml"}
|
||||
assert discovered.secrets == set()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"secret_name",
|
||||
[
|
||||
pytest.param("secrets.yaml", id="yaml"),
|
||||
pytest.param("secrets.yml", id="yml"),
|
||||
],
|
||||
)
|
||||
def test_discover_user_yaml_files_flags_secrets_filename(
|
||||
tmp_path: Path, secret_name: str
|
||||
) -> None:
|
||||
"""Both `secrets.yaml` and `secrets.yml` get flagged in `.secrets`."""
|
||||
_write(tmp_path, secret_name, "key: value\n")
|
||||
discovered = discover_user_yaml_files(_write_entry_including(tmp_path, secret_name))
|
||||
assert (tmp_path / secret_name).resolve() in discovered.secrets
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_flags_secrets_symlink(tmp_path: Path) -> None:
|
||||
"""`secrets.yaml` symlinked to a non-secrets-named target is still flagged
|
||||
because the un-resolved basename is what gets recorded."""
|
||||
target = _write(tmp_path, "real_creds.yaml", "key: value\n")
|
||||
(tmp_path / "secrets.yaml").symlink_to(target)
|
||||
discovered = discover_user_yaml_files(
|
||||
_write_entry_including(tmp_path, "secrets.yaml")
|
||||
)
|
||||
# The recorded "secret path" is the resolved target — even though its
|
||||
# basename is `real_creds.yaml`, it's still in `.secrets`.
|
||||
assert target.resolve() in discovered.secrets
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_swallows_parse_errors(tmp_path: Path) -> None:
|
||||
"""A YAML parse failure returns whatever was tracked so far without raising."""
|
||||
entry = _write(tmp_path, "entry.yaml", "esphome: [unterminated\n")
|
||||
discovered = discover_user_yaml_files(entry)
|
||||
assert isinstance(discovered, DiscoveredYamlFiles)
|
||||
|
||||
|
||||
def test_discover_user_yaml_files_deduplicates(tmp_path: Path) -> None:
|
||||
"""The same file referenced twice appears once in `.files`."""
|
||||
_write(tmp_path, "wifi.yaml", "ssid: a\n")
|
||||
entry = _write(
|
||||
tmp_path,
|
||||
"entry.yaml",
|
||||
"esphome:\n name: test\nwifi: !include wifi.yaml\nfoo: !include wifi.yaml\n",
|
||||
)
|
||||
discovered = discover_user_yaml_files(entry)
|
||||
wifi_resolved = (tmp_path / "wifi.yaml").resolve()
|
||||
assert discovered.files.count(wifi_resolved) == 1
|
||||
|
||||
|
||||
def test_track_yaml_loads_records_resolved_paths(tmp_path: Path) -> None:
|
||||
"""`track_yaml_loads` is the building block — sanity-check it resolves
|
||||
symlinks so callers can dedupe by identity."""
|
||||
target = _write(tmp_path, "actual.yaml", "esphome:\n name: t\n")
|
||||
link = tmp_path / "alias.yaml"
|
||||
link.symlink_to(target)
|
||||
with track_yaml_loads() as loaded:
|
||||
yaml_util.load_yaml(link)
|
||||
assert target.resolve() in loaded
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML frontmatter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_frontmatter_parsed_and_stored_on_core(tmp_path: Path) -> None:
|
||||
"""A leading `---`-separated YAML document is stored as frontmatter and
|
||||
stripped from the returned config."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text(
|
||||
"author: Jesse\nlabels: [office, climate]\n---\nesphome:\n name: my_node\n"
|
||||
)
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
|
||||
# Config does not contain frontmatter keys
|
||||
assert "author" not in config
|
||||
assert "labels" not in config
|
||||
assert config["esphome"]["name"] == "my_node"
|
||||
|
||||
# Frontmatter is stored on CORE keyed by resolved path
|
||||
frontmatter = core.CORE.frontmatter[yaml_file.resolve()]
|
||||
assert frontmatter["author"] == "Jesse"
|
||||
assert frontmatter["labels"] == ["office", "climate"]
|
||||
|
||||
|
||||
def test_frontmatter_absent_when_single_document(tmp_path: Path) -> None:
|
||||
"""A YAML file with a single document does not populate CORE.frontmatter."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("esphome:\n name: my_node\n")
|
||||
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
assert yaml_file.resolve() not in core.CORE.frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_absent_when_leading_doc_separator(tmp_path: Path) -> None:
|
||||
"""A leading `---` with no content above it is just a document start marker,
|
||||
not frontmatter, and must not populate CORE.frontmatter."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("---\nesphome:\n name: my_node\n")
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
assert config["esphome"]["name"] == "my_node"
|
||||
assert yaml_file.resolve() not in core.CORE.frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_supports_arbitrary_keys(tmp_path: Path) -> None:
|
||||
"""Frontmatter keys are not validated — any structure is accepted."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text(
|
||||
"any_key: any_value\n"
|
||||
"nested:\n"
|
||||
" count: 42\n"
|
||||
" items:\n"
|
||||
" - a\n"
|
||||
" - b\n"
|
||||
"---\n"
|
||||
"esphome:\n"
|
||||
" name: t\n"
|
||||
)
|
||||
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
frontmatter = core.CORE.frontmatter[yaml_file.resolve()]
|
||||
assert frontmatter["any_key"] == "any_value"
|
||||
assert frontmatter["nested"]["count"] == 42
|
||||
assert frontmatter["nested"]["items"] == ["a", "b"]
|
||||
|
||||
|
||||
def test_frontmatter_supports_deeply_nested_paths(tmp_path: Path) -> None:
|
||||
"""Frontmatter preserves deeply nested dict/list structures intact."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text(
|
||||
"device:\n"
|
||||
" metadata:\n"
|
||||
" location:\n"
|
||||
" building: HQ\n"
|
||||
" floor: 3\n"
|
||||
" room:\n"
|
||||
" number: 302\n"
|
||||
" occupants:\n"
|
||||
" - name: Jesse\n"
|
||||
" role:\n"
|
||||
" title: maintainer\n"
|
||||
" since: 2021\n"
|
||||
" - name: Alice\n"
|
||||
" role:\n"
|
||||
" title: contributor\n"
|
||||
" since: 2024\n"
|
||||
"---\n"
|
||||
"esphome:\n"
|
||||
" name: t\n"
|
||||
)
|
||||
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
fm = core.CORE.frontmatter[yaml_file.resolve()]
|
||||
room = fm["device"]["metadata"]["location"]["room"]
|
||||
assert room["number"] == 302
|
||||
assert room["occupants"][0]["name"] == "Jesse"
|
||||
assert room["occupants"][0]["role"]["title"] == "maintainer"
|
||||
assert room["occupants"][0]["role"]["since"] == 2021
|
||||
assert room["occupants"][1]["role"]["title"] == "contributor"
|
||||
|
||||
|
||||
def test_frontmatter_more_than_two_documents_raises(tmp_path: Path) -> None:
|
||||
"""Three or more YAML documents is unsupported and must raise."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("a: 1\n---\nb: 2\n---\nc: 3\n")
|
||||
|
||||
with pytest.raises(EsphomeError, match="at most two are supported"):
|
||||
yaml_util.load_yaml(yaml_file)
|
||||
|
||||
|
||||
def test_frontmatter_empty_frontmatter_doc_not_stored(tmp_path: Path) -> None:
|
||||
"""An empty (null) frontmatter document is treated as no frontmatter."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("---\n---\nesphome:\n name: t\n")
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
assert config["esphome"]["name"] == "t"
|
||||
assert yaml_file.resolve() not in core.CORE.frontmatter
|
||||
|
||||
|
||||
def test_frontmatter_empty_config_doc(tmp_path: Path) -> None:
|
||||
"""An empty config document after a frontmatter document yields an empty config."""
|
||||
yaml_file = tmp_path / "main.yaml"
|
||||
yaml_file.write_text("only: frontmatter\n---\n")
|
||||
|
||||
config = yaml_util.load_yaml(yaml_file)
|
||||
assert config == {}
|
||||
assert core.CORE.frontmatter[yaml_file.resolve()]["only"] == "frontmatter"
|
||||
|
||||
|
||||
def test_frontmatter_included_file_stored(tmp_path: Path) -> None:
|
||||
"""Frontmatter on an !include'd file is also captured on CORE, keyed by
|
||||
that file's resolved path."""
|
||||
inc = tmp_path / "child.yaml"
|
||||
inc.write_text("child_meta: hello\n---\nchild_key: value\n")
|
||||
main = tmp_path / "main.yaml"
|
||||
main.write_text("esphome:\n name: t\nchild: !include child.yaml\n")
|
||||
|
||||
config = yaml_util.load_yaml(main)
|
||||
# !include is deferred; force resolution so the child file actually loads
|
||||
force_load_include_files(config)
|
||||
assert config["child"].load()["child_key"] == "value"
|
||||
# Main file has no frontmatter
|
||||
assert main.resolve() not in core.CORE.frontmatter
|
||||
# Included file's frontmatter is captured
|
||||
assert core.CORE.frontmatter[inc.resolve()]["child_meta"] == "hello"
|
||||
|
||||
Reference in New Issue
Block a user