mirror of
https://github.com/esphome/esphome.git
synced 2026-09-25 05:54:01 +00:00
Merge remote-tracking branch 'upstream/dev' into integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -27,6 +27,14 @@ 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
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
|
||||
shell: bash
|
||||
@@ -34,8 +42,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 +51,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,12 @@ 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
|
||||
|
||||
- name: Install apt dependencies
|
||||
run: |
|
||||
@@ -34,7 +40,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
|
||||
|
||||
+54
-15
@@ -52,14 +52,22 @@ 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
|
||||
- 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
|
||||
@@ -181,7 +189,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
|
||||
@@ -222,12 +230,12 @@ 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
|
||||
with:
|
||||
@@ -249,6 +257,7 @@ jobs:
|
||||
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,7 +296,12 @@ 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
|
||||
|
||||
@@ -296,6 +310,7 @@ jobs:
|
||||
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 +359,20 @@ 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
|
||||
- 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 +384,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 +521,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 +539,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 +599,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 +617,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 +694,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 +712,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"
|
||||
|
||||
@@ -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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -212,74 +212,6 @@ jobs:
|
||||
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
|
||||
$(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *)
|
||||
|
||||
deploy-ha-addon-repo:
|
||||
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- init
|
||||
- deploy-manifest
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: home-assistant-addon
|
||||
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
let description = "ESPHome";
|
||||
if (context.eventName == "release") {
|
||||
description = ${{ toJSON(github.event.release.body) }};
|
||||
}
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: "esphome",
|
||||
repo: "home-assistant-addon",
|
||||
workflow_id: "bump-version.yml",
|
||||
ref: "main",
|
||||
inputs: {
|
||||
version: "${{ needs.init.outputs.tag }}",
|
||||
content: description
|
||||
}
|
||||
})
|
||||
|
||||
deploy-esphome-schema:
|
||||
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [init]
|
||||
environment: ${{ needs.init.outputs.deploy_env }}
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: esphome-schema
|
||||
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: "esphome",
|
||||
repo: "esphome-schema",
|
||||
workflow_id: "generate-schemas.yml",
|
||||
ref: "main",
|
||||
inputs: {
|
||||
version: "${{ needs.init.outputs.tag }}",
|
||||
}
|
||||
})
|
||||
|
||||
version-notifier:
|
||||
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -302,7 +234,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: "esphome",
|
||||
repo: "version-notifier",
|
||||
workflow_id: "notify.yml",
|
||||
|
||||
@@ -41,19 +41,52 @@ 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
|
||||
|
||||
- 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
|
||||
|
||||
+7
-3
@@ -13,12 +13,16 @@ RUN git config --system --add safe.directory "*" \
|
||||
&& git config --system advice.detachedHead false
|
||||
|
||||
# Install build tools for Python packages that require compilation
|
||||
# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager)
|
||||
# (e.g., ruamel.yaml.clib used by ESP-IDF's idf-component-manager).
|
||||
# Also install libusb-1.0 at runtime so the ESP-IDF tools installer can
|
||||
# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without
|
||||
# it idf_tools.py rejects the openocd install with exit 127 and aborts
|
||||
# the whole framework setup.
|
||||
RUN if command -v apk > /dev/null; then \
|
||||
apk add --no-cache build-base; \
|
||||
apk add --no-cache build-base libusb; \
|
||||
else \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential \
|
||||
&& apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ from esphome.const import (
|
||||
CONF_TOPIC,
|
||||
CONF_USERNAME,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_PLATFORM,
|
||||
@@ -734,6 +735,13 @@ def write_cpp_file() -> int:
|
||||
|
||||
|
||||
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
# Keep this gate here, NOT in config validation: device-builder needs
|
||||
# `esphome config` to keep succeeding with placeholders so onboarding can run.
|
||||
if CONF_WIFI in config:
|
||||
from esphome.components.wifi import check_placeholder_credentials
|
||||
|
||||
check_placeholder_credentials(config)
|
||||
|
||||
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
|
||||
# If you change this format, update the regex in that script as well
|
||||
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.components.esp32 import get_esp32_variant
|
||||
from esphome.components.esp32 import get_esp32_variant, idf_version
|
||||
import esphome.config_validation as cv
|
||||
from esphome.core import CORE
|
||||
from esphome.helpers import mkdir_p, write_file_if_changed
|
||||
from esphome.writer import update_storage_json
|
||||
@@ -61,6 +62,11 @@ def get_project_cmakelists(minimal: bool = False) -> str:
|
||||
variant = get_esp32_variant()
|
||||
idf_target = variant.lower().replace("-", "")
|
||||
|
||||
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
|
||||
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
|
||||
# --format=raw because the legacy mode doesn't support it.
|
||||
size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
|
||||
|
||||
# Project-wide compile options: -D defines and -W warning flags (skip
|
||||
# -Wl, linker flags — those go on the src component via
|
||||
# target_link_options below). Emitted via idf_build_set_property so the
|
||||
@@ -146,7 +152,7 @@ project({CORE.name})
|
||||
# Emit raw JSON size data for ESPHome to read post-build.
|
||||
add_custom_command(
|
||||
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
|
||||
COMMAND ${{PYTHON}} -m esp_idf_size --ng --format=raw
|
||||
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
|
||||
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
|
||||
${{CMAKE_PROJECT_NAME}}.map
|
||||
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
|
||||
|
||||
+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("<"):
|
||||
|
||||
@@ -395,7 +395,7 @@ async def to_code(config):
|
||||
)
|
||||
if data.mp3_support:
|
||||
cg.add_define("USE_AUDIO_MP3_SUPPORT")
|
||||
add_idf_component(name="esphome/micro-mp3", ref="0.2.0")
|
||||
add_idf_component(name="esphome/micro-mp3", ref="0.2.1")
|
||||
_emit_memory_pair(
|
||||
data.mp3.buffer_memory,
|
||||
"CONFIG_MP3_DECODER_PREFER_PSRAM",
|
||||
|
||||
@@ -161,7 +161,7 @@ void BME680BSECComponent::dump_config() {
|
||||
" IAQ Mode: %s\n"
|
||||
" Supply Voltage: %sV\n"
|
||||
" Sample Rate: %s\n"
|
||||
" State Save Interval: %ims",
|
||||
" State Save Interval: %" PRIu32 "ms",
|
||||
this->temperature_offset_, this->iaq_mode_ == IAQ_MODE_STATIC ? "Static" : "Mobile",
|
||||
this->supply_voltage_ == SUPPLY_VOLTAGE_3V3 ? "3.3" : "1.8",
|
||||
BME680_BSEC_SAMPLE_RATE_LOG(this->sample_rate_), this->state_save_interval_ms_);
|
||||
@@ -461,7 +461,7 @@ int8_t BME680BSECComponent::write_bytes_wrapper(uint8_t devid, uint8_t a_registe
|
||||
}
|
||||
|
||||
void BME680BSECComponent::delay_ms(uint32_t period) {
|
||||
ESP_LOGV(TAG, "Delaying for %ums", period);
|
||||
ESP_LOGV(TAG, "Delaying for %" PRIu32 "ms", period);
|
||||
delay(period);
|
||||
}
|
||||
|
||||
|
||||
@@ -1767,9 +1767,11 @@ async def to_code(config):
|
||||
else:
|
||||
cg.add_build_flag("-Wno-error=format")
|
||||
cg.add_build_flag("-Wno-error=maybe-uninitialized")
|
||||
cg.add_build_flag("-Wno-error=missing-field-initializers")
|
||||
cg.add_build_flag("-Wno-error=overloaded-virtual")
|
||||
cg.add_build_flag("-Wno-error=reorder")
|
||||
cg.add_build_flag("-Wno-error=volatile")
|
||||
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
|
||||
cg.add_build_flag("-Wno-missing-field-initializers")
|
||||
|
||||
cg.set_cpp_standard("gnu++20")
|
||||
cg.add_build_flag("-DUSE_ESP32")
|
||||
|
||||
@@ -218,13 +218,14 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt
|
||||
}
|
||||
} 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();
|
||||
response.attr_value.len = this->value_.size();
|
||||
if (response.attr_value.len > ESP_GATT_MAX_ATTR_LEN) {
|
||||
ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating", response.attr_value.len,
|
||||
ESP_GATT_MAX_ATTR_LEN);
|
||||
response.attr_value.len = ESP_GATT_MAX_ATTR_LEN;
|
||||
}
|
||||
memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len);
|
||||
this->value_read_offset_ = 0;
|
||||
}
|
||||
|
||||
response.attr_value.handle = this->handle_;
|
||||
|
||||
@@ -249,7 +249,7 @@ async def to_code(config):
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.7")
|
||||
else:
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")
|
||||
|
||||
@@ -92,7 +92,7 @@ void Esp32HostedUpdate::setup() {
|
||||
if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) {
|
||||
// 16 bytes: "255.255.255" (11 chars) + null + safety margin
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "%d.%d.%d", ver_info.major1, ver_info.minor1, ver_info.patch1);
|
||||
snprintf(buf, sizeof(buf), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, ver_info.major1, ver_info.minor1, ver_info.patch1);
|
||||
this->update_info_.current_version = buf;
|
||||
} else {
|
||||
this->update_info_.current_version = "unknown";
|
||||
@@ -120,8 +120,8 @@ void Esp32HostedUpdate::setup() {
|
||||
this->state_ = update::UPDATE_STATE_NO_UPDATE;
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word,
|
||||
ESP_APP_DESC_MAGIC_WORD);
|
||||
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")",
|
||||
app_desc->magic_word, static_cast<uint32_t>(ESP_APP_DESC_MAGIC_WORD));
|
||||
this->state_ = update::UPDATE_STATE_NO_UPDATE;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -108,8 +108,8 @@ void ESPHomeOTAComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Partition access allowed\n"
|
||||
" Running app:\n"
|
||||
" Partition address: 0x%X\n"
|
||||
" Used size: %zu bytes (0x%X)",
|
||||
" Partition address: 0x%" PRIX32 "\n"
|
||||
" Used size: %zu bytes (0x%zX)",
|
||||
this->running_app_offset_, this->running_app_size_, this->running_app_size_);
|
||||
|
||||
#ifdef USE_ESP32
|
||||
@@ -378,7 +378,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
}
|
||||
ota_size = (static_cast<size_t>(buf[0]) << 24) | (static_cast<size_t>(buf[1]) << 16) |
|
||||
(static_cast<size_t>(buf[2]) << 8) | buf[3];
|
||||
ESP_LOGV(TAG, "Size is %u bytes", ota_size);
|
||||
ESP_LOGV(TAG, "Size is %zu bytes", ota_size);
|
||||
|
||||
#ifndef USE_OTA_PARTITIONS
|
||||
if (ota_type != ota::OTA_TYPE_UPDATE_APP) {
|
||||
@@ -749,7 +749,7 @@ bool ESPHomeOTAComponent::handle_auth_send_() {
|
||||
this->auth_buf_[0] = this->auth_type_;
|
||||
hasher.get_hex(buf);
|
||||
|
||||
ESP_LOGV(TAG, "Auth: Nonce is %.*s", hex_size, buf);
|
||||
ESP_LOGV(TAG, "Auth: Nonce is %.*s", (int) hex_size, buf);
|
||||
}
|
||||
|
||||
// Try to write auth_type + nonce
|
||||
@@ -809,13 +809,13 @@ bool ESPHomeOTAComponent::handle_auth_read_() {
|
||||
hasher.add(nonce, hex_size * 2); // Add both nonce and cnonce (contiguous in buffer)
|
||||
hasher.calculate();
|
||||
|
||||
ESP_LOGV(TAG, "Auth: CNonce is %.*s", hex_size, cnonce);
|
||||
ESP_LOGV(TAG, "Auth: CNonce is %.*s", (int) hex_size, cnonce);
|
||||
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
|
||||
char computed_hash[SHA256_HEX_SIZE + 1]; // Buffer for hex-encoded hash (max expected length + null terminator)
|
||||
hasher.get_hex(computed_hash);
|
||||
ESP_LOGV(TAG, "Auth: Result is %.*s", hex_size, computed_hash);
|
||||
ESP_LOGV(TAG, "Auth: Result is %.*s", (int) hex_size, computed_hash);
|
||||
#endif
|
||||
ESP_LOGV(TAG, "Auth: Response is %.*s", hex_size, response);
|
||||
ESP_LOGV(TAG, "Auth: Response is %.*s", (int) hex_size, response);
|
||||
|
||||
// Compare response
|
||||
bool matches = hasher.equals_hex(response);
|
||||
|
||||
@@ -19,7 +19,7 @@ void FastLEDLightOutput::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"FastLED light:\n"
|
||||
" Num LEDs: %u\n"
|
||||
" Max refresh rate: %u",
|
||||
" Max refresh rate: %" PRIu32,
|
||||
this->num_leds_, this->max_refresh_rate_.value_or(0));
|
||||
}
|
||||
void FastLEDLightOutput::write_state(light::LightState *state) {
|
||||
|
||||
@@ -206,6 +206,7 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() {
|
||||
break;
|
||||
case ENROLL_MISMATCH:
|
||||
ESP_LOGE(TAG, "Scans do not match");
|
||||
[[fallthrough]];
|
||||
default:
|
||||
return this->data_[0];
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ void FT5x06Touchscreen::setup() {
|
||||
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
|
||||
}
|
||||
|
||||
// reading the chip registers to get max x/y does not seem to work.
|
||||
if (this->display_ != nullptr) {
|
||||
if (this->x_raw_max_ == this->x_raw_min_) {
|
||||
this->x_raw_max_ = this->display_->get_native_width();
|
||||
}
|
||||
if (this->y_raw_max_ == this->y_raw_min_) {
|
||||
this->y_raw_max_ = this->display_->get_native_height();
|
||||
}
|
||||
}
|
||||
|
||||
// wait 200ms after reset.
|
||||
this->set_timeout(200, [this] { this->continue_setup_(); });
|
||||
}
|
||||
@@ -39,15 +49,6 @@ void FT5x06Touchscreen::continue_setup_() {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
// reading the chip registers to get max x/y does not seem to work.
|
||||
if (this->display_ != nullptr) {
|
||||
if (this->x_raw_max_ == this->x_raw_min_) {
|
||||
this->x_raw_max_ = this->display_->get_native_width();
|
||||
}
|
||||
if (this->y_raw_max_ == this->y_raw_min_) {
|
||||
this->y_raw_max_ = this->display_->get_native_height();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FT5x06Touchscreen::update_touches() {
|
||||
@@ -71,7 +72,7 @@ void FT5x06Touchscreen::update_touches() {
|
||||
uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]);
|
||||
uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]);
|
||||
|
||||
ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
|
||||
ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
|
||||
if (status == 0 || status == 2) {
|
||||
this->add_raw_touch_position_(id, x, y);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ static constexpr uint8_t MEAS_CONF_HUM = 0x04; // Bits 2:1 = 10: humidity only
|
||||
void HDC2080Component::setup() {
|
||||
const uint8_t data = 0x00; // automatic measurement mode disabled, heater off
|
||||
if (this->write_register(REG_RESET_DRDY_INT_CONF, &data, 1) != i2c::ERROR_OK) {
|
||||
this->mark_failed(ESP_LOG_MSG_COMM_FAIL);
|
||||
this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,7 +125,6 @@ async def to_code(config):
|
||||
cg.add(var.set_vertical_default(config[CONF_VERTICAL_DEFAULT]))
|
||||
cg.add(var.set_max_temperature(config[CONF_MAX_TEMPERATURE]))
|
||||
cg.add(var.set_min_temperature(config[CONF_MIN_TEMPERATURE]))
|
||||
cg.add_build_flag("-Wno-error=overloaded-virtual")
|
||||
|
||||
cg.add_library("tonia/HeatpumpIR", "1.0.41")
|
||||
if CORE.is_libretiny or CORE.is_esp32:
|
||||
|
||||
@@ -319,7 +319,7 @@ void Inkplate::fill(Color color) {
|
||||
memset(this->partial_buffer_, fill, this->get_buffer_length_());
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time);
|
||||
ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time);
|
||||
}
|
||||
|
||||
void Inkplate::display() {
|
||||
|
||||
@@ -506,13 +506,13 @@ async def _late_logger_init(config: ConfigType) -> None:
|
||||
def validate_printf(value):
|
||||
# https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python
|
||||
cfmt = r"""
|
||||
( # start of capture group 1
|
||||
% # literal "%"
|
||||
(?:[-+0 #]{0,5}) # optional flags
|
||||
(?:\d+|\*)? # width
|
||||
(?:\.(?:\d+|\*))? # precision
|
||||
(?:h|l|ll|w|I|I32|I64)? # size
|
||||
[cCdiouxXeEfgGaAnpsSZ] # type
|
||||
( # start of capture group 1
|
||||
% # literal "%"
|
||||
(?:[-+0 #]{0,5}) # optional flags
|
||||
(?:\d+|\*)? # width
|
||||
(?:\.(?:\d+|\*))? # precision
|
||||
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
|
||||
[cCdiouxXeEfgGaAnpsSZ] # type
|
||||
)
|
||||
""" # noqa
|
||||
matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE)
|
||||
|
||||
@@ -55,6 +55,7 @@ from .automation import layers_to_code, lvgl_update
|
||||
from .defines import (
|
||||
CONF_ALIGN_TO_LAMBDA_ID,
|
||||
LOGGER,
|
||||
add_lv_use,
|
||||
get_focused_widgets,
|
||||
get_lv_images_used,
|
||||
get_refreshed_widgets,
|
||||
@@ -71,6 +72,7 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code
|
||||
from .lv_validation import lv_bool
|
||||
from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static
|
||||
from .schemas import (
|
||||
BASE_PROPS,
|
||||
DISP_BG_SCHEMA,
|
||||
FULL_STYLE_SCHEMA,
|
||||
STYLE_REMAP,
|
||||
@@ -100,6 +102,7 @@ from .widgets import (
|
||||
get_screen_active,
|
||||
set_obj_properties,
|
||||
)
|
||||
from .widgets.img import CONF_IMAGE
|
||||
|
||||
# Import only what we actually use directly in this file
|
||||
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
|
||||
@@ -433,6 +436,8 @@ async def to_code(configs):
|
||||
|
||||
# This must be done after all widgets are created
|
||||
styles_used = df.get_styles_used()
|
||||
if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used):
|
||||
add_lv_use(CONF_IMAGE)
|
||||
for use in df.get_lv_uses():
|
||||
df.add_define(f"LV_USE_{use.upper()}")
|
||||
cg.add_define(f"USE_LVGL_{use.upper()}")
|
||||
|
||||
@@ -9,13 +9,13 @@ CONF_IF_NAN = "if_nan"
|
||||
# noqa
|
||||
f_regex = re.compile(
|
||||
r"""
|
||||
( # start of capture group 1
|
||||
% # literal "%"
|
||||
[-+0 #]{0,5} # optional flags
|
||||
(?:\d+|\*)? # width
|
||||
(?:\.(?:\d+|\*))? # precision
|
||||
(?:h|l|ll|w|I|I32|I64)? # size
|
||||
f # type
|
||||
( # start of capture group 1
|
||||
% # literal "%"
|
||||
[-+0 #]{0,5} # optional flags
|
||||
(?:\d+|\*)? # width
|
||||
(?:\.(?:\d+|\*))? # precision
|
||||
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
|
||||
f # type
|
||||
)
|
||||
""",
|
||||
flags=re.VERBOSE,
|
||||
@@ -23,13 +23,13 @@ f_regex = re.compile(
|
||||
# noqa
|
||||
c_regex = re.compile(
|
||||
r"""
|
||||
( # start of capture group 1
|
||||
% # literal "%"
|
||||
[-+0 #]{0,5} # optional flags
|
||||
(?:\d+|\*)? # width
|
||||
(?:\.(?:\d+|\*))? # precision
|
||||
(?:h|l|ll|w|I|I32|I64)? # size
|
||||
[cCdiouxXeEfgGaAnpsSZ] # type
|
||||
( # start of capture group 1
|
||||
% # literal "%"
|
||||
[-+0 #]{0,5} # optional flags
|
||||
(?:\d+|\*)? # width
|
||||
(?:\.(?:\d+|\*))? # precision
|
||||
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
|
||||
[cCdiouxXeEfgGaAnpsSZ] # type
|
||||
)
|
||||
""",
|
||||
flags=re.VERBOSE,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
|
||||
lv_style_set_text_font(style, font->get_lv_font());
|
||||
}
|
||||
#endif
|
||||
#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE)
|
||||
#if LV_USE_IMAGE
|
||||
|
||||
#ifdef USE_IMAGE
|
||||
#ifdef USE_LVGL_IMAGE
|
||||
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
|
||||
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
|
||||
#endif // LV_USE_IMAGE
|
||||
|
||||
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
|
||||
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
|
||||
@@ -93,7 +93,8 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) {
|
||||
inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) {
|
||||
::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc());
|
||||
}
|
||||
#endif // USE_LVGL_IMAGE
|
||||
#endif
|
||||
|
||||
#ifdef USE_LVGL_ANIMIMG
|
||||
inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images) {
|
||||
auto *dsc = static_cast<std::vector<lv_image_dsc_t *> *>(lv_obj_get_user_data(img));
|
||||
@@ -109,6 +110,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
|
||||
lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size());
|
||||
}
|
||||
#endif // USE_LVGL_ANIMIMG
|
||||
#endif // USE_IMAGE
|
||||
|
||||
#ifdef USE_LVGL_METER
|
||||
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
|
||||
|
||||
@@ -9,6 +9,7 @@ from .defines import (
|
||||
CONF_THEME,
|
||||
LValidator,
|
||||
add_lv_use,
|
||||
get_styles_used,
|
||||
get_theme_widget_map,
|
||||
literal,
|
||||
)
|
||||
@@ -25,6 +26,7 @@ def has_style_props(config) -> bool:
|
||||
async def style_set(svar, style):
|
||||
for prop, validator in ALL_STYLES.items():
|
||||
if (value := style.get(prop)) is not None:
|
||||
get_styles_used().add(prop)
|
||||
if isinstance(validator, LValidator):
|
||||
value = await validator.process(value)
|
||||
if isinstance(value, list):
|
||||
|
||||
@@ -130,8 +130,8 @@ ClimateTraits AirConditioner::traits() {
|
||||
void AirConditioner::dump_config() {
|
||||
ESP_LOGCONFIG(Constants::TAG,
|
||||
"MideaDongle:\n"
|
||||
" [x] Period: %dms\n"
|
||||
" [x] Response timeout: %dms\n"
|
||||
" [x] Period: %" PRIu32 "ms\n"
|
||||
" [x] Response timeout: %" PRIu32 "ms\n"
|
||||
" [x] Request attempts: %d",
|
||||
this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts());
|
||||
#ifdef USE_REMOTE_TRANSMITTER
|
||||
|
||||
@@ -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_();
|
||||
|
||||
|
||||
@@ -210,7 +210,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() {
|
||||
ESP_LOGE(TAG, "Cannot resolve running app partition at address 0x%" PRIX32, running_app_offset);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
|
||||
}
|
||||
ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address,
|
||||
ESP_LOGD(TAG, "Copying running app from 0x%" PRIX32 " to 0x%" PRIX32 " (size: 0x%zX)", running_app_part->address,
|
||||
plan.copy_dest_part->address, running_app_size);
|
||||
err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size);
|
||||
if (err != ESP_OK) {
|
||||
@@ -261,7 +261,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() {
|
||||
ESP_LOGE(TAG, "Selected app partition not found after partition table update");
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
|
||||
}
|
||||
ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address);
|
||||
ESP_LOGD(TAG, "Setting next boot partition to 0x%" PRIX32, new_boot_partition->address);
|
||||
err = esp_ota_set_boot_partition(new_boot_partition);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err);
|
||||
|
||||
@@ -150,7 +150,7 @@ void IRAM_ATTR PulseMeterSensor::edge_intr(PulseMeterSensor *sensor) {
|
||||
edge_state.last_sent_edge_us_ = now;
|
||||
state.last_detected_edge_us_ = now;
|
||||
state.last_rising_edge_us_ = now;
|
||||
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
|
||||
state.count_ += 1;
|
||||
}
|
||||
|
||||
// This ISR is bound to rising edges, so the pin is high
|
||||
@@ -173,7 +173,7 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) {
|
||||
} else if (length && !pulse_state.latched_ && sensor->last_pin_val_) { // Long enough high edge
|
||||
pulse_state.latched_ = true;
|
||||
state.last_detected_edge_us_ = pulse_state.last_intr_;
|
||||
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
|
||||
state.count_ += 1;
|
||||
}
|
||||
|
||||
// Due to order of operations this includes
|
||||
|
||||
@@ -78,10 +78,10 @@ void RemoteReceiverComponent::setup() {
|
||||
void RemoteReceiverComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Remote Receiver:\n"
|
||||
" Buffer Size: %u\n"
|
||||
" Tolerance: %u%s\n"
|
||||
" Filter out pulses shorter than: %u us\n"
|
||||
" Signal is done after %u us of no changes",
|
||||
" Buffer Size: %" PRIu32 "\n"
|
||||
" Tolerance: %" PRIu32 "%s\n"
|
||||
" Filter out pulses shorter than: %" PRIu32 " us\n"
|
||||
" Signal is done after %" PRIu32 " us of no changes",
|
||||
this->buffer_size_, this->tolerance_,
|
||||
(this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", this->filter_us_,
|
||||
this->idle_us_);
|
||||
|
||||
@@ -153,7 +153,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) {
|
||||
LastPlayedServerPref pref{.server_id_hash = hash};
|
||||
bool ok = this->last_played_server_pref_.save(&pref);
|
||||
if (ok) {
|
||||
ESP_LOGD(TAG, "Persisted last played server hash: 0x%08X", hash);
|
||||
ESP_LOGD(TAG, "Persisted last played server hash: 0x%08" PRIX32, hash);
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Failed to persist last played server hash");
|
||||
}
|
||||
@@ -164,7 +164,7 @@ bool SendspinHub::save_last_server_hash(uint32_t hash) {
|
||||
std::optional<uint32_t> SendspinHub::load_last_server_hash() {
|
||||
LastPlayedServerPref pref{};
|
||||
if (this->last_played_server_pref_.load(&pref)) {
|
||||
ESP_LOGI(TAG, "Loaded last played server hash: 0x%08X", pref.server_id_hash);
|
||||
ESP_LOGI(TAG, "Loaded last played server hash: 0x%08" PRIX32, pref.server_id_hash);
|
||||
return pref.server_id_hash;
|
||||
}
|
||||
return std::nullopt;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -126,7 +126,7 @@ void Sim800LComponent::parse_cmd_(std::string message) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Else fall thru ...
|
||||
[[fallthrough]];
|
||||
}
|
||||
case STATE_CHECK_SMS:
|
||||
send_cmd_("AT+CMGL=\"ALL\"");
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace esphome::sound_level {
|
||||
|
||||
static const char *const TAG = "sound_level";
|
||||
|
||||
static const uint32_t AUDIO_BUFFER_DURATION_MS = 30;
|
||||
static const uint32_t MAX_FILL_DURATION_MS = 30;
|
||||
static const uint32_t RING_BUFFER_DURATION_MS = 120;
|
||||
|
||||
// Square INT16_MIN since INT16_MIN^2 > INT16_MAX^2
|
||||
@@ -30,8 +30,7 @@ void SoundLevelComponent::dump_config() {
|
||||
void SoundLevelComponent::setup() {
|
||||
this->microphone_source_->add_data_callback([this](const std::vector<uint8_t> &data) {
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (this->ring_buffer_.use_count() == 2) {
|
||||
// ``audio_buffer_`` and ``temp_ring_buffer`` share ownership of a ring buffer, so its safe/useful to write
|
||||
if (temp_ring_buffer != nullptr) {
|
||||
temp_ring_buffer->write((void *) data.data(), data.size());
|
||||
}
|
||||
});
|
||||
@@ -81,10 +80,11 @@ void SoundLevelComponent::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy data from ring buffer into the transfer buffer - don't block to avoid slowing the main loop
|
||||
this->audio_buffer_->transfer_data_from_source(0);
|
||||
// Expose a chunk of the ring buffer's internal storage - don't block to avoid slowing the main loop.
|
||||
// pre_shift is ignored by RingBufferAudioSource (no intermediate transfer buffer to compact).
|
||||
this->audio_source_->fill(0, false);
|
||||
|
||||
if (this->audio_buffer_->available() == 0) {
|
||||
if (this->audio_source_->available() == 0) {
|
||||
// No new audio available for processing
|
||||
return;
|
||||
}
|
||||
@@ -92,11 +92,11 @@ void SoundLevelComponent::loop() {
|
||||
const uint32_t samples_in_window =
|
||||
this->microphone_source_->get_audio_stream_info().ms_to_samples(this->measurement_duration_ms_);
|
||||
const uint32_t samples_available_to_process =
|
||||
this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_buffer_->available());
|
||||
this->microphone_source_->get_audio_stream_info().bytes_to_samples(this->audio_source_->available());
|
||||
const uint32_t samples_to_process = std::min(samples_in_window - this->sample_count_, samples_available_to_process);
|
||||
|
||||
// MicrophoneSource always provides int16 samples due to Python codegen settings
|
||||
const int16_t *audio_data = reinterpret_cast<const int16_t *>(this->audio_buffer_->get_buffer_start());
|
||||
const int16_t *audio_data = reinterpret_cast<const int16_t *>(this->audio_source_->data());
|
||||
|
||||
// Process all the new audio samples
|
||||
for (uint32_t i = 0; i < samples_to_process; ++i) {
|
||||
@@ -115,9 +115,8 @@ void SoundLevelComponent::loop() {
|
||||
++this->sample_count_;
|
||||
}
|
||||
|
||||
// Remove the processed samples from ``audio_buffer_``
|
||||
this->audio_buffer_->decrease_buffer_length(
|
||||
this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process));
|
||||
// Remove the processed samples from ``audio_source_``
|
||||
this->audio_source_->consume(this->microphone_source_->get_audio_stream_info().samples_to_bytes(samples_to_process));
|
||||
|
||||
if (this->sample_count_ == samples_in_window) {
|
||||
// Processed enough samples for the measurement window, compute and publish the sensor values
|
||||
@@ -158,36 +157,39 @@ void SoundLevelComponent::stop() {
|
||||
}
|
||||
|
||||
bool SoundLevelComponent::start_() {
|
||||
if (this->audio_buffer_ != nullptr) {
|
||||
if (this->audio_source_ != nullptr) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allocate a transfer buffer
|
||||
this->audio_buffer_ = audio::AudioSourceTransferBuffer::create(
|
||||
this->microphone_source_->get_audio_stream_info().ms_to_bytes(AUDIO_BUFFER_DURATION_MS));
|
||||
if (this->audio_buffer_ == nullptr) {
|
||||
this->status_momentary_error("transfer_buffer", 15000);
|
||||
const auto &stream_info = this->microphone_source_->get_audio_stream_info();
|
||||
const size_t bytes_per_frame = stream_info.frames_to_bytes(1);
|
||||
|
||||
// Allocate a ring buffer for the microphone callback to write into. Round the size down to a multiple
|
||||
// of bytes_per_frame so the wrap boundary stays frame-aligned and avoids unnecessary single-frame splices.
|
||||
this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation
|
||||
const size_t ring_buffer_size =
|
||||
(stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame;
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
this->status_momentary_error("ring_buffer", 15000);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allocates a new ring buffer, adds it as a source for the transfer buffer, and points ring_buffer_ to it
|
||||
this->ring_buffer_.reset(); // Reset pointer to any previous ring buffer allocation
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
|
||||
this->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS));
|
||||
if (temp_ring_buffer.use_count() == 0) {
|
||||
this->status_momentary_error("ring_buffer", 15000);
|
||||
this->stop_();
|
||||
// Zero-copy source that reads directly from the ring buffer's internal storage. Frame-aligned reads
|
||||
// ensure multi-channel frames are never split across the ring buffer's wrap boundary.
|
||||
this->audio_source_ = audio::RingBufferAudioSource::create(
|
||||
temp_ring_buffer, stream_info.ms_to_bytes(MAX_FILL_DURATION_MS), static_cast<uint8_t>(bytes_per_frame));
|
||||
if (this->audio_source_ == nullptr) {
|
||||
this->status_momentary_error("audio_source", 15000);
|
||||
return false;
|
||||
} else {
|
||||
this->ring_buffer_ = temp_ring_buffer;
|
||||
this->audio_buffer_->set_source(temp_ring_buffer);
|
||||
}
|
||||
this->ring_buffer_ = temp_ring_buffer;
|
||||
|
||||
this->status_clear_error();
|
||||
return true;
|
||||
}
|
||||
|
||||
void SoundLevelComponent::stop_() { this->audio_buffer_.reset(); }
|
||||
void SoundLevelComponent::stop_() { this->audio_source_.reset(); }
|
||||
|
||||
} // namespace esphome::sound_level
|
||||
|
||||
|
||||
@@ -36,11 +36,12 @@ class SoundLevelComponent : public Component {
|
||||
void stop();
|
||||
|
||||
protected:
|
||||
/// @brief Internal start command that, if necessary, allocates ``audio_buffer_`` and a ring buffer which
|
||||
/// ``audio_buffer_`` owns and ``ring_buffer_`` points to. Returns true if allocations were successful.
|
||||
/// @brief Internal start command that, if necessary, allocates a ring buffer and a zero-copy
|
||||
/// ``RingBufferAudioSource`` that reads directly from it. ``ring_buffer_`` weakly references the
|
||||
/// ring buffer owned by ``audio_source_``. Returns true if allocations were successful.
|
||||
bool start_();
|
||||
|
||||
/// @brief Internal stop command the deallocates ``audio_buffer_`` (which automatically deallocates its ring buffer)
|
||||
/// @brief Internal stop command that deallocates ``audio_source_`` (which releases its ring buffer)
|
||||
void stop_();
|
||||
|
||||
microphone::MicrophoneSource *microphone_source_{nullptr};
|
||||
@@ -48,7 +49,7 @@ class SoundLevelComponent : public Component {
|
||||
sensor::Sensor *peak_sensor_{nullptr};
|
||||
sensor::Sensor *rms_sensor_{nullptr};
|
||||
|
||||
std::unique_ptr<audio::AudioSourceTransferBuffer> audio_buffer_;
|
||||
std::unique_ptr<audio::RingBufferAudioSource> audio_source_;
|
||||
std::weak_ptr<ring_buffer::RingBuffer> ring_buffer_;
|
||||
|
||||
int32_t squared_peak_{0};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from esphome import final_validate as fv
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32
|
||||
from esphome.components.esp32 import (
|
||||
@@ -8,7 +9,7 @@ from esphome.components.esp32 import (
|
||||
add_idf_sdkconfig_option,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.const import CONF_HARDWARE_UART, CONF_ID
|
||||
|
||||
CODEOWNERS = ["@kbx81"]
|
||||
CONFLICTS_WITH = ["usb_host"]
|
||||
@@ -20,6 +21,13 @@ CONF_USB_PRODUCT_STR = "usb_product_str"
|
||||
CONF_USB_SERIAL_STR = "usb_serial_str"
|
||||
CONF_USB_VENDOR_ID = "usb_vendor_id"
|
||||
|
||||
# Components that provide a USB device class (CDC, HID, MSC, ...) on top of
|
||||
# tinyusb. Configuring `tinyusb:` without any of these triggers a 5s hang in
|
||||
# esp_tinyusb's driver install (descriptors_set fails with no class and no
|
||||
# user-provided full_speed_config), which trips the task watchdog before
|
||||
# loop() ever runs.
|
||||
_USB_CLASS_COMPONENTS = ("usb_cdc_acm",)
|
||||
|
||||
tinyusb_ns = cg.esphome_ns.namespace("tinyusb")
|
||||
TinyUSB = tinyusb_ns.class_("TinyUSB", cg.Component)
|
||||
|
||||
@@ -41,6 +49,29 @@ CONFIG_SCHEMA = cv.All(
|
||||
)
|
||||
|
||||
|
||||
def _final_validate(config):
|
||||
full_config = fv.full_config.get()
|
||||
if not any(name in full_config for name in _USB_CLASS_COMPONENTS):
|
||||
raise cv.Invalid(
|
||||
"The 'tinyusb' component requires at least one USB class component"
|
||||
)
|
||||
# tinyusb owns the USB OTG peripheral. The logger's USB_CDC backend routes
|
||||
# the ROM console through that same peripheral, so the two cannot coexist.
|
||||
# (USB_SERIAL_JTAG is a separate peripheral and is fine alongside tinyusb.)
|
||||
logger_config = full_config.get("logger")
|
||||
if logger_config and logger_config.get(CONF_HARDWARE_UART) == "USB_CDC":
|
||||
raise cv.Invalid(
|
||||
"'tinyusb' cannot be used with 'logger.hardware_uart: USB_CDC' "
|
||||
"because both share the USB OTG peripheral. Set "
|
||||
"'logger.hardware_uart' to a hardware UART (e.g. UART0), or to "
|
||||
"USB_SERIAL_JTAG on variants that support it (ESP32-S3, ESP32-P4)"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -26,6 +26,21 @@ void TinyUSB::setup() {
|
||||
.string_count = SIZE,
|
||||
};
|
||||
|
||||
// Defense-in-depth: esp_tinyusb's tinyusb_descriptors_set() fails with
|
||||
// ESP_ERR_INVALID_ARG when no configuration descriptor is provided and
|
||||
// no class that has a built-in default (CDC/MSC/NCM) is compiled in. In
|
||||
// that case the internal task exits without notifying us, and
|
||||
// tinyusb_driver_install() blocks 5s on the notify-take -- long enough
|
||||
// to trip the task watchdog. Bail early so the rest of the device can
|
||||
// still boot.
|
||||
#if !(CFG_TUD_CDC > 0 || CFG_TUD_MSC > 0 || CFG_TUD_NCM > 0)
|
||||
if (this->tusb_cfg_.descriptor.full_speed_config == nullptr) {
|
||||
ESP_LOGE(TAG, "No USB class configured");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
esp_err_t result = tinyusb_driver_install(&this->tusb_cfg_);
|
||||
if (result != ESP_OK) {
|
||||
ESP_LOGE(TAG, "tinyusb_driver_install failed: %s", esp_err_to_name(result));
|
||||
|
||||
@@ -72,7 +72,7 @@ void TotalDailyEnergy::schedule_midnight_reset_() {
|
||||
timeout_seconds = seconds_until_midnight + 1;
|
||||
}
|
||||
|
||||
ESP_LOGD(TAG, "Scheduling midnight check in %us", timeout_seconds);
|
||||
ESP_LOGD(TAG, "Scheduling midnight check in %" PRIu32 "s", timeout_seconds);
|
||||
this->set_timeout(TIMEOUT_ID_MIDNIGHT, timeout_seconds * MILLIS_PER_SECOND,
|
||||
[this]() { this->schedule_midnight_reset_(); });
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -684,8 +684,10 @@ void Tuya::set_numeric_datapoint_value_(uint8_t datapoint_id, TuyaDatapointType
|
||||
case 4:
|
||||
data.push_back(value >> 24);
|
||||
data.push_back(value >> 16);
|
||||
[[fallthrough]];
|
||||
case 2:
|
||||
data.push_back(value >> 8);
|
||||
[[fallthrough]];
|
||||
case 1:
|
||||
data.push_back(value >> 0);
|
||||
break;
|
||||
|
||||
@@ -135,7 +135,7 @@ void Tx20Component::decode_and_publish_() {
|
||||
}
|
||||
if (tx20_se == tx20_sb) {
|
||||
tx20_wind_direction = tx20_se;
|
||||
if (tx20_wind_direction >= 0 && tx20_wind_direction < 16) {
|
||||
if (tx20_wind_direction < 16) {
|
||||
wind_cardinal_direction_ = DIRECTIONS[tx20_wind_direction];
|
||||
}
|
||||
ESP_LOGV(TAG, "WindDirection %d", tx20_wind_direction);
|
||||
@@ -164,7 +164,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) {
|
||||
}
|
||||
arg->buffer[arg->buffer_index] = 1;
|
||||
arg->start_time = now;
|
||||
arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile)
|
||||
arg->buffer_index += 1;
|
||||
return;
|
||||
}
|
||||
const uint32_t delay = now - arg->start_time;
|
||||
@@ -195,7 +195,7 @@ void IRAM_ATTR Tx20ComponentStore::gpio_intr(Tx20ComponentStore *arg) {
|
||||
}
|
||||
arg->spent_time += delay;
|
||||
arg->start_time = now;
|
||||
arg->buffer_index++; // NOLINT(clang-diagnostic-deprecated-volatile)
|
||||
arg->buffer_index += 1;
|
||||
}
|
||||
void IRAM_ATTR Tx20ComponentStore::reset() {
|
||||
tx20_available = false;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -135,7 +135,7 @@ class USBUartChannel : public uart::UARTComponent, public Parented<USBUartCompon
|
||||
// Computed as ceil(buffer_size / 64) + 1 in Python codegen; defaults to 5 (256 / 64 + 1).
|
||||
static constexpr uint8_t USB_OUTPUT_CHUNK_COUNT = USB_UART_OUTPUT_CHUNK_COUNT;
|
||||
|
||||
USBUartChannel(uint8_t index, uint16_t buffer_size) : index_(index), input_buffer_(RingBuffer(buffer_size)) {}
|
||||
USBUartChannel(uint8_t index, uint16_t buffer_size) : input_buffer_(RingBuffer(buffer_size)), index_(index) {}
|
||||
void write_array(const uint8_t *data, size_t len) override;
|
||||
bool peek_byte(uint8_t *data) override;
|
||||
bool read_array(uint8_t *data, size_t len) override;
|
||||
|
||||
@@ -611,7 +611,7 @@ static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const c
|
||||
}
|
||||
|
||||
// Helper to get request detail parameter
|
||||
static JsonDetail get_request_detail(AsyncWebServerRequest *request) {
|
||||
[[maybe_unused]] static JsonDetail get_request_detail(AsyncWebServerRequest *request) {
|
||||
return request->arg(ESPHOME_F("detail")) == "all" ? DETAIL_ALL : DETAIL_STATE;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ namespace {
|
||||
* - HTTPD_SOCK_ERR_TIMEOUT if the send buffer is full (EAGAIN/EWOULDBLOCK).
|
||||
* - HTTPD_SOCK_ERR_FAIL for other errors.
|
||||
*/
|
||||
int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) {
|
||||
[[maybe_unused]] int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_len, int flags) {
|
||||
if (buf == nullptr) {
|
||||
return HTTPD_SOCK_ERR_INVALID;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ static const char *const KEYS = "0123456789*#";
|
||||
void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) {
|
||||
if (arg->d0.digital_read())
|
||||
return;
|
||||
arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile)
|
||||
arg->count += 1;
|
||||
arg->value <<= 1;
|
||||
arg->last_bit_time = millis();
|
||||
arg->done = false;
|
||||
@@ -20,7 +20,7 @@ void IRAM_ATTR HOT WiegandStore::d0_gpio_intr(WiegandStore *arg) {
|
||||
void IRAM_ATTR HOT WiegandStore::d1_gpio_intr(WiegandStore *arg) {
|
||||
if (arg->d1.digital_read())
|
||||
return;
|
||||
arg->count++; // NOLINT(clang-diagnostic-deprecated-volatile)
|
||||
arg->count += 1;
|
||||
arg->value = (arg->value << 1) | 1;
|
||||
arg->last_bit_time = millis();
|
||||
arg->done = false;
|
||||
|
||||
@@ -54,10 +54,18 @@ from esphome.const import (
|
||||
CONF_TTLS_PHASE_2,
|
||||
CONF_USE_ADDRESS,
|
||||
CONF_USERNAME,
|
||||
CONF_WIFI,
|
||||
PLACEHOLDER_WIFI_SSID,
|
||||
Platform,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority
|
||||
from esphome.core import (
|
||||
CORE,
|
||||
CoroPriority,
|
||||
EsphomeError,
|
||||
HexInt,
|
||||
coroutine_with_priority,
|
||||
)
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
@@ -903,3 +911,45 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
|
||||
"wifi_component_pico_w.cpp": {PlatformFramework.RP2040_ARDUINO},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _placeholder_wifi_credentials(config: ConfigType) -> list[str]:
|
||||
"""Return human-readable locations where the dashboard's placeholder wifi
|
||||
values still appear. Empty list means no placeholders were found.
|
||||
"""
|
||||
placeholders: list[str] = []
|
||||
wifi_conf = config.get(CONF_WIFI)
|
||||
if not wifi_conf:
|
||||
return placeholders
|
||||
|
||||
for idx, network in enumerate(wifi_conf.get(CONF_NETWORKS, [])):
|
||||
ssid = network.get(CONF_SSID)
|
||||
if isinstance(ssid, str) and ssid == PLACEHOLDER_WIFI_SSID:
|
||||
placeholders.append(f"wifi.networks[{idx}].ssid")
|
||||
|
||||
ap_conf = wifi_conf.get(CONF_AP)
|
||||
if ap_conf:
|
||||
ap_ssid = ap_conf.get(CONF_SSID)
|
||||
if isinstance(ap_ssid, str) and ap_ssid == PLACEHOLDER_WIFI_SSID:
|
||||
placeholders.append("wifi.ap.ssid")
|
||||
|
||||
return placeholders
|
||||
|
||||
|
||||
def check_placeholder_credentials(config: ConfigType) -> None:
|
||||
"""Raise EsphomeError if any wifi credential is the dashboard placeholder.
|
||||
|
||||
Call only at compile time. NEVER from CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA,
|
||||
or any path reached by `esphome config`; device-builder relies on
|
||||
validation passing with the placeholders still in place.
|
||||
"""
|
||||
locations = _placeholder_wifi_credentials(config)
|
||||
if not locations:
|
||||
return
|
||||
formatted = ", ".join(locations)
|
||||
raise EsphomeError(
|
||||
f"wifi configuration still contains the dashboard placeholder value "
|
||||
f"'{PLACEHOLDER_WIFI_SSID}' at: {formatted}. "
|
||||
f"Open secrets.yaml and replace 'wifi_ssid' (and 'wifi_password') "
|
||||
f"with your real wifi credentials before flashing."
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
@@ -1415,3 +1416,12 @@ ENTITY_CATEGORY_DIAGNOSTIC = "diagnostic"
|
||||
# The corresponding constant exists in c++
|
||||
# when update_interval is set to never, it becomes SCHEDULER_DONT_RUN milliseconds
|
||||
SCHEDULER_DONT_RUN = 4294967295
|
||||
|
||||
# Sentinel values written by the esphome-device-builder dashboard into
|
||||
# secrets.yaml on first boot so that !secret wifi_ssid / !secret wifi_password
|
||||
# references resolve cleanly through validation before the user has finished
|
||||
# the onboarding wizard. Compilation refuses if these reach the binary so that
|
||||
# a user who dismisses onboarding can't accidentally flash a device that will
|
||||
# never associate with their wifi.
|
||||
PLACEHOLDER_WIFI_SSID = "REPLACE_WITH_YOUR_WIFI_NETWORK"
|
||||
PLACEHOLDER_WIFI_PASSWORD = "REPLACE_WITH_YOUR_WIFI_PASSWORD" # noqa: S105
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,16 +26,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())
|
||||
|
||||
|
||||
@@ -67,10 +69,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/espressif/esp-idf/releases/download/v{VERSION}/esp-idf-v{VERSION}.zip;https://github.com/espressif/esp-idf/releases/download/v{MAJOR}.{MINOR}/esp-idf-v{MAJOR}.{MINOR}.zip",
|
||||
)
|
||||
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(
|
||||
|
||||
@@ -10,7 +10,7 @@ dependencies:
|
||||
esphome/micro-flac:
|
||||
version: 0.2.0
|
||||
esphome/micro-mp3:
|
||||
version: 0.2.0
|
||||
version: 0.2.1
|
||||
esphome/micro-opus:
|
||||
version: 0.4.1
|
||||
esphome/micro-wav:
|
||||
@@ -36,7 +36,7 @@ dependencies:
|
||||
rules:
|
||||
- if: "target in [esp32h2, esp32p4]"
|
||||
espressif/esp_hosted:
|
||||
version: 2.12.6
|
||||
version: 2.12.7
|
||||
rules:
|
||||
- if: "target in [esp32h2, esp32p4]"
|
||||
zorxx/multipart-parser:
|
||||
|
||||
+12
-1
@@ -273,10 +273,21 @@ class StorageJSON:
|
||||
"""
|
||||
CORE.name = self.name
|
||||
CORE.build_path = self.build_path
|
||||
target_platform = self.core_platform or self.target_platform.lower()
|
||||
CORE.data[KEY_CORE] = {
|
||||
KEY_TARGET_PLATFORM: self.core_platform or self.target_platform.lower(),
|
||||
KEY_TARGET_PLATFORM: target_platform,
|
||||
KEY_TARGET_FRAMEWORK: self.framework,
|
||||
}
|
||||
# The compile pipeline populates CORE.data[KEY_ESP32] when esp32's
|
||||
# validator runs; on the cache fast path that validator is skipped,
|
||||
# so populate the variant upload_using_esptool reads via
|
||||
# esp32.get_esp32_variant(). target_platform on disk is the variant
|
||||
# (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32").
|
||||
if target_platform == const.PLATFORM_ESP32:
|
||||
from esphome.components.esp32.const import KEY_ESP32
|
||||
from esphome.const import KEY_VARIANT
|
||||
|
||||
CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform}
|
||||
|
||||
def __eq__(self, o) -> bool:
|
||||
return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict()
|
||||
|
||||
@@ -87,6 +87,21 @@ def replace_file_content(text, pattern, repl):
|
||||
|
||||
|
||||
def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
|
||||
"""Return True when the build tree must be wiped before reuse.
|
||||
|
||||
Predicate is True when *old* is missing (first build),
|
||||
``src_version`` differs, ``build_path`` differs, or a previously
|
||||
loaded integration was removed in *new*. Adding integrations or
|
||||
changing unrelated fields (friendly name, esphome version, etc.)
|
||||
does not trigger a clean.
|
||||
|
||||
Used by esphome-device-builder (esphome/device-builder) to gate
|
||||
its remote-build artifact materialiser so a local → remote → local
|
||||
cycle preserves PlatformIO's local object cache instead of wiping
|
||||
it on every cycle. The signature, semantics, and ``None`` handling
|
||||
for *old* are part of the public contract; keep them stable so the
|
||||
offloader's wipe decision tracks core's.
|
||||
"""
|
||||
if old is None:
|
||||
return True
|
||||
|
||||
|
||||
@@ -2,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):
|
||||
|
||||
+4
-4
@@ -12,19 +12,19 @@ platformio==6.1.19
|
||||
esptool==5.2.0
|
||||
click==8.3.3
|
||||
esphome-dashboard==20260425.0
|
||||
aioesphomeapi==45.0.0
|
||||
zeroconf==0.148.0
|
||||
aioesphomeapi==45.0.3
|
||||
zeroconf==0.149.7
|
||||
puremagic==1.30
|
||||
ruamel.yaml==0.19.1 # dashboard_import
|
||||
ruamel.yaml.clib==0.2.15 # dashboard_import
|
||||
esphome-glyphsets==0.2.0
|
||||
pillow==12.2.0
|
||||
resvg-py==0.3.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.13 # also change in .pre-commit-config.yaml when updating
|
||||
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
|
||||
pre-commit
|
||||
|
||||
@@ -16,7 +16,7 @@ hypothesis==6.92.1
|
||||
# CodSpeed benchmarks under tests/benchmarks/python/
|
||||
# (skipped via pytest.importorskip when missing -- only required for the
|
||||
# benchmarks job in .github/workflows/ci.yml)
|
||||
pytest-codspeed==5.0.1
|
||||
pytest-codspeed==5.0.2
|
||||
|
||||
# Used by the import-time regression check (.github/workflows/ci.yml → import-time job)
|
||||
importtime-waterfall==1.0.0
|
||||
|
||||
+60
-19
@@ -1062,22 +1062,42 @@ 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
|
||||
)
|
||||
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 +1126,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 +1179,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 +1215,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
|
||||
@@ -1219,6 +1259,7 @@ def main() -> None:
|
||||
"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,
|
||||
|
||||
@@ -29,12 +29,12 @@ esp32_ble_tracker:
|
||||
- service_uuid: ABCD
|
||||
then:
|
||||
- lambda: !lambda |-
|
||||
ESP_LOGD("main", "Length of service data is %i", x.size());
|
||||
ESP_LOGD("main", "Length of service data is %zu", x.size());
|
||||
on_ble_manufacturer_data_advertise:
|
||||
- manufacturer_id: ABCD
|
||||
then:
|
||||
- lambda: !lambda |-
|
||||
ESP_LOGD("main", "Length of manufacturer data is %i", x.size());
|
||||
ESP_LOGD("main", "Length of manufacturer data is %zu", x.size());
|
||||
on_scan_end:
|
||||
- then:
|
||||
- lambda: |-
|
||||
|
||||
@@ -123,7 +123,7 @@ select:
|
||||
- lambda: |-
|
||||
id(uart_bus).flush();
|
||||
uint32_t new_baud_rate = stoi(x);
|
||||
ESP_LOGD("change_baud_rate", "Changing baud rate from %i to %i",id(uart_bus).get_baud_rate(), new_baud_rate);
|
||||
ESP_LOGD("change_baud_rate", "Changing baud rate from %" PRIu32 " to %" PRIu32, id(uart_bus).get_baud_rate(), new_baud_rate);
|
||||
if (id(uart_bus).get_baud_rate() != new_baud_rate) {
|
||||
id(uart_bus).set_baud_rate(new_baud_rate);
|
||||
#if defined(USE_ESP8266) || defined(USE_ESP32)
|
||||
|
||||
@@ -660,13 +660,13 @@ lvgl:
|
||||
on_release:
|
||||
logger.log:
|
||||
format: Button released at %d/%d
|
||||
args: [point.x, point.y]
|
||||
args: ['(int) point.x', '(int) point.y']
|
||||
on_long_press_repeat:
|
||||
logger.log: Button clicked
|
||||
on_pressing:
|
||||
logger.log:
|
||||
format: Button pressing at %d/%d
|
||||
args: [point.x, point.y]
|
||||
args: ['(int) point.x', '(int) point.y']
|
||||
on_press_lost:
|
||||
logger.log: Button press lost
|
||||
on_single_click:
|
||||
@@ -944,7 +944,7 @@ lvgl:
|
||||
on_release:
|
||||
logger.log:
|
||||
format: Slider released at %d/%d with value %.0f
|
||||
args: [point.x, point.y, x]
|
||||
args: ['(int) point.x', '(int) point.y', x]
|
||||
- button:
|
||||
styles: spin_button
|
||||
id: spin_up
|
||||
|
||||
@@ -21,7 +21,7 @@ modbus_server:
|
||||
read_lambda: |-
|
||||
return 31;
|
||||
write_lambda: |-
|
||||
printf("address=%d, value=%d", x);
|
||||
printf("address=%d, value=%" PRId32 "\n", (int) address, x);
|
||||
return true;
|
||||
- id: modbus_server4
|
||||
modbus_id: mod_bus2
|
||||
|
||||
@@ -64,7 +64,7 @@ mqtt:
|
||||
topic: some/topic
|
||||
payload: Good-bye
|
||||
- lambda: |-
|
||||
ESP_LOGD("MQTT", "Disconnect reason %d", reason);
|
||||
ESP_LOGD("MQTT", "Disconnect reason %d", (int) reason);
|
||||
publish_nan_as_none: false
|
||||
|
||||
binary_sensor:
|
||||
|
||||
@@ -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!");
|
||||
@@ -299,7 +298,7 @@ display:
|
||||
- lambda: |-
|
||||
// key: StringRef, value: int32_t
|
||||
if (key == "temperature_raw") {
|
||||
ESP_LOGD("nextion.custom", "%s=%d", key.c_str(), value);
|
||||
ESP_LOGD("nextion.custom", "%s=%" PRId32, key.c_str(), value);
|
||||
}
|
||||
on_custom_binary_sensor:
|
||||
then:
|
||||
|
||||
@@ -12,7 +12,7 @@ on_brennenstuhl:
|
||||
then:
|
||||
- logger.log:
|
||||
format: "on_brennenstuhl: %u"
|
||||
args: ["x.code"]
|
||||
args: ["(unsigned) x.code"]
|
||||
on_aeha:
|
||||
then:
|
||||
- logger.log:
|
||||
|
||||
@@ -49,7 +49,7 @@ script:
|
||||
then:
|
||||
- lambda: |-
|
||||
ESP_LOGD("main", "ints=%d floats=%f bools=%d strings=%s",
|
||||
ints[0], floats[0], bools[0], strings[0].c_str());
|
||||
ints[0], floats[0], (int) bools[0], strings[0].c_str());
|
||||
- id: my_script_with_params
|
||||
parameters:
|
||||
prefix: string
|
||||
|
||||
@@ -6,3 +6,8 @@ tinyusb:
|
||||
usb_product_str: ESPHomeTestProduct
|
||||
usb_serial_str: ESPHomeTestSerialNumber
|
||||
usb_vendor_id: 0x2345
|
||||
|
||||
# tinyusb requires at least one USB class companion; usb_cdc_acm satisfies that.
|
||||
usb_cdc_acm:
|
||||
interfaces:
|
||||
- id: tinyusb_test_cdc
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
<<: !include common.yaml
|
||||
|
||||
# S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared
|
||||
# USB OTG peripheral; route the logger to UART0 so the fixture builds.
|
||||
logger:
|
||||
hardware_uart: UART0
|
||||
|
||||
@@ -11,7 +11,7 @@ udp:
|
||||
- "10.0.0.255"
|
||||
on_receive:
|
||||
- logger.log:
|
||||
format: "Received %d bytes"
|
||||
format: "Received %zu bytes"
|
||||
args: [data.size()]
|
||||
- udp.write:
|
||||
id: my_udp
|
||||
|
||||
@@ -4,7 +4,7 @@ udp:
|
||||
addresses: ["239.0.60.53"]
|
||||
on_receive:
|
||||
- logger.log:
|
||||
format: "Received %d bytes"
|
||||
format: "Received %zu bytes"
|
||||
args: [data.size()]
|
||||
- udp.write:
|
||||
id: my_udp
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<<: !include tinyusb_common.yaml
|
||||
|
||||
# S2 defaults logger to USB_CDC, which conflicts with tinyusb on the shared
|
||||
# USB OTG peripheral; route the logger to UART0 so the fixture builds.
|
||||
logger:
|
||||
hardware_uart: UART0
|
||||
|
||||
usb_cdc_acm:
|
||||
interfaces:
|
||||
- id: usb_cdc_acm1
|
||||
|
||||
@@ -1518,6 +1518,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 +1530,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 +1588,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 +1600,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 +2610,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()
|
||||
|
||||
@@ -7,6 +7,9 @@ esp32:
|
||||
variant: ESP32S3
|
||||
framework:
|
||||
type: esp-idf
|
||||
# Use custom partition table with larger app partition (3MB)
|
||||
# Default IDF partitions only allow 1.75MB which is too small for grouped tests
|
||||
partitions: ../partitions_testing.csv
|
||||
|
||||
logger:
|
||||
level: VERY_VERBOSE
|
||||
|
||||
@@ -11,10 +11,12 @@ import pytest
|
||||
from esphome.components.esp32 import (
|
||||
KEY_COMPONENTS,
|
||||
KEY_ESP32,
|
||||
KEY_IDF_VERSION,
|
||||
KEY_PATH,
|
||||
KEY_REF,
|
||||
KEY_REPO,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import KEY_CORE
|
||||
from esphome.core import CORE
|
||||
|
||||
@@ -24,7 +26,10 @@ def _reset_core(tmp_path: Path) -> None:
|
||||
"""Give each test its own CORE.build_path and a clean esp32 data slot."""
|
||||
CORE.build_path = str(tmp_path)
|
||||
CORE.data.setdefault(KEY_CORE, {})
|
||||
CORE.data[KEY_ESP32] = {KEY_COMPONENTS: {}}
|
||||
CORE.data[KEY_ESP32] = {
|
||||
KEY_COMPONENTS: {},
|
||||
KEY_IDF_VERSION: cv.Version(5, 5, 4),
|
||||
}
|
||||
|
||||
|
||||
def _write_project_description(tmp_path: Path, components: dict[str, str]) -> None:
|
||||
|
||||
@@ -3,8 +3,20 @@
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import const
|
||||
from esphome.components.wifi import has_native_wifi, variant_has_wifi
|
||||
from esphome.const import Platform
|
||||
from esphome.components.wifi import (
|
||||
check_placeholder_credentials,
|
||||
has_native_wifi,
|
||||
variant_has_wifi,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_AP,
|
||||
CONF_NETWORKS,
|
||||
CONF_SSID,
|
||||
CONF_WIFI,
|
||||
PLACEHOLDER_WIFI_SSID,
|
||||
Platform,
|
||||
)
|
||||
from esphome.core import EsphomeError, Lambda
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -123,3 +135,65 @@ def test_has_native_wifi_esp32_without_variant_assumes_wifi() -> None:
|
||||
def test_has_native_wifi_rp2040_without_board_assumes_wifi() -> None:
|
||||
"""RP2040 without a board id falls open to True (custom-board default)."""
|
||||
assert has_native_wifi(platform=Platform.RP2040) is True
|
||||
|
||||
|
||||
def _wifi_config(
|
||||
*,
|
||||
networks: list[dict] | None = None,
|
||||
ap: dict | None = None,
|
||||
) -> dict:
|
||||
"""Build a minimal config dict matching the post-validation shape."""
|
||||
wifi: dict = {}
|
||||
if networks is not None:
|
||||
wifi[CONF_NETWORKS] = networks
|
||||
if ap is not None:
|
||||
wifi[CONF_AP] = ap
|
||||
return {CONF_WIFI: wifi}
|
||||
|
||||
|
||||
def test_check_placeholder_credentials_passes_with_real_ssid() -> None:
|
||||
"""A real SSID compiles without complaint."""
|
||||
config = _wifi_config(networks=[{CONF_SSID: "home_network"}])
|
||||
assert check_placeholder_credentials(config) is None
|
||||
|
||||
|
||||
def test_check_placeholder_credentials_refuses_placeholder_ssid() -> None:
|
||||
"""The placeholder SSID is rejected with an actionable message."""
|
||||
config = _wifi_config(networks=[{CONF_SSID: PLACEHOLDER_WIFI_SSID}])
|
||||
with pytest.raises(EsphomeError) as exc_info:
|
||||
check_placeholder_credentials(config)
|
||||
message = str(exc_info.value)
|
||||
assert "wifi.networks[0].ssid" in message
|
||||
assert "secrets.yaml" in message
|
||||
|
||||
|
||||
def test_check_placeholder_credentials_refuses_placeholder_in_second_network() -> None:
|
||||
"""Index reporting picks the placeholder out of a mixed network list."""
|
||||
config = _wifi_config(
|
||||
networks=[
|
||||
{CONF_SSID: "home_network"},
|
||||
{CONF_SSID: PLACEHOLDER_WIFI_SSID},
|
||||
],
|
||||
)
|
||||
with pytest.raises(EsphomeError) as exc_info:
|
||||
check_placeholder_credentials(config)
|
||||
assert "wifi.networks[1].ssid" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_check_placeholder_credentials_refuses_placeholder_ap_ssid() -> None:
|
||||
"""An AP using the placeholder broadcast name is also refused."""
|
||||
config = _wifi_config(ap={CONF_SSID: PLACEHOLDER_WIFI_SSID})
|
||||
with pytest.raises(EsphomeError) as exc_info:
|
||||
check_placeholder_credentials(config)
|
||||
assert "wifi.ap.ssid" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_check_placeholder_credentials_no_wifi_passes() -> None:
|
||||
"""Ethernet-only / wifi-less configs skip the check entirely."""
|
||||
assert check_placeholder_credentials({}) is None
|
||||
|
||||
|
||||
def test_check_placeholder_credentials_skips_template_ssid() -> None:
|
||||
"""A templated (Lambda) SSID is not a string and is skipped."""
|
||||
config = _wifi_config(networks=[{CONF_SSID: Lambda('return "x";')}])
|
||||
assert check_placeholder_credentials(config) is 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(
|
||||
|
||||
@@ -22,6 +22,7 @@ from esphome.const import (
|
||||
KEY_CORE,
|
||||
KEY_TARGET_FRAMEWORK,
|
||||
KEY_TARGET_PLATFORM,
|
||||
KEY_VARIANT,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
|
||||
@@ -47,7 +48,12 @@ wifi:
|
||||
"""
|
||||
|
||||
|
||||
def _write_storage(storage_path: Path) -> None:
|
||||
def _write_storage(
|
||||
storage_path: Path,
|
||||
*,
|
||||
esp_platform: str = "ESP32",
|
||||
core_platform: str | None = "esp32",
|
||||
) -> None:
|
||||
"""Write a vanilla StorageJSON sidecar for the cache tests."""
|
||||
storage_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = {
|
||||
@@ -59,14 +65,14 @@ def _write_storage(storage_path: Path) -> None:
|
||||
"src_version": 1,
|
||||
"address": "192.168.1.42",
|
||||
"web_port": None,
|
||||
"esp_platform": "ESP32",
|
||||
"esp_platform": esp_platform,
|
||||
"build_path": "/build/lite_test",
|
||||
"firmware_bin_path": "/build/lite_test/firmware.bin",
|
||||
"loaded_integrations": ["api", "logger", "ota", "wifi"],
|
||||
"loaded_platforms": [],
|
||||
"no_mdns": False,
|
||||
"framework": "arduino",
|
||||
"core_platform": "esp32",
|
||||
"core_platform": core_platform,
|
||||
}
|
||||
storage_path.write_text(json.dumps(data))
|
||||
|
||||
@@ -123,6 +129,50 @@ def test_load_compiled_config_happy_path(fresh_cache_files: Path) -> None:
|
||||
assert CORE.build_path == Path("/build/lite_test")
|
||||
assert CORE.data[KEY_CORE][KEY_TARGET_PLATFORM] == "esp32"
|
||||
assert CORE.data[KEY_CORE][KEY_TARGET_FRAMEWORK] == "arduino"
|
||||
# upload_using_esptool reads get_esp32_variant() off CORE.data[KEY_ESP32].
|
||||
from esphome.components.esp32.const import KEY_ESP32
|
||||
|
||||
assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32"
|
||||
|
||||
|
||||
def test_load_compiled_config_populates_esp32_variant(tmp_path: Path) -> None:
|
||||
"""ESP32 variants survive the cache fast path so esptool gets the right --chip."""
|
||||
from esphome.components.esp32.const import KEY_ESP32
|
||||
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
_write_storage(storage_dir / "lite_test.yaml.json", esp_platform="ESP32S3")
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
|
||||
_set_cache_mtime(cache, yaml_path, offset=5)
|
||||
|
||||
assert load_compiled_config(yaml_path) is not None
|
||||
assert CORE.data[KEY_ESP32][KEY_VARIANT] == "ESP32S3"
|
||||
|
||||
|
||||
def test_load_compiled_config_skips_esp32_block_for_other_platforms(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Non-esp32 targets shouldn't fabricate an esp32 data block."""
|
||||
from esphome.components.esp32.const import KEY_ESP32
|
||||
|
||||
yaml_path = tmp_path / "lite_test.yaml"
|
||||
yaml_path.write_text("esphome:\n name: lite_test\n")
|
||||
CORE.config_path = yaml_path
|
||||
|
||||
storage_dir = tmp_path / ".esphome" / "storage"
|
||||
_write_storage(
|
||||
storage_dir / "lite_test.yaml.json",
|
||||
esp_platform="ESP8266",
|
||||
core_platform="esp8266",
|
||||
)
|
||||
cache = _write_cache(storage_dir / "lite_test.yaml.validated.yaml")
|
||||
_set_cache_mtime(cache, yaml_path, offset=5)
|
||||
|
||||
assert load_compiled_config(yaml_path) is not None
|
||||
assert KEY_ESP32 not in CORE.data
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -966,3 +970,215 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user