Compare commits

..

1 Commits

Author SHA1 Message Date
J. Nick Koston 6c42a27a14 [dashboard] Test PR for deprecation-comment bot
Trivial docstring tweak under esphome/dashboard/ to verify that the
workflow added in #16378 posts the expected comment. Will be closed
without merging.
2026-05-12 14:43:10 -05:00
234 changed files with 1698 additions and 8450 deletions
+4 -14
View File
@@ -398,23 +398,13 @@ This document provides essential context for AI models interacting with this pro
│ ├── i2c/ # I2C bus
│ └── spi/ # SPI bus
└── components/[component]/
├── common.yaml # Component-only config (no bus definitions)
├── test.esp32-idf.yaml # config + compile
├── test.esp8266-ard.yaml # config + compile
── test-variant.esp32-idf.yaml # variant test, config + compile
├── validate.esp32-idf.yaml # config-only (never compiled)
└── validate-legacy.esp32-idf.yaml # config-only variant
├── common.yaml # Component-only config (no bus definitions)
├── test.esp32-idf.yaml
├── test.esp8266-ard.yaml
── test.rp2040-ard.yaml
```
Run them using `script/test_build_components`. Use `-c <component>` to test specific components and `-t <target>` for specific platforms.
* **Config-only test files (`validate.*.yaml`):** Use this prefix when a YAML file only needs to exercise schema/validation paths and does not need to be compiled. CI runs `validate.*.yaml` files with `esphome config` only and skips them during compile. The grammar mirrors `test.*.yaml`:
- `validate.<platform>.yaml` — base config-only test
- `validate-<variant>.<platform>.yaml` — config-only variant
Use this for things like deprecated-syntax migration tests, schema edge cases, or platform-specific validation branches where building firmware adds no signal. A component may have any mix of `test.*.yaml` and `validate.*.yaml` files. Validate files never participate in bus-grouping; each one runs as its own `esphome config` invocation.
When a PR's only edits to a component are `validate.*.yaml` files (no source changes, no `test.*.yaml` changes, and the component isn't pulled in as a dependency of another changed component), CI skips the compile stage for that component entirely and only runs config validation. This is decided in `script/determine-jobs.py` via `_component_change_is_validate_only` and surfaced as the `validate_only_components` output that the `test-build-components-split` job consumes.
* **Test Grouping with Packages:** Components that use shared bus packages can be grouped together in CI to reduce build count. **Never define buses (uart, i2c, spi, modbus) directly in test YAML files** — always use packages from `test_build_components/common/`:
```yaml
# test.esp32-idf.yaml — use packages for buses
+1
View File
@@ -116,6 +116,7 @@ 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
View File
@@ -1 +1 @@
27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f
96c95feaa60831da5f43e3c6a7c7a3a237e17c5d12995a730dbc3884c8dcd11c
+2 -2
View File
@@ -47,7 +47,7 @@ runs:
- name: Build and push to ghcr by digest
id: build-ghcr
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
@@ -73,7 +73,7 @@ runs:
- name: Build and push to dockerhub by digest
id: build-dockerhub
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
env:
DOCKER_BUILD_SUMMARY: false
DOCKER_BUILD_RECORD_UPLOAD: false
+4 -16
View File
@@ -27,18 +27,6 @@ runs:
path: venv
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ inputs.cache-key }}
- name: Set up uv
# Only needed on cache miss to populate the venv. ``uv pip install``
# detects the activated venv via ``VIRTUAL_ENV`` so the venv layout
# downstream jobs rely on is preserved.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
shell: bash
@@ -46,8 +34,8 @@ runs:
python -m venv venv
source venv/bin/activate
python --version
uv pip install -r requirements.txt -r requirements_test.txt
uv pip install -e .
pip install -r requirements.txt -r requirements_test.txt
pip install -e .
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os == 'Windows'
shell: bash
@@ -55,5 +43,5 @@ runs:
python -m venv venv
source ./venv/Scripts/activate
python --version
uv pip install -r requirements.txt -r requirements_test.txt
uv pip install -e .
pip install -r requirements.txt -r requirements_test.txt
pip install -e .
+1 -1
View File
@@ -1 +1 @@
../AGENTS.md
../.ai/instructions.md
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+1 -11
View File
@@ -26,16 +26,6 @@ jobs:
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Set up uv
# ``--system`` (below) installs into the setup-python interpreter;
# no venv is created or restored by this workflow.
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install apt dependencies
run: |
@@ -44,7 +34,7 @@ jobs:
sudo apt install -y protobuf-compiler
protoc --version
- name: Install python dependencies
run: uv pip install --system aioesphomeapi -c requirements.txt -r requirements_dev.txt
run: pip install aioesphomeapi -c requirements.txt -r requirements_dev.txt
- name: Generate files
run: script/api_protobuf/api_protobuf.py
- name: Check for changes
+1 -1
View File
@@ -48,7 +48,7 @@ jobs:
with:
python-version: "3.11"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Set TAG
run: |
+30 -125
View File
@@ -6,6 +6,14 @@ on:
branches: [dev, beta, release]
pull_request:
paths:
- "**"
- "!.github/workflows/*.yml"
- "!.github/actions/build-image/*"
- ".github/workflows/ci.yml"
- "!.yamllint"
- "!.github/dependabot.yml"
- "!docker/**"
merge_group:
permissions:
@@ -44,26 +52,14 @@ jobs:
path: venv
# yamllint disable-line rule:line-length
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ steps.cache-key.outputs.key }}
- name: Set up uv
# Only needed on cache miss to populate the venv. ``uv pip install``
# detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs
# that ``. venv/bin/activate`` see an identical layout.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
python -m venv venv
. venv/bin/activate
python --version
uv pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
uv pip install -e .
pip install -r requirements.txt -r requirements_dev.txt -r requirements_test.txt pre-commit
pip install -e .
pylint:
name: Check pylint
@@ -93,8 +89,6 @@ jobs:
runs-on: ubuntu-24.04
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -173,10 +167,6 @@ jobs:
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install device-builder + esphome from PR
# Install device-builder with its esphome + test extras
# first so its pinned versions of pytest/etc. land, then
@@ -191,7 +181,7 @@ jobs:
# own CI). No ``--cov`` here -- this is purely a downstream
# smoke check against this PR's esphome code.
working-directory: device-builder
run: pytest -q -n auto --maxfail=5 --durations=30 --no-cov --ignore=tests/benchmarks
run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks
pytest:
name: Run pytest
@@ -217,8 +207,6 @@ jobs:
runs-on: ${{ matrix.os }}
needs:
- common
- determine-jobs
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -234,14 +222,14 @@ jobs:
if: matrix.os == 'windows-latest'
run: |
. ./venv/Scripts/activate.ps1
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
- name: Run pytest
if: matrix.os == 'ubuntu-latest' || matrix.os == 'macOS-latest'
run: |
. venv/bin/activate
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/
- name: Upload coverage to Codecov
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
- name: Save Python virtual environment cache
@@ -257,17 +245,13 @@ jobs:
needs:
- common
outputs:
core-ci: ${{ steps.determine.outputs.core-ci }}
integration-tests: ${{ steps.determine.outputs.integration-tests }}
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
clang-tidy-full-scan: ${{ steps.determine.outputs.clang-tidy-full-scan }}
python-linters: ${{ steps.determine.outputs.python-linters }}
import-time: ${{ steps.determine.outputs.import-time }}
device-builder: ${{ steps.determine.outputs.device-builder }}
native-idf: ${{ steps.determine.outputs.native-idf }}
native-idf-components: ${{ steps.determine.outputs.native-idf-components }}
changed-components: ${{ steps.determine.outputs.changed-components }}
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
@@ -277,7 +261,6 @@ jobs:
cpp-unit-tests-run-all: ${{ steps.determine.outputs.cpp-unit-tests-run-all }}
cpp-unit-tests-components: ${{ steps.determine.outputs.cpp-unit-tests-components }}
component-test-batches: ${{ steps.determine.outputs.component-test-batches }}
validate-only-components: ${{ steps.determine.outputs.validate-only-components }}
benchmarks: ${{ steps.determine.outputs.benchmarks }}
steps:
- name: Check out code from GitHub
@@ -301,27 +284,18 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
. venv/bin/activate
EXTRA_ARGS=""
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ci-run-all') }}" == "true" ]]; then
EXTRA_ARGS="--force-all"
echo "::notice::ci-run-all label detected -- forcing every CI job to run"
fi
output=$(python script/determine-jobs.py $EXTRA_ARGS)
output=$(python script/determine-jobs.py)
echo "Test determination output:"
echo "$output" | jq
# Extract individual fields
echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
echo "clang-tidy-full-scan=$(echo "$output" | jq -r '.clang_tidy_full_scan')" >> $GITHUB_OUTPUT
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
echo "native-idf=$(echo "$output" | jq -r '.native_idf')" >> $GITHUB_OUTPUT
echo "native-idf-components=$(echo "$output" | jq -r '.native_idf_components')" >> $GITHUB_OUTPUT
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
@@ -331,7 +305,6 @@ jobs:
echo "cpp-unit-tests-run-all=$(echo "$output" | jq -r '.cpp_unit_tests_run_all')" >> $GITHUB_OUTPUT
echo "cpp-unit-tests-components=$(echo "$output" | jq -c '.cpp_unit_tests_components')" >> $GITHUB_OUTPUT
echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT
echo "validate-only-components=$(echo "$output" | jq -c '.validate_only_components')" >> $GITHUB_OUTPUT
echo "benchmarks=$(echo "$output" | jq -r '.benchmarks')" >> $GITHUB_OUTPUT
- name: Save components graph cache
if: github.ref == 'refs/heads/dev'
@@ -365,24 +338,14 @@ jobs:
with:
path: venv
key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }}
- name: Set up uv
# Only needed on cache miss to populate the venv.
if: steps.cache-venv.outputs.cache-hit != 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Create Python virtual environment
if: steps.cache-venv.outputs.cache-hit != 'true'
run: |
python -m venv venv
. venv/bin/activate
python --version
uv pip install -r requirements.txt -r requirements_test.txt
uv pip install -e .
pip install -r requirements.txt -r requirements_test.txt
pip install -e .
- name: Register matcher
run: echo "::add-matcher::.github/workflows/matchers/pytest.json"
- name: Run integration tests
@@ -394,7 +357,7 @@ jobs:
. venv/bin/activate
mapfile -t test_files < <(echo "$BUCKET_TESTS" | jq -r '.[]')
echo "Bucket ${{ matrix.bucket.name }}: running ${#test_files[@]} integration tests"
pytest -vv --no-cov --tb=native --durations=30 -n auto "${test_files[@]}"
pytest -vv --no-cov --tb=native -n auto "${test_files[@]}"
cpp-unit-tests:
name: Run C++ unit tests
@@ -454,10 +417,7 @@ jobs:
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1
with:
run: |
. venv/bin/activate
${{ steps.build.outputs.binary }}
pytest tests/benchmarks/python/ --codspeed --no-cov
run: ${{ steps.build.outputs.binary }}
mode: simulation
clang-tidy-single:
@@ -531,13 +491,7 @@ jobs:
id: check_full_scan
run: |
. venv/bin/activate
# 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
if python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -549,7 +503,7 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
echo "Running FULL clang-tidy scan (hash changed)"
script/clang-tidy --all-headers --fix ${{ matrix.options }} ${{ matrix.ignore_errors && '|| true' || '' }}
else
echo "Running clang-tidy on changed files only"
@@ -609,13 +563,7 @@ jobs:
id: check_full_scan
run: |
. venv/bin/activate
# 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
if python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -627,7 +575,7 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
echo "Running FULL clang-tidy scan (hash changed)"
script/clang-tidy --all-headers --fix --environment esp32-arduino-tidy
else
echo "Running clang-tidy on changed files only"
@@ -704,13 +652,7 @@ jobs:
id: check_full_scan
run: |
. venv/bin/activate
# 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
if python script/clang_tidy_hash.py --check; then
echo "full_scan=true" >> $GITHUB_OUTPUT
echo "reason=hash_changed" >> $GITHUB_OUTPUT
else
@@ -722,7 +664,7 @@ jobs:
run: |
. venv/bin/activate
if [ "${{ steps.check_full_scan.outputs.full_scan }}" = "true" ]; then
echo "Running FULL clang-tidy scan (reason: ${{ steps.check_full_scan.outputs.reason }})"
echo "Running FULL clang-tidy scan (hash changed)"
script/clang-tidy --all-headers --fix ${{ matrix.options }}
else
echo "Running clang-tidy on changed files only"
@@ -833,45 +775,13 @@ jobs:
echo "Config validation passed! Starting compilation..."
echo ""
# Compute the compile-stage component list. Components whose only
# changes are validate.*.yaml files are config-only -- their source
# and test fixtures didn't move, so rebuilding firmware adds no
# signal. Subtract them from this batch before invoking compile.
validate_only_json='${{ needs.determine-jobs.outputs.validate-only-components }}'
if [ -z "$validate_only_json" ]; then
validate_only_json='[]'
fi
if ! validate_only_csv=$(echo "$validate_only_json" | jq -r 'join(",")'); then
echo "::error::Failed to render validate-only-components as CSV from: $validate_only_json"
exit 1
fi
if [ -z "$validate_only_csv" ]; then
compile_csv="$components_csv"
else
components_sorted=$(echo "$components_csv" | tr ',' '\n' | sort -u)
validate_sorted=$(echo "$validate_only_csv" | tr ',' '\n' | sort -u)
if ! diff_out=$(comm -23 <(echo "$components_sorted") <(echo "$validate_sorted")); then
echo "::error::Failed to compute compile component subset."
exit 1
fi
compile_csv=$(echo "$diff_out" | paste -sd ',' -)
skipped=$(comm -12 <(echo "$components_sorted") <(echo "$validate_sorted") | paste -sd ',' -)
if [ -n "$skipped" ]; then
echo "Validate-only components in this batch (skipping compile): $skipped"
fi
fi
# Show disk space before compilation
echo "Disk space before compilation:"
df -h
echo ""
if [ -n "$compile_csv" ]; then
# Run compilation with grouping and isolation
python3 script/test_build_components.py -e compile -c "$compile_csv" -f --isolate "$directly_changed_csv"
else
echo "All components in this batch are validate-only -- skipping compile stage."
fi
# Run compilation with grouping and isolation
python3 script/test_build_components.py -e compile -c "$components_csv" -f --isolate "$directly_changed_csv"
test-native-idf:
name: Test components with native ESP-IDF
@@ -879,14 +789,10 @@ jobs:
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && needs.determine-jobs.outputs.native-idf == 'true'
if: github.event_name == 'pull_request'
env:
ESPHOME_ESP_IDF_PREFIX: ~/.esphome-idf
# Comma-joined subset of the native-IDF representative component list,
# computed by script/determine-jobs.py (native_idf_components_to_test).
# Single source of truth -- the full list lives in
# script/determine-jobs.py::NATIVE_IDF_TEST_COMPONENTS.
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }}
TEST_COMPONENTS: esp32,api,heatpumpir,bme280_i2c,bh1750,aht10,esp32_ble,esp32_ble_beacon,esp32_ble_client,esp32_ble_server,esp32_ble_tracker,ble_client,ble_presence,ble_rssi,ble_scanner
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -967,8 +873,7 @@ jobs:
runs-on: ubuntu-latest
needs:
- common
- determine-jobs
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -35,7 +35,7 @@ jobs:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+2 -2
View File
@@ -56,7 +56,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
category: "/language:${{matrix.language}}"
@@ -12,16 +12,10 @@ jobs:
dashboard-deprecation-comment:
name: Dashboard deprecation comment
runs-on: ubuntu-latest
# Release-bump PRs (bump-X.Y.Z -> beta, beta -> release) inevitably
# roll up everything merged into dev since the last cut, which can
# include dashboard changes that have already been reviewed once.
# The bot's purpose is to warn new contributors before they invest
# time -- that only applies to PRs entering dev.
if: github.event.pull_request.base.ref == 'dev'
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
+9 -11
View File
@@ -29,11 +29,10 @@ jobs:
} = require('./.github/scripts/detect-tags.js');
const title = context.payload.pull_request.title;
const user = context.payload.pull_request.user;
const author = context.payload.pull_request.user.login;
// Skip bot PRs (e.g. dependabot, esphome[bot] device-class sync) -
// they have their own title formats.
if (user.type === 'Bot') {
// Skip bot PRs (e.g. dependabot) - they have their own title format
if (author === 'dependabot[bot]') {
return;
}
@@ -69,15 +68,14 @@ jobs:
return;
}
// Check for MDX syntax characters not wrapped in backticks.
// Astro docs MDX treats bare `<` as JSX component opening tags and
// bare `{` as JS expressions, so both must be escaped in changelog entries.
// Check for angle brackets not wrapped in backticks.
// Astro docs MDX treats bare < as JSX component opening tags.
const stripped = title.replace(/`[^`]*`/g, '');
if (/[<>{}]/.test(stripped)) {
if (/[<>]/.test(stripped)) {
core.setFailed(
'PR title contains `<`, `>`, `{`, or `}` not wrapped in backticks.\n' +
'Astro docs MDX interprets bare `<` as JSX components and bare `{` as JS expressions.\n' +
'Please wrap these characters with backticks, e.g.: [component] Add `<feature>` support'
'PR title contains `<` or `>` not wrapped in backticks.\n' +
'Astro docs MDX interprets bare `<` as JSX components.\n' +
'Please wrap angle brackets with backticks, e.g.: [component] Add `<feature>` support'
);
return;
}
+76 -8
View File
@@ -99,15 +99,15 @@ jobs:
python-version: "3.11"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to docker hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -178,17 +178,17 @@ jobs:
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Log in to docker hub
if: matrix.registry == 'dockerhub'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Log in to the GitHub container registry
if: matrix.registry == 'ghcr'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -212,6 +212,74 @@ jobs:
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
$(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *)
deploy-ha-addon-repo:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs:
- init
- deploy-manifest
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: home-assistant-addon
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
let description = "ESPHome";
if (context.eventName == "release") {
description = ${{ toJSON(github.event.release.body) }};
}
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "home-assistant-addon",
workflow_id: "bump-version.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
content: description
}
})
deploy-esphome-schema:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
needs: [init]
environment: ${{ needs.init.outputs.deploy_env }}
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
owner: esphome
repositories: esphome-schema
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
- name: Trigger Workflow
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "esphome-schema",
workflow_id: "generate-schemas.yml",
ref: "main",
inputs: {
version: "${{ needs.init.outputs.tag }}",
}
})
version-notifier:
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
runs-on: ubuntu-latest
@@ -221,7 +289,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -234,7 +302,7 @@ jobs:
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
await github.rest.actions.createWorkflowDispatch({
github.rest.actions.createWorkflowDispatch({
owner: "esphome",
repo: "version-notifier",
workflow_id: "notify.yml",
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Stale
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
remove-stale-when-updated: true
+7 -44
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Generate a token
id: generate-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
@@ -41,56 +41,19 @@ jobs:
with:
python-version: "3.14"
- name: Set up uv
# An order of magnitude faster than pip on cold boots, with its
# own wheel cache. ``--system`` (below) installs into the
# setup-python interpreter so subsequent ``pre-commit`` /
# ``script/run-in-env.py`` steps find the deps without a
# ``uv run`` prefix.
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
# Pin uv version so the action does not have to fetch the
# manifest from raw.githubusercontent.com on every cache
# miss; that fetch flakes on Windows runners.
version: "0.11.15"
- name: Install Home Assistant
run: |
uv pip install --system -e lib/home-assistant
uv pip install --system -r requirements.txt -r requirements_test.txt pre-commit
python -m pip install --upgrade pip
pip install -e lib/home-assistant
pip install -r requirements_test.txt pre-commit
- name: Sync
run: |
python ./script/sync-device_class.py
- 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: Run pre-commit hooks
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
+1 -1
View File
@@ -11,7 +11,7 @@ ci:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.15.14
rev: v0.15.12
hooks:
# Run the linter.
- id: ruff
+1 -1
View File
@@ -1 +1 @@
AGENTS.md
.ai/instructions.md
+1 -1
View File
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.6.0-dev
PROJECT_NUMBER = 2026.5.0-dev
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a
+1 -1
View File
@@ -1 +1 @@
AGENTS.md
.ai/instructions.md
+3 -7
View File
@@ -13,16 +13,12 @@ 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.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.
# (e.g., ruamel.yaml.clibz used by ESP-IDF's idf-component-manager)
RUN if command -v apk > /dev/null; then \
apk add --no-cache build-base libusb; \
apk add --no-cache build-base; \
else \
apt-get update \
&& apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
&& apt-get install -y --no-install-recommends build-essential \
&& rm -rf /var/lib/apt/lists/*; \
fi
+3 -59
View File
@@ -50,7 +50,6 @@ from esphome.const import (
CONF_TOPIC,
CONF_USERNAME,
CONF_WEB_SERVER,
CONF_WIFI,
ENV_NOGITIGNORE,
KEY_CORE,
KEY_TARGET_PLATFORM,
@@ -734,13 +733,6 @@ def write_cpp_file() -> int:
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
# Keep this gate here, NOT in config validation: device-builder needs
# `esphome config` to keep succeeding with placeholders so onboarding can run.
if CONF_WIFI in config:
from esphome.components.wifi import check_placeholder_credentials
check_placeholder_credentials(config)
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
# If you change this format, update the regex in that script as well
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
@@ -1423,15 +1415,6 @@ def command_config(args: ArgsProtocol, config: ConfigType) -> int | None:
return 0
def command_config_hash(args: ArgsProtocol, config: ConfigType) -> int | None:
# generating code might modify config, so it must be done in order to generate
# a hash that will match what was generated when compiling and then running
# on the device
generate_cpp_contents(config)
safe_print(f"0x{CORE.config_hash:08x}")
return 0
def command_vscode(args: ArgsProtocol) -> int | None:
from esphome import vscode
@@ -1967,7 +1950,6 @@ PRE_CONFIG_ACTIONS = {
POST_CONFIG_ACTIONS = {
"config": command_config,
"config-hash": command_config_hash,
"compile": command_compile,
"upload": command_upload,
"logs": command_logs,
@@ -2081,13 +2063,6 @@ def parse_args(argv):
"--show-secrets", help="Show secrets in output.", action="store_true"
)
parser_config_hash = subparsers.add_parser(
"config-hash", help="Calculate the hash of the configuration."
)
parser_config_hash.add_argument(
"configuration", help="Your YAML configuration file(s).", nargs="+"
)
parser_compile = subparsers.add_parser(
"compile", help="Read the configuration and compile a program."
)
@@ -2442,41 +2417,10 @@ def run_esphome(argv):
# Commands that don't need fresh external components: logs just connects
# to the device, and clean is about to delete the build directory.
skip_external = args.command in ("logs", "clean")
command_line_substitutions = dict(args.substitution) if args.substitution else {}
# Fast path for upload/logs: reuse the validated-config cache the
# last compile wrote. Falls back to read_config when missing/stale.
# Skipped when -s overrides are passed, since the cache was written
# against the previous substitution set.
config: ConfigType | None = None
cache_eligible = (
args.command in ("upload", "logs") and not command_line_substitutions
config = read_config(
dict(args.substitution) if args.substitution else {},
skip_external_update=skip_external,
)
if cache_eligible:
from esphome.compiled_config import load_compiled_config
config = load_compiled_config(conf_path)
if config is not None:
_LOGGER.info(
"Loaded validated config cache for %s, skipping validation.",
conf_path.name,
)
if config is None:
config = read_config(
command_line_substitutions,
skip_external_update=skip_external,
)
# Refresh the cache so the next upload/logs hits the fast path
# instead of re-running read_config. Skip when the storage
# sidecar is absent (no compile has run): the cache would
# never be loaded back, so writing secrets to disk is wasted.
if cache_eligible and config is not None:
from esphome.compiled_config import save_compiled_config
from esphome.storage_json import ext_storage_path
if ext_storage_path(conf_path.name).exists():
save_compiled_config(config)
if config is None:
return 2
CORE.config = config
+39 -111
View File
@@ -3,22 +3,18 @@
import json
from pathlib import Path
from esphome.components.esp32 import get_esp32_variant, idf_version
import esphome.config_validation as cv
from esphome.components.esp32 import get_esp32_variant
from esphome.core import CORE
from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.writer import update_storage_json
def get_available_components() -> list[str] | None:
"""Get list of built-in ESP-IDF components from project_description.json.
"""Get list of available ESP-IDF components from project_description.json.
Excludes ``src``, IDF-managed components (``managed_components/``), and
converted PIO libs (``pio_components/``). Returns ``None`` if the build
dir or ``project_description.json`` isn't ready yet.
Returns only internal ESP-IDF components, excluding external/managed
components (from idf_component.yml).
"""
if CORE.build_path is None:
return None
project_desc = Path(CORE.build_path) / "build" / "project_description.json"
if not project_desc.exists():
return None
@@ -35,9 +31,9 @@ def get_available_components() -> list[str] | None:
if name == "src":
continue
# Exclude IDF-managed and converted-PIO components (external).
# Exclude managed/external components
comp_dir = info.get("dir", "")
if "managed_components" in comp_dir or "pio_components" in comp_dir:
if "managed_components" in comp_dir:
continue
result.append(name)
@@ -52,68 +48,17 @@ def has_discovered_components() -> bool:
return get_available_components() is not None
def get_project_cmakelists(minimal: bool = False) -> str:
"""Generate the top-level CMakeLists.txt for ESP-IDF project.
When ``minimal`` is true, omit ``ESPHOME_PROJECT_BUILTIN_COMPONENTS``
since ``project_description.json`` may be stale on the first write.
"""
def get_project_cmakelists() -> str:
"""Generate the top-level CMakeLists.txt for ESP-IDF project."""
# Get IDF target from ESP32 variant (e.g., ESP32S3 -> esp32s3)
variant = get_esp32_variant()
idf_target = variant.lower().replace("-", "")
# esp_idf_size 2.x (bundled with IDF >=6.0) made NG the default and
# removed the --ng flag; on 1.x (IDF 5.5) --ng is required to get
# --format=raw because the legacy mode doesn't support it.
size_ng_flag = "--ng" if idf_version() < cv.Version(6, 0, 0) else ""
# Project-wide compile options: -D defines and -W warning flags (skip
# -Wl, linker flags — those go on the src component via
# target_link_options below). Emitted via idf_build_set_property so the
# flags propagate to every IDF component (including managed ones like
# esphome__micro-mp3) rather than just src/. Required so suppressions
# like ``-Wno-error=maybe-uninitialized`` actually silence warnings in
# third-party components we don't author.
project_compile_opts = [
flag
for flag in sorted(CORE.build_flags)
if flag.startswith("-D")
or (flag.startswith("-W") and not flag.startswith("-Wl,"))
]
# Extract compile definitions from build flags (-DXXX -> XXX)
compile_defs = [flag for flag in sorted(CORE.build_flags) if flag.startswith("-D")]
extra_compile_options = "\n".join(
f'idf_build_set_property(COMPILE_OPTIONS "{flag}" APPEND)'
for flag in project_compile_opts
)
# Per-project list exposed as a CMake variable so converted PIO libs
# can reference ${ESPHOME_PROJECT_MANAGED_COMPONENTS} without baking
# project-specific names into their cached CMakeLists.
#
# Emit via idf_build_set_property (not plain set()) so the value is
# serialised into build_properties.temp.cmake and visible to IDF's
# early requirements-expansion pass (component_get_requirements.cmake
# runs as a separate CMake script invocation that doesn't load the
# project's top-level CMakeLists; without this, ${ESPHOME_PROJECT_
# MANAGED_COMPONENTS} in a converted-lib REQUIRES expands to empty).
from esphome.components.esp32 import get_managed_component_require_names
managed_components_property = "\n".join(
f"idf_build_set_property(ESPHOME_PROJECT_MANAGED_COMPONENTS {name} APPEND)"
for name in get_managed_component_require_names()
)
# Built-in IDF components exposed via our own property (not IDF's
# __COMPONENT_REQUIRES_COMMON, which would append them to every
# component's REQUIRES including real IDF components). Referenced by
# src/CMakeLists and by each converted PIO lib's CMakeLists. Skipped
# on minimal writes because project_description.json may be stale.
builtin_components_property = (
""
if minimal
else "\n".join(
f"idf_build_set_property(ESPHOME_PROJECT_BUILTIN_COMPONENTS {name} APPEND)"
for name in sorted(get_available_components() or [])
)
f'idf_build_set_property(COMPILE_OPTIONS "{compile_def}" APPEND)'
for compile_def in compile_defs
)
return f"""\
@@ -143,67 +88,50 @@ include($ENV{{IDF_PATH}}/tools/cmake/project.cmake)
{extra_compile_options}
{managed_components_property}
{builtin_components_property}
project({CORE.name})
# Emit raw JSON size data for ESPHome to read post-build.
add_custom_command(
TARGET ${{CMAKE_PROJECT_NAME}}.elf POST_BUILD
COMMAND ${{PYTHON}} -m esp_idf_size {size_ng_flag} --format=raw
-o ${{CMAKE_BINARY_DIR}}/esp_idf_size.json
${{CMAKE_PROJECT_NAME}}.map
WORKING_DIRECTORY ${{CMAKE_BINARY_DIR}}
VERBATIM
)
"""
def get_component_cmakelists() -> str:
"""Generate the main component CMakeLists.txt.
def get_component_cmakelists(minimal: bool = False) -> str:
"""Generate the main component CMakeLists.txt."""
idf_requires = [] if minimal else (get_available_components() or [])
requires_str = " ".join(idf_requires)
REQUIRES pulls in the discovered built-in IDF components via the
project-level variables set in the top-level CMakeLists.
"""
# Extract linker options (-Wl, flags). Compile flags (-D, -W) are
# emitted project-wide via idf_build_set_property in
# get_project_cmakelists so they reach every component, not just src/.
# Extract compile options (-W flags, excluding linker flags)
compile_opts = [
flag
for flag in CORE.build_flags
if flag.startswith("-W") and not flag.startswith("-Wl,")
]
compile_opts_str = "\n ".join(sorted(compile_opts)) if compile_opts else ""
# Extract linker options (-Wl, flags)
link_opts = [flag for flag in CORE.build_flags if flag.startswith("-Wl,")]
link_opts_str = "\n ".join(sorted(link_opts)) if link_opts else ""
return f"""\
# Auto-generated by ESPHome
# CONFIGURE_DEPENDS asks CMake to re-check the glob each build so test
# runs that reuse the build dir don't compile stale source paths. It's
# invalid in script mode (cmake -P), which is how IDF's
# component_get_requirements.cmake includes us, so skip it there.
if(CMAKE_SCRIPT_MODE_FILE)
file(GLOB_RECURSE app_sources
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
else()
file(GLOB_RECURSE app_sources CONFIGURE_DEPENDS
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
endif()
file(GLOB_RECURSE app_sources
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/*.c"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.cpp"
"${{CMAKE_CURRENT_SOURCE_DIR}}/esphome/*.c"
)
idf_component_register(
SRCS ${{app_sources}}
INCLUDE_DIRS "." "esphome"
REQUIRES ${{ESPHOME_PROJECT_BUILTIN_COMPONENTS}}
REQUIRES {requires_str}
)
# Apply C++ standard
target_compile_features(${{COMPONENT_LIB}} PUBLIC cxx_std_20)
# ESPHome compile options
target_compile_options(${{COMPONENT_LIB}} PUBLIC
{compile_opts_str}
)
# ESPHome linker options
target_link_options(${{COMPONENT_LIB}} PUBLIC
{link_opts_str}
@@ -224,11 +152,11 @@ def write_project(minimal: bool = False) -> None:
# Write top-level CMakeLists.txt
write_file_if_changed(
CORE.relative_build_path("CMakeLists.txt"),
get_project_cmakelists(minimal=minimal),
get_project_cmakelists(),
)
# Write component CMakeLists.txt in src/
write_file_if_changed(
CORE.relative_src_path("CMakeLists.txt"),
get_component_cmakelists(),
get_component_cmakelists(minimal=minimal),
)
+85 -12
View File
@@ -260,20 +260,42 @@ class ConfigBundleCreator:
def _discover_yaml_includes(self) -> None:
"""Discover YAML files loaded during config parsing.
Delegates to :func:`yaml_util.discover_user_yaml_files`, which does a
fresh re-parse and force-loads every deferred ``IncludeFile`` so that
*all* potentially-reachable includes are captured (even branches not
selected by local substitutions). Bundles are meant to be compiled on
another system where command-line substitution overrides may choose a
different branch — e.g. ``!include network/${eth_model}/config.yaml``
must ship every candidate so the remote build can pick any one.
Deliberately uses a fresh re-parse and force-loads every deferred
``IncludeFile`` to include *all* potentially-reachable includes,
even branches not selected by the local substitutions. Bundles are
meant to be compiled on another system where command-line
substitution overrides may choose a different branch — e.g.
``!include network/${eth_model}/config.yaml`` must ship every
candidate so the remote build can pick any one.
Entries with unresolved substitution variables in the filename
path are skipped with a warning (they cannot be resolved without
the substitution pass).
Secrets files are tracked separately so we can filter them to
only include the keys this config actually references.
"""
discovered = yaml_util.discover_user_yaml_files(self._config_path)
self._secrets_paths.update(discovered.secrets)
config_resolved = self._config_path.resolve()
for fpath in discovered.files:
if fpath == config_resolved:
# Must be a fresh parse: IncludeFile.load() caches its result in
# _content, and we discover files by listening for loader calls. On
# an already-parsed tree the cache is populated, .load() returns
# without calling the loader, the listener never fires, and the
# referenced files would be silently dropped from the bundle.
with yaml_util.track_yaml_loads() as loaded_files:
try:
data = yaml_util.load_yaml(self._config_path)
except EsphomeError:
_LOGGER.debug(
"Bundle: re-loading YAML for include discovery failed, "
"proceeding with partial file list"
)
else:
_force_load_include_files(data)
for fpath in loaded_files:
if fpath == self._config_path.resolve():
continue # Already added as config
if fpath.name in const.SECRETS_FILES:
self._secrets_paths.add(fpath)
self._add_file(fpath)
def _discover_component_files(self) -> None:
@@ -603,6 +625,57 @@ def _add_bytes_to_tar(tar: tarfile.TarFile, name: str, data: bytes) -> None:
tar.addfile(info, io.BytesIO(data))
def _force_load_include_files(obj: Any, _seen: set[int] | None = None) -> None:
"""Recursively resolve any ``IncludeFile`` instances in a YAML tree.
Nested ``!include`` returns a deferred ``IncludeFile`` that is only
resolved during the substitution pass. During bundle discovery we need
the referenced files to actually load so the ``track_yaml_loads``
listener fires for them.
``IncludeFile`` instances with unresolved substitution variables in the
filename cannot be loaded — we skip and warn about those.
"""
if _seen is None:
_seen = set()
if isinstance(obj, yaml_util.IncludeFile):
if id(obj) in _seen:
return
_seen.add(id(obj))
if obj.has_unresolved_expressions():
_LOGGER.warning(
"Bundle: cannot resolve !include %s (referenced from %s) "
"with substitutions in path",
obj.file,
obj.parent_file,
)
return
try:
loaded = obj.load()
except EsphomeError as err:
_LOGGER.warning(
"Bundle: failed to load !include %s (referenced from %s): %s",
obj.file,
obj.parent_file,
err,
)
return
_force_load_include_files(loaded, _seen)
elif isinstance(obj, dict):
if id(obj) in _seen:
return
_seen.add(id(obj))
for value in obj.values():
_force_load_include_files(value, _seen)
elif isinstance(obj, (list, tuple)):
if id(obj) in _seen:
return
_seen.add(id(obj))
for item in obj:
_force_load_include_files(item, _seen)
def _resolve_include_path(include_path: Any) -> Path | None:
"""Resolve an include path to absolute, skipping system includes."""
if isinstance(include_path, str) and include_path.startswith("<"):
-76
View File
@@ -1,76 +0,0 @@
"""Validated-config cache for the upload/logs fast path.
compile dumps the validated config to <data_dir>/storage/<file>.validated.yaml;
the next upload/logs for that YAML reuses it instead of running the full
read_config pipeline. YAML round-trip (yaml_util.dump/load_yaml) keeps
!lambda/!include/IDs/paths intact; mtime gates staleness.
"""
from __future__ import annotations
import logging
from pathlib import Path
from esphome.core import CORE
from esphome.helpers import write_file
from esphome.storage_json import StorageJSON, ext_storage_path
from esphome.types import ConfigType
_LOGGER = logging.getLogger(__name__)
def compiled_config_path(config_filename: str) -> Path:
"""Path to the cached validated config alongside the storage sidecar."""
return CORE.data_dir / "storage" / f"{config_filename}.validated.yaml"
def _cache_is_fresh(cache_path: Path, source_path: Path) -> bool:
"""True iff the cache file exists and isn't older than the source."""
try:
return cache_path.stat().st_mtime >= source_path.stat().st_mtime
except OSError:
return False
def save_compiled_config(config: ConfigType) -> None:
"""Write the validated-config cache. Always-write so mtime stays fresh.
Mode 0600 because show_secrets=True resolves !secret inline.
Failures are non-fatal: the fast path falls back to read_config.
"""
from esphome import yaml_util
try:
rendered = yaml_util.dump(config, show_secrets=True)
write_file(compiled_config_path(CORE.config_filename), rendered, private=True)
except Exception as err: # pylint: disable=broad-except
_LOGGER.debug("Skipping compiled config cache write: %s", err)
def load_compiled_config(conf_path: Path) -> ConfigType | None:
"""Load the cached validated config and apply storage metadata to CORE.
Returns None (caller falls back to read_config) when the cache is
missing, older than the source YAML, unparseable, or the sidecar
is incomplete.
"""
cache_path = compiled_config_path(conf_path.name)
if not _cache_is_fresh(cache_path, conf_path):
return None
from esphome import yaml_util
try:
config = yaml_util.load_yaml(cache_path, clear_secrets=False)
except Exception: # pylint: disable=broad-except
return None
storage = StorageJSON.load(ext_storage_path(conf_path.name))
if storage is None:
return None
# apply_to_core assumes a real compile wrote the sidecar; wizard-only
# sidecars leave both of these unset and can't drive upload/logs.
if not storage.core_platform and not storage.target_platform:
return None
storage.apply_to_core()
return config
-1
View File
@@ -2026,7 +2026,6 @@ message VoiceAssistantAudio {
bytes data = 1 [(pointer_to_buffer) = true];
bool end = 2;
bytes data2 = 3 [(pointer_to_buffer) = true];
}
enum VoiceAssistantTimerEvent {
@@ -1,6 +1,5 @@
#include "api_connection.h"
#ifdef USE_API
#include "api_connection_buffer.h" // for encode_to_buffer / get_batch_delay_ms_ inlines
#ifdef USE_API_NOISE
#include "api_frame_helper_noise.h"
#endif
+40 -11
View File
@@ -11,8 +11,7 @@
#endif
#include "api_pb2.h"
#include "api_pb2_service.h"
#include "list_entities.h"
#include "subscribe_state.h"
#include "api_server.h"
#include "esphome/core/application.h"
#include "esphome/core/component.h"
#ifdef USE_ESP32_CRASH_HANDLER
@@ -37,9 +36,6 @@ class ComponentIterator;
namespace esphome::api {
// Forward-declared to break the api_server.h cycle; full-type inlines are in api_connection_buffer.h.
class APIServer;
// Keepalive timeout in milliseconds
static constexpr uint32_t KEEPALIVE_TIMEOUT_MS = 60000;
// Maximum number of entities to process in a single batch during initial state/info sending
@@ -415,10 +411,44 @@ class APIConnection final : public APIServerConnectionBase {
// Non-template buffer management for send_message
bool send_message_(uint32_t payload_size, uint8_t message_type, MessageEncodeFn encode_fn, const void *msg);
// Core batch encoding logic. ALWAYS_INLINE so encode_fn devirtualizes at hot call sites.
// Defined in api_connection_buffer.h (needs APIServer complete).
static uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn, uint32_t remaining_size);
// Core batch encoding logic. Computes header size, checks fit, resizes buffer, encodes.
// ALWAYS_INLINE so the compiler can devirtualize encode_fn at hot call sites.
static inline uint16_t ESPHOME_ALWAYS_INLINE encode_to_buffer(uint32_t calculated_size, MessageEncodeFn encode_fn,
const void *msg, APIConnection *conn,
uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
// Noinline version of encode_to_buffer for cold paths (entity info, zero-payload messages).
// All cold callers share this single copy instead of each getting an ALWAYS_INLINE expansion.
@@ -762,8 +792,7 @@ class APIConnection final : public APIServerConnectionBase {
// Read by process_batch_multi_ to pass into MessageInfo.
uint8_t batch_header_size_{0};
// Defined in api_connection_buffer.h (needs APIServer complete).
uint32_t get_batch_delay_ms_() const;
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
// Message will use 8 more bytes than the minimum size, and typical
// MTU is 1500. Sometimes users will see as low as 1460 MTU.
// If its IPv6 the header is 40 bytes, and if its IPv4
@@ -1,54 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#ifdef USE_API
// Inline APIConnection methods that need APIServer complete. Include this
// instead of api_connection.h when calling encode_to_buffer or get_batch_delay_ms_.
#include "api_connection.h"
#include "api_server.h"
namespace esphome::api {
inline uint16_t ESPHOME_ALWAYS_INLINE APIConnection::encode_to_buffer(uint32_t calculated_size,
MessageEncodeFn encode_fn, const void *msg,
APIConnection *conn, uint32_t remaining_size) {
#ifdef HAS_PROTO_MESSAGE_DUMP
if (conn->flags_.log_only_mode) {
auto *proto_msg = static_cast<const ProtoMessage *>(msg);
DumpBuffer dump_buf;
conn->log_send_message_(proto_msg->message_name(), proto_msg->dump_to(dump_buf));
return 1;
}
#endif
const uint8_t footer_size = conn->helper_->frame_footer_size();
// First message uses max padding (already in buffer), subsequent use exact header size
size_t to_add;
if (conn->flags_.batch_first_message) {
conn->flags_.batch_first_message = false;
conn->batch_header_size_ = conn->helper_->frame_header_padding();
to_add = calculated_size;
} else {
conn->batch_header_size_ = conn->helper_->frame_header_size(calculated_size, conn->batch_message_type_);
to_add = calculated_size + conn->batch_header_size_ + footer_size;
}
// Check if it fits (using actual header size, not max padding)
uint16_t total_calculated_size = calculated_size + conn->batch_header_size_ + footer_size;
if (total_calculated_size > remaining_size)
return 0;
auto &shared_buf = conn->parent_->get_shared_buffer_ref();
shared_buf.resize(shared_buf.size() + to_add);
ProtoWriteBuffer buffer{&shared_buf, shared_buf.size() - calculated_size};
encode_fn(msg, buffer PROTO_ENCODE_DEBUG_INIT(&shared_buf));
return total_calculated_size;
}
inline uint32_t APIConnection::get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
} // namespace esphome::api
#endif
-7
View File
@@ -2893,11 +2893,6 @@ bool VoiceAssistantAudio::decode_length(uint32_t field_id, ProtoLengthDelimited
this->data_len = value.size();
break;
}
case 3: {
this->data2 = value.data();
this->data2_len = value.size();
break;
}
default:
return false;
}
@@ -2907,14 +2902,12 @@ uint8_t *VoiceAssistantAudio::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG
uint8_t *__restrict__ pos = buffer.get_pos();
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len);
ProtoEncode::encode_bool(pos PROTO_ENCODE_DEBUG_ARG, 2, this->end);
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 3, this->data2, this->data2_len);
return pos;
}
uint32_t VoiceAssistantAudio::calculate_size() const {
uint32_t size = 0;
size += ProtoSize::calc_length(1, this->data_len);
size += ProtoSize::calc_bool(1, this->end);
size += ProtoSize::calc_length(1, this->data2_len);
return size;
}
bool VoiceAssistantTimerEventResponse::decode_varint(uint32_t field_id, proto_varint_value_t value) {
+1 -3
View File
@@ -2436,15 +2436,13 @@ class VoiceAssistantEventResponse final : public ProtoDecodableMessage {
class VoiceAssistantAudio final : public ProtoDecodableMessage {
public:
static constexpr uint8_t MESSAGE_TYPE = 106;
static constexpr uint8_t ESTIMATED_SIZE = 40;
static constexpr uint8_t ESTIMATED_SIZE = 21;
#ifdef HAS_PROTO_MESSAGE_DUMP
const LogString *message_name() const override { return LOG_STR("voice_assistant_audio"); }
#endif
const uint8_t *data{nullptr};
uint16_t data_len{0};
bool end{false};
const uint8_t *data2{nullptr};
uint16_t data2_len{0};
uint8_t *encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const;
uint32_t calculate_size() const;
#ifdef HAS_PROTO_MESSAGE_DUMP
-1
View File
@@ -2174,7 +2174,6 @@ const char *VoiceAssistantAudio::dump_to(DumpBuffer &out) const {
MessageDumpHelper helper(out, ESPHOME_PSTR("VoiceAssistantAudio"));
dump_bytes_field(out, ESPHOME_PSTR("data"), this->data, this->data_len);
dump_field(out, ESPHOME_PSTR("end"), this->end);
dump_bytes_field(out, ESPHOME_PSTR("data2"), this->data2, this->data2_len);
return out.c_str();
}
const char *VoiceAssistantTimerEventResponse::dump_to(DumpBuffer &out) const {
+5
View File
@@ -30,6 +30,11 @@ APIServer *global_api_server = nullptr; // NOLINT(cppcoreguidelines-avoid-non-c
APIServer::APIServer() { global_api_server = this; }
// Custom deleter defined here so `delete` sees the complete APIConnection type.
// This prevents libc++ from emitting an "incomplete type" error when other
// translation units only have the forward declaration of APIConnection.
void APIServer::APIConnectionDeleter::operator()(APIConnection *p) const { delete p; }
void APIServer::socket_failed_(const LogString *msg) {
ESP_LOGW(TAG, "Socket %s: errno %d", LOG_STR_ARG(msg), errno);
this->destroy_socket_();
+10 -4
View File
@@ -3,8 +3,6 @@
#include "esphome/core/defines.h"
#ifdef USE_API
#include "api_buffer.h"
// Must precede clients_ so APIConnection is complete for default_delete (libc++).
#include "api_connection.h"
#include "api_noise_context.h"
#include "api_pb2.h"
#include "api_pb2_service.h"
@@ -14,6 +12,8 @@
#include "esphome/core/controller.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#include "list_entities.h"
#include "subscribe_state.h"
#ifdef USE_LOGGER
#include "esphome/components/logger/logger.h"
#endif
@@ -191,9 +191,15 @@ class APIServer final : public Component,
bool is_connected_with_state_subscription() const;
// Range-for view over the populated slice [0, api_connection_count_). Read-only with respect
// to ownership; callers get `const unique_ptr&` so they can invoke non-const methods on the
// to ownership callers get `const unique_ptr&` so they can invoke non-const methods on the
// APIConnection but cannot reset/move the slot and break the count invariant.
using APIConnectionPtr = std::unique_ptr<APIConnection>;
// Custom deleter is defined out-of-line in api_server.cpp so libc++ does not
// eagerly instantiate `delete static_cast<APIConnection *>(p)` here, where
// only the forward declaration of APIConnection is visible (incomplete type).
struct APIConnectionDeleter {
void operator()(APIConnection *p) const;
};
using APIConnectionPtr = std::unique_ptr<APIConnection, APIConnectionDeleter>;
class ActiveClientsView {
const APIConnectionPtr *begin_;
const APIConnectionPtr *end_;
+2 -2
View File
@@ -335,7 +335,7 @@ async def to_code(config):
add_idf_component(
name="esphome/esp-audio-libs",
ref="3.1.0",
ref="3.0.0",
)
data = _get_data()
@@ -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.1")
add_idf_component(name="esphome/micro-mp3", ref="0.2.0")
_emit_memory_pair(
data.mp3.buffer_memory,
"CONFIG_MP3_DECODER_PREFER_PSRAM",
@@ -207,153 +207,6 @@ void ConstAudioSourceBuffer::consume(size_t bytes) {
this->data_start_ += bytes;
}
std::unique_ptr<RingBufferAudioSource> RingBufferAudioSource::create(
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer, size_t max_fill_bytes, uint8_t alignment_bytes) {
if (ring_buffer == nullptr || max_fill_bytes == 0 || alignment_bytes == 0 || alignment_bytes > MAX_ALIGNMENT_BYTES) {
return nullptr;
}
return std::unique_ptr<RingBufferAudioSource>(
new RingBufferAudioSource(std::move(ring_buffer), max_fill_bytes, alignment_bytes));
}
RingBufferAudioSource::~RingBufferAudioSource() {
if (this->acquired_item_ != nullptr) {
this->ring_buffer_->receive_release(this->acquired_item_);
this->acquired_item_ = nullptr;
}
}
void RingBufferAudioSource::release_item_() {
if (this->acquired_item_ == nullptr) {
return;
}
if (this->item_trailing_length_ > 0) {
// Copy the trailing sub-frame bytes into the splice buffer before returning the item; the next
// fill() will complete the frame from the head of the next chunk.
std::memcpy(this->splice_buffer_, this->item_trailing_ptr_, this->item_trailing_length_);
this->splice_length_ = this->item_trailing_length_;
this->item_trailing_ptr_ = nullptr;
this->item_trailing_length_ = 0;
}
this->ring_buffer_->receive_release(this->acquired_item_);
this->acquired_item_ = nullptr;
}
void RingBufferAudioSource::consume(size_t bytes) {
bytes = std::min(bytes, this->current_available_);
this->current_data_ += bytes;
this->current_available_ -= bytes;
// Promotion of queued data is deferred to fill() so callers see new data as a fresh return value
// rather than appearing silently after consume(). When the held item has nothing left depending
// on it (no exposed bytes and no queued region), release it now so the ring buffer can be
// reclaimed by writers even if fill() is never called again.
if (this->current_available_ == 0 && this->queued_length_ == 0) {
this->release_item_();
}
}
void RingBufferAudioSource::clear_buffered_data() {
// Release the held item before reset() so the source no longer references memory the reset will reclaim.
if (this->acquired_item_ != nullptr) {
this->ring_buffer_->receive_release(this->acquired_item_);
this->acquired_item_ = nullptr;
}
this->current_data_ = nullptr;
this->current_available_ = 0;
this->queued_data_ = nullptr;
this->queued_length_ = 0;
this->item_trailing_ptr_ = nullptr;
this->item_trailing_length_ = 0;
this->splice_length_ = 0;
this->ring_buffer_->reset();
}
bool RingBufferAudioSource::has_buffered_data() const {
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
// Counting it separately would strand a drain loop when a stream ends mid-frame and those completion
// bytes never come.
return (this->current_available_ > 0) || (this->queued_length_ > 0) || (this->ring_buffer_->available() > 0);
}
size_t RingBufferAudioSource::fill(TickType_t ticks_to_wait, bool /*pre_shift*/) {
if (this->current_available_ > 0) {
// Caller has not finished consuming the current exposure
return 0;
}
// If a queued region (the aligned remainder of the new chunk after a splice frame) is waiting,
// promote it to the exposed region and report its size as fresh data.
if (this->queued_length_ > 0) {
this->current_data_ = this->queued_data_;
this->current_available_ = this->queued_length_;
this->queued_data_ = nullptr;
this->queued_length_ = 0;
return this->current_available_;
}
// Nothing exposed and nothing queued: release the previously held item (saving any sub-frame tail
// to splice_buffer_) and acquire a new chunk.
this->release_item_();
size_t chunk_length = 0;
void *item = this->ring_buffer_->receive_acquire(chunk_length, this->max_fill_bytes_, ticks_to_wait);
if (item == nullptr) {
return 0;
}
uint8_t *chunk_data = static_cast<uint8_t *>(item);
bool exposing_splice_frame = false;
// Complete any pending splice frame from the head of the new chunk.
if (this->splice_length_ > 0) {
const size_t needed = static_cast<size_t>(this->alignment_bytes_) - this->splice_length_;
if (chunk_length < needed) {
// Not enough data to complete the spliced frame yet; absorb everything and wait for more.
std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, chunk_length);
this->splice_length_ += chunk_length;
this->ring_buffer_->receive_release(item);
return 0;
}
std::memcpy(this->splice_buffer_ + this->splice_length_, chunk_data, needed);
chunk_data += needed;
chunk_length -= needed;
this->splice_length_ = 0;
exposing_splice_frame = true;
}
this->acquired_item_ = item;
// Split the remaining chunk into its aligned region and a (possibly zero) sub-frame trailing tail.
const size_t trailing = (this->alignment_bytes_ > 1) ? (chunk_length % this->alignment_bytes_) : 0;
const size_t aligned_bytes = chunk_length - trailing;
if (trailing > 0) {
this->item_trailing_ptr_ = chunk_data + aligned_bytes;
this->item_trailing_length_ = trailing;
}
if (exposing_splice_frame) {
// Expose the spliced frame from splice_buffer_, queuing the chunk's aligned region for the next
// fill() call.
this->current_data_ = this->splice_buffer_;
this->current_available_ = this->alignment_bytes_;
this->queued_data_ = chunk_data;
this->queued_length_ = aligned_bytes;
return this->alignment_bytes_;
}
if (aligned_bytes == 0) {
// The entire chunk is a sub-frame tail (only possible when alignment exceeds chunk size). Save it
// to the splice buffer and release the item so the next fill() can complete the frame.
this->release_item_();
return 0;
}
this->current_data_ = chunk_data;
this->current_available_ = aligned_bytes;
return aligned_bytes;
}
} // namespace esphome::audio
#endif
@@ -214,90 +214,6 @@ class ConstAudioSourceBuffer : public AudioReadableBuffer {
size_t length_{0};
};
/// @brief Zero-copy audio source that reads directly from a ring buffer's internal storage.
///
/// Optionally enforces a minimum read alignment (e.g. one audio frame). When alignment_bytes > 1, the
/// source transparently stitches frames that straddle the ring buffer's wrap boundary by buffering the
/// trailing partial frame from one chunk and joining it with the head of the next chunk in a small
/// internal splice buffer, so callers always see frame-aligned data.
///
/// Not thread-safe. The underlying ring_buffer::RingBuffer supports one producer and one consumer
/// running concurrently, but a given RingBufferAudioSource (its acquired item, splice buffer, and
/// queued region) must be used by only one thread, and that thread is the ring buffer's consumer.
class RingBufferAudioSource : public AudioReadableBuffer {
public:
/// Maximum supported alignment. Sized to cover 32-bit samples across up to 2 channels (8 bytes).
static constexpr size_t MAX_ALIGNMENT_BYTES = 8;
/// @brief Creates a new ring-buffer-backed audio source after validating its parameters.
/// @param ring_buffer The ring buffer to read from. Must be non-null.
/// @param max_fill_bytes Soft cap on bytes acquired per fill() call. Must be > 0.
/// @param alignment_bytes Minimum exposed-region alignment in bytes (defaults to 1, i.e. byte-aligned).
/// Pass bytes_per_frame to make every exposed region a whole number of frames. Must be in
/// [1, MAX_ALIGNMENT_BYTES].
/// @return unique_ptr if parameters are valid, nullptr otherwise
static std::unique_ptr<RingBufferAudioSource> create(std::shared_ptr<ring_buffer::RingBuffer> ring_buffer,
size_t max_fill_bytes, uint8_t alignment_bytes = 1);
~RingBufferAudioSource() override;
// AudioReadableBuffer interface
const uint8_t *data() const override { return this->current_data_; }
size_t available() const override { return this->current_available_; }
void consume(size_t bytes) override;
bool has_buffered_data() const override;
/// pre_shift is ignored: there is no intermediate transfer buffer to compact, so an unconsumed
/// exposure stays in place and fill() returns 0 until it is fully consumed.
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
/// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight
/// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread.
void clear_buffered_data();
/// @brief Returns a mutable pointer to the currently exposed audio data.
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
/// should be discarded after use, since the underlying storage will be reused on the next fill().
/// Use only when the caller is the sole consumer of this source.
uint8_t *mutable_data() { return this->current_data_; }
protected:
/// @brief Constructs a new ring-buffer-backed audio source. Use create() instead, which validates
/// arguments before construction.
explicit RingBufferAudioSource(std::shared_ptr<ring_buffer::RingBuffer> ring_buffer, size_t max_fill_bytes,
uint8_t alignment_bytes)
: ring_buffer_(std::move(ring_buffer)), max_fill_bytes_(max_fill_bytes), alignment_bytes_(alignment_bytes) {}
/// @brief Releases the currently held ring buffer item, first copying any trailing sub-frame bytes
/// into the splice buffer so they can be stitched with the next chunk.
void release_item_();
std::shared_ptr<ring_buffer::RingBuffer> ring_buffer_;
size_t max_fill_bytes_;
void *acquired_item_{nullptr};
uint8_t *current_data_{nullptr};
// Sub-frame trailing bytes inside the held item that will be copied to splice_buffer_ on release.
uint8_t *item_trailing_ptr_{nullptr};
// After the currently-exposed splice frame is consumed, fill() will promote this region (the aligned
// remainder of the new chunk) to the exposed region. queued_length_ == 0 when nothing is queued.
uint8_t *queued_data_{nullptr};
// Splice buffer holds the start of a partial frame whose remainder lives at the head of the next
// chunk. While splice_length_ > 0, the buffer is incomplete and waiting for completion bytes.
uint8_t splice_buffer_[MAX_ALIGNMENT_BYTES];
size_t current_available_{0};
size_t queued_length_{0};
// item_trailing_length_ and splice_length_ are bounded by MAX_ALIGNMENT_BYTES.
uint8_t alignment_bytes_;
uint8_t item_trailing_length_{0};
uint8_t splice_length_{0};
};
} // namespace esphome::audio
#endif
@@ -135,26 +135,12 @@ void BluetoothConnection::loop() {
// - For V3_WITH_CACHE: Services are never sent, disable after INIT state
// - For V3_WITHOUT_CACHE: Disable only after service discovery is complete
// (send_service_ == DONE_SENDING_SERVICES, which is only set after services are sent)
// Never disable while DISCONNECTING — BLEClientBase::loop() needs to keep running so the
// 10s safety timeout can force IDLE if CLOSE_EVT is never delivered.
if (this->state() != espbt::ClientState::INIT && this->state() != espbt::ClientState::DISCONNECTING &&
(this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
this->send_service_ == DONE_SENDING_SERVICES)) {
this->disable_loop();
}
}
void BluetoothConnection::on_disconnect_complete(esp_err_t reason) {
// Called from both the CLOSE_EVT handler and the DISCONNECTING safety timeout in the
// base class. Free the proxy slot, notify the API client, and reset send_service_.
// address_ may already be 0 if reset_connection_ ran earlier on this teardown.
if (this->address_ == 0) {
return;
}
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, reason);
this->reset_connection_(reason);
}
void BluetoothConnection::reset_connection_(esp_err_t reason) {
// Send disconnection notification
this->proxy_->send_device_connection(this->address_, false, 0, reason);
@@ -386,6 +372,14 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga
this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason);
break;
}
case ESP_GATTC_CLOSE_EVT: {
ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_,
param->close.reason);
// Now the GATT connection is fully closed and controller resources are freed
// Safe to mark the connection slot as available
this->reset_connection_(param->close.reason);
break;
}
case ESP_GATTC_OPEN_EVT: {
if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) {
this->reset_connection_(param->open.status);
@@ -33,8 +33,6 @@ class BluetoothConnection final : public esp32_ble_client::BLEClientBase {
protected:
friend class BluetoothProxy;
void on_disconnect_complete(esp_err_t reason) override;
bool supports_efficient_uuids_() const;
void send_service_for_discovery_();
void reset_connection_(esp_err_t reason);
@@ -1,6 +1,5 @@
#include "bluetooth_proxy.h"
#include "esphome/components/api/api_server.h"
#include "esphome/core/log.h"
#include "esphome/core/macros.h"
#include "esphome/core/application.h"
@@ -161,7 +161,7 @@ void BME680BSECComponent::dump_config() {
" IAQ Mode: %s\n"
" Supply Voltage: %sV\n"
" Sample Rate: %s\n"
" State Save Interval: %" PRIu32 "ms",
" State Save Interval: %ims",
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 %" PRIu32 "ms", period);
ESP_LOGV(TAG, "Delaying for %ums", period);
delay(period);
}
+47 -120
View File
@@ -113,7 +113,6 @@ ARDUINO_FRAMEWORK_NAME = "framework-arduinoespressif32"
ARDUINO_FRAMEWORK_PKG = f"pioarduino/{ARDUINO_FRAMEWORK_NAME}"
ARDUINO_LIBS_NAME = f"{ARDUINO_FRAMEWORK_NAME}-libs"
ARDUINO_LIBS_PKG = f"pioarduino/{ARDUINO_LIBS_NAME}"
ARDUINO_ESP32_COMPONENT_NAME = "espressif/arduino-esp32"
LOG_LEVELS_IDF = [
"NONE",
@@ -589,18 +588,6 @@ def add_idf_component(
}
def get_managed_component_require_names() -> list[str]:
"""Return sorted IDF require names for components added via
``add_idf_component`` (``owner/name`` -> ``owner__name``).
The build_gen layer (``build_gen.espidf.get_project_cmakelists``)
feeds this list into ``ESPHOME_PROJECT_MANAGED_COMPONENTS`` so
converted PIO libraries can REQUIRE them by name at configure time.
"""
components_registry = CORE.data.get(KEY_ESP32, {}).get(KEY_COMPONENTS, {})
return sorted(name.replace("/", "__") for name in components_registry)
def exclude_builtin_idf_component(name: str) -> None:
"""Exclude an ESP-IDF component from the build.
@@ -793,15 +780,19 @@ PLATFORM_VERSION_LOOKUP = {
}
def _resolve_framework_version(value: ConfigType) -> cv.Version:
"""Resolve a named or raw framework version and validate the minimum.
def _check_pio_versions(config):
config = config.copy()
value = config[CONF_FRAMEWORK]
Normalises value[CONF_VERSION] to its string form and returns the parsed
cv.Version. Shared between the PIO and esp-idf toolchain paths; toolchain-
specific concerns (source defaults, platform_version) live in the per-
toolchain functions.
"""
if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP:
if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value:
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
platform_lookup = PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]]
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
version = ARDUINO_FRAMEWORK_VERSION_LOOKUP[value[CONF_VERSION]]
else:
@@ -814,38 +805,7 @@ def _resolve_framework_version(value: ConfigType) -> cv.Version:
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
if version < cv.Version(3, 0, 0):
raise cv.Invalid("Only Arduino 3.0+ is supported.")
recommended = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
if version != recommended:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
return version
def _check_pio_versions(config: ConfigType) -> ConfigType:
config = config.copy()
value = config[CONF_FRAMEWORK]
is_named_version = value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP
if is_named_version and (CONF_SOURCE in value or CONF_PLATFORM_VERSION in value):
raise cv.Invalid(
"Version needs to be explicitly set when a custom source or platform_version is used."
)
if is_named_version:
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP[value[CONF_VERSION]])
)
version = _resolve_framework_version(value)
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get(
CONF_SOURCE, _format_framework_arduino_version(version)
@@ -853,6 +813,9 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
if _is_framework_url(value[CONF_SOURCE]):
value[CONF_SOURCE] = f"{ARDUINO_FRAMEWORK_PKG}@{value[CONF_SOURCE]}"
else:
if version < cv.Version(5, 0, 0):
raise cv.Invalid("Only ESP-IDF 5.0+ is supported.")
recommended_version = ESP_IDF_FRAMEWORK_VERSION_LOOKUP["recommended"]
platform_lookup = ESP_IDF_PLATFORM_VERSION_LOOKUP.get(version)
value[CONF_SOURCE] = value.get(
CONF_SOURCE,
@@ -868,6 +831,12 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
)
value[CONF_PLATFORM_VERSION] = _parse_pio_platform_version(str(platform_lookup))
if version != recommended_version:
_LOGGER.warning(
"The selected framework version is not the recommended one. "
"If there are connectivity or build issues please remove the manual version."
)
if value[CONF_PLATFORM_VERSION] != _parse_pio_platform_version(
str(PLATFORM_VERSION_LOOKUP["recommended"])
):
@@ -879,26 +848,19 @@ def _check_pio_versions(config: ConfigType) -> ConfigType:
return config
def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
config = config.copy()
def _check_esp_idf_versions(config):
config = _check_pio_versions(config)
value = config[CONF_FRAMEWORK]
# platform_version is a PlatformIO concept; drop it if a user carried it
# over from a PIO-style config. CONF_SOURCE, on the other hand, is kept:
# it lets a user override the framework tarball URL under the esp-idf
# toolchain (the espidf framework downloader consults it).
value.pop(CONF_PLATFORM_VERSION, None)
# Remove unwanted keys if present
for key in (CONF_SOURCE, CONF_PLATFORM_VERSION):
value.pop(key, None)
version = _resolve_framework_version(value)
# Official ESP-IDF frameworks don't use extra
version = cv.Version.parse(value[CONF_VERSION])
version = cv.Version(version.major, version.minor, version.patch)
if CONF_SOURCE in value:
_LOGGER.warning(
"A custom framework source is set. "
"If there are connectivity or build issues please remove the manual source."
)
# Official ESP-IDF frameworks don't use the 'extra' semver component.
value[CONF_VERSION] = str(cv.Version(version.major, version.minor, version.patch))
value[CONF_VERSION] = str(version)
return config
@@ -1744,31 +1706,6 @@ async def _add_yaml_idf_components(components: list[ConfigType]):
)
@coroutine_with_priority(CoroPriority.FINAL - 1)
async def _finalize_arduino_aware_flags():
"""Build flags that depend on whether arduino-esp32 is linked in.
Scheduler runs lower priority values later, so ``FINAL - 1`` fires
after every ``FINAL`` job (incl. ``_add_yaml_idf_components``) --
by then ``KEY_COMPONENTS`` is fully populated.
- Skip our esp_panic_handler wrap when Arduino is linked; Arduino
wraps the same symbol and the linker errors on the duplicate.
- Define USE_ARDUINO in the hybrid esp-idf+arduino-esp32-component
case so ESPHome's ``#ifdef USE_ARDUINO`` paths light up. The
framework=arduino branch already adds it inline in to_code.
"""
arduino_linked = (
CORE.using_arduino
or ARDUINO_ESP32_COMPONENT_NAME in CORE.data[KEY_ESP32][KEY_COMPONENTS]
)
if not arduino_linked:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
elif not CORE.using_arduino:
cg.add_build_flag("-DUSE_ARDUINO")
async def to_code(config):
framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
conf = config[CONF_FRAMEWORK]
@@ -1816,18 +1753,19 @@ async def to_code(config):
Path(__file__).parent / "iram_fix.py.script",
)
else:
# Undo IDF's blanket -Werror so third-party libraries and user
# lambdas don't need a -Wno-error=<class> entry per warning class.
cg.add_build_flag("-Wno-error")
# -Wno- (not -Wno-error=): suppress entirely, too noisy on C++ aggregates
cg.add_build_flag("-Wno-missing-field-initializers")
cg.add_build_flag("-Wno-error=format")
cg.add_build_flag("-Wno-error=missing-field-initializers")
cg.add_build_flag("-Wno-error=volatile")
cg.set_cpp_standard("gnu++20")
cg.add_build_flag("-DUSE_ESP32")
cg.add_define("USE_NATIVE_64BIT_TIME")
cg.add_build_flag("-Wl,-z,noexecstack")
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
CORE.add_job(_finalize_arduino_aware_flags)
# Arduino already wraps esp_panic_handler for its own backtrace handler,
# so only add our wrap when using ESP-IDF framework to avoid linker conflicts.
if conf[CONF_TYPE] == FRAMEWORK_ESP_IDF:
cg.add_build_flag("-Wl,--wrap=esp_panic_handler")
cg.add_define("USE_ESP32_CRASH_HANDLER")
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
variant = config[CONF_VARIANT]
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
@@ -2008,7 +1946,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True)
# Setup watchdog
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
@@ -2050,8 +1988,7 @@ async def to_code(config):
if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]:
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]:
# Kconfig range is [1,63]; 0 gets clamped to the default.
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1)
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0)
_configure_lwip_max_sockets(conf)
@@ -2143,6 +2080,7 @@ async def to_code(config):
for key, flag in ASSERTION_LEVELS.items():
add_idf_sdkconfig_option(flag, assertion_level == key)
add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False)
compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION]
for key, flag in COMPILER_OPTIMIZATIONS.items():
add_idf_sdkconfig_option(flag, compiler_optimization == key)
@@ -2297,8 +2235,7 @@ async def to_code(config):
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2)
elif advanced[CONF_DISABLE_FATFS]:
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True)
# Kconfig range is [1,10]; 0 gets clamped to the default.
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1)
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0)
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
@@ -2513,14 +2450,8 @@ def _write_sdkconfig():
)
want_opts = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
# Include the resolved framework version as a Kconfig comment so a
# version switch that happens to leave the option set unchanged still
# bumps this file's content -- which is what has_outdated_files()
# uses to decide whether to reconfigure.
framework_version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]
contents = (
f"# ESPHOME_IDF_VERSION={framework_version}\n"
+ "\n".join(
"\n".join(
f"{name}={_format_sdkconfig_val(value)}"
for name, value in sorted(want_opts.items())
)
@@ -2535,8 +2466,9 @@ def _write_sdkconfig():
def _platformio_library_to_dependency(library: Library) -> tuple[str, dict[str, str]]:
dependency: dict[str, str] = {}
name, _version, path = generate_idf_component(library)
name, version, path = generate_idf_component(library)
dependency["override_path"] = str(path)
dependency["version"] = version
return name, dependency
@@ -2563,12 +2495,7 @@ def _write_idf_component_yml():
stubs_dir = CORE.relative_build_path("component_stubs")
stubs_dir.mkdir(exist_ok=True)
# Sort so the dict insertion order (and thus the generated
# src/idf_component.yml) is deterministic across runs; otherwise
# the manifest content shuffles every build, write_file_if_changed
# always writes, and ninja keeps triggering CMake re-runs on
# otherwise-cached rebuilds.
for component_name in sorted(components_to_stub):
for component_name in components_to_stub:
# Create stub directory with minimal CMakeLists.txt
stub_path = stubs_dir / _idf_component_stub_name(component_name)
stub_path.mkdir(exist_ok=True)
@@ -2588,7 +2515,7 @@ def _write_idf_component_yml():
if CORE.using_toolchain_esp_idf:
add_idf_component(
name=ARDUINO_ESP32_COMPONENT_NAME,
name="espressif/arduino-esp32",
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
)
@@ -72,7 +72,6 @@ void BLEClientBase::loop() {
// never delivered CLOSE_EVT/DISCONNECT_EVT, services would leak without this call.
this->release_services();
this->set_idle_();
this->on_disconnect_complete(ESP_GATT_CONN_TIMEOUT);
}
}
@@ -419,7 +418,6 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_
this->log_gattc_lifecycle_event_("CLOSE");
this->release_services();
this->set_idle_();
this->on_disconnect_complete(param->close.reason);
break;
}
case ESP_GATTC_SEARCH_RES_EVT: {
@@ -140,12 +140,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void log_gattc_warning_(const char *operation, esp_err_t err);
void log_connection_params_(const char *param_type);
void handle_connection_result_(esp_err_t ret);
/// Hook called once a connection has been fully torn down (after release_services() and
/// set_idle_()), from both the CLOSE_EVT handler and the DISCONNECTING safety timeout.
/// Subclasses with extra per-connection accounting (e.g. bluetooth_proxy slot state)
/// override this to release that state. `reason` is the controller reason code, or
/// ESP_GATT_CONN_TIMEOUT for the safety-timeout path.
virtual void on_disconnect_complete(esp_err_t reason) {}
/// Transition to IDLE and reset conn_id — call when the connection is fully dead.
void set_idle_() {
this->set_state(espbt::ClientState::IDLE);
@@ -155,10 +149,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
void set_disconnecting_() {
this->disconnecting_started_ = millis();
this->set_state(espbt::ClientState::DISCONNECTING);
// BluetoothConnection::loop() disables the component loop after service discovery
// completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT
// gets lost. Re-enable the loop so the 10s safety timeout can force IDLE.
this->enable_loop();
}
// Compact error logging helpers to reduce flash usage
void log_error_(const char *message);
@@ -196,35 +196,42 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt
(*this->on_read_callback_)(param->read.conn_id);
}
// Use the client-supplied offset for long reads; short reads always start at 0.
// The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we
// just provide as much data as we have from the requested offset and let the stack
// handle framing. The client issues subsequent blob reads with increasing offsets
// until it has received the whole value.
const uint16_t offset = param->read.is_long ? param->read.offset : 0;
esp_gatt_status_t status = ESP_GATT_OK;
esp_gatt_rsp_t response;
response.attr_value.offset = offset;
uint16_t max_offset = 22;
if (offset > this->value_.size()) {
status = ESP_GATT_INVALID_OFFSET;
response.attr_value.len = 0;
} else {
size_t remaining = this->value_.size() - offset;
if (remaining > ESP_GATT_MAX_ATTR_LEN) {
ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating",
static_cast<unsigned>(remaining), ESP_GATT_MAX_ATTR_LEN);
remaining = ESP_GATT_MAX_ATTR_LEN;
esp_gatt_rsp_t response;
if (param->read.is_long) {
if (this->value_read_offset_ >= this->value_.size()) {
response.attr_value.len = 0;
response.attr_value.offset = this->value_read_offset_;
this->value_read_offset_ = 0;
} else if (this->value_.size() - this->value_read_offset_ < max_offset) {
// Last message in the chain
response.attr_value.len = this->value_.size() - this->value_read_offset_;
response.attr_value.offset = this->value_read_offset_;
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
this->value_read_offset_ = 0;
} else {
response.attr_value.len = max_offset;
response.attr_value.offset = this->value_read_offset_;
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
this->value_read_offset_ += max_offset;
}
response.attr_value.len = remaining;
memcpy(response.attr_value.value, this->value_.data() + offset, remaining);
} else {
response.attr_value.offset = 0;
if (this->value_.size() + 1 > max_offset) {
response.attr_value.len = max_offset;
this->value_read_offset_ = max_offset;
} else {
response.attr_value.len = this->value_.size();
}
memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len);
}
response.attr_value.handle = this->handle_;
response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE;
esp_err_t err =
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response);
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err);
}
@@ -79,6 +79,7 @@ class BLECharacteristic {
esp_gatt_char_prop_t properties_;
uint16_t handle_{0xFFFF};
uint16_t value_read_offset_{0};
std::vector<uint8_t> value_;
std::vector<BLEDescriptor *> descriptors_;
+1 -1
View File
@@ -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.7")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6")
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), "%" PRIu32 ".%" PRIu32 ".%" PRIu32, ver_info.major1, ver_info.minor1, ver_info.patch1);
snprintf(buf, sizeof(buf), "%d.%d.%d", 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%08" PRIx32 " (expected 0x%08" PRIx32 ")",
app_desc->magic_word, static_cast<uint32_t>(ESP_APP_DESC_MAGIC_WORD));
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08x (expected 0x%08x)", app_desc->magic_word,
ESP_APP_DESC_MAGIC_WORD);
this->state_ = update::UPDATE_STATE_NO_UPDATE;
}
} else {
+12 -12
View File
@@ -5,7 +5,6 @@
#include <Arduino.h>
#include <core_esp8266_features.h>
#include <coredecls.h>
extern "C" {
#include <user_interface.h>
@@ -72,22 +71,23 @@ uint32_t IRAM_ATTR HOT millis() {
return result;
}
// Delegate to Arduino's 1-arg esp_delay(), which uses os_timer + esp_suspend to
// suspend the cont task for `ms` milliseconds without polling millis(). This
// matches pre-2026.5.0 behavior (when esphome::delay() forwarded to ::delay())
// and lets the SDK run freely while we wait, which timing-sensitive
// interrupt-driven code (e.g. ESP8266 software-serial RX in components like
// fingerprint_grow) depends on. The poll-based busy-wait that this replaced
// rarely yielded inside short waits like delay(1), starving WiFi/SDK tasks and
// extending interrupt latency. Unlike ::delay(), esp_delay()'s 1-arg form does
// not call millis(), so the slow Arduino millis() body is not pulled into IRAM
// by this path (the --wrap=millis goal of #15662 is preserved).
// Poll-based delay that avoids ::delay() — Arduino's __delay has an intra-object
// call to the original millis() that --wrap can't intercept, so calling ::delay()
// would keep the slow Arduino millis body alive in IRAM. optimistic_yield still
// enters esp_schedule()/esp_suspend_within_cont() via yield(), so SDK tasks and
// WiFi run correctly. Theoretically less power-efficient than Arduino's
// os_timer-based delay() for long waits, but nearly all ESPHome delays are short
// (sensor/I²C/SPI settling in the 1100 ms range) where the difference is
// negligible.
void HOT delay(uint32_t ms) {
if (ms == 0) {
optimistic_yield(1000);
return;
}
esp_delay(ms);
uint32_t start = millis();
while (millis() - start < ms) {
optimistic_yield(1000);
}
}
void arch_restart() {
-6
View File
@@ -58,12 +58,6 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c
__attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) {
return pgm_read_word(addr); // NOLINT
}
// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers
// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on
// ESP8266 is ~4x as many flash accesses as the bulk path.
__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) {
memcpy_P(dst, src, len); // NOLINT
}
// NOLINTNEXTLINE(readability-identifier-naming)
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
@@ -108,8 +108,8 @@ void ESPHomeOTAComponent::dump_config() {
ESP_LOGCONFIG(TAG,
" Partition access allowed\n"
" Running app:\n"
" Partition address: 0x%" PRIX32 "\n"
" Used size: %zu bytes (0x%zX)",
" Partition address: 0x%X\n"
" Used size: %zu bytes (0x%X)",
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 %zu bytes", ota_size);
ESP_LOGV(TAG, "Size is %u 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", (int) hex_size, buf);
ESP_LOGV(TAG, "Auth: Nonce is %.*s", 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", (int) hex_size, cnonce);
ESP_LOGV(TAG, "Auth: CNonce is %.*s", 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", (int) hex_size, computed_hash);
ESP_LOGV(TAG, "Auth: Result is %.*s", hex_size, computed_hash);
#endif
ESP_LOGV(TAG, "Auth: Response is %.*s", (int) hex_size, response);
ESP_LOGV(TAG, "Auth: Response is %.*s", hex_size, response);
// Compare response
bool matches = hasher.equals_hex(response);
+1 -1
View File
@@ -17,7 +17,7 @@ from esphome.core import HexInt
from esphome.types import ConfigType
CODEOWNERS = ["@jesserockz"]
AUTO_LOAD = ["network"]
byte_vector = cg.std_vector.template(cg.uint8)
peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6)
@@ -149,6 +149,12 @@ bool ESPNowComponent::is_wifi_enabled() {
}
void ESPNowComponent::setup() {
#ifndef USE_WIFI
// Initialize LwIP stack for wake_loop_threadsafe() socket support
// When WiFi component is present, it handles esp_netif_init()
ESP_ERROR_CHECK(esp_netif_init());
#endif
if (this->enable_on_boot_) {
this->enable_();
} else {
@@ -168,6 +174,8 @@ void ESPNowComponent::enable() {
void ESPNowComponent::enable_() {
if (!this->is_wifi_enabled()) {
esp_event_loop_create_default();
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
+6 -37
View File
@@ -3,11 +3,7 @@ import logging
from esphome import automation, pins
import esphome.codegen as cg
from esphome.components.network import (
KEY_NETWORK_PRIORITY,
get_network_priority,
ip_address_literal,
)
from esphome.components.network import ip_address_literal
from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
@@ -17,7 +13,6 @@ from esphome.const import (
CONF_DNS1,
CONF_DNS2,
CONF_DOMAIN,
CONF_ENABLE_ON_BOOT,
CONF_GATEWAY,
CONF_ID,
CONF_INTERRUPT_PIN,
@@ -32,7 +27,6 @@ from esphome.const import (
CONF_PAGE_ID,
CONF_PIN,
CONF_POLLING_INTERVAL,
CONF_PRIORITY,
CONF_RESET_PIN,
CONF_SPI,
CONF_STATIC_IP,
@@ -54,6 +48,7 @@ from esphome.core import (
import esphome.final_validate as fv
from esphome.types import ConfigType
CONFLICTS_WITH = ["wifi"]
AUTO_LOAD = ["network"]
LOGGER = logging.getLogger(__name__)
@@ -353,7 +348,6 @@ BASE_SCHEMA = cv.Schema(
cv.Optional(CONF_DOMAIN, default=".local"): cv.domain_name,
cv.Optional(CONF_USE_ADDRESS): cv.string_strict,
cv.Optional(CONF_MAC_ADDRESS): cv.mac_address,
cv.Optional(CONF_ENABLE_ON_BOOT, default=True): cv.boolean,
cv.Optional(CONF_ON_CONNECT): automation.validate_automation(single=True),
cv.Optional(CONF_ON_DISCONNECT): automation.validate_automation(single=True),
}
@@ -493,11 +487,6 @@ async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
# Apply network priority if configured, otherwise use the existing default
prio = get_network_priority("ethernet")
if prio is not None:
cg.add(var.set_setup_priority(prio))
if CORE.is_esp32:
await _to_code_esp32(var, config)
elif CORE.is_rp2040:
@@ -505,9 +494,6 @@ async def to_code(config):
cg.add(var.set_type(ETHERNET_TYPES[config[CONF_TYPE]]))
cg.add(var.set_use_address(config[CONF_USE_ADDRESS]))
# enable_on_boot defaults to true in C++ - only set if false
if not config[CONF_ENABLE_ON_BOOT]:
cg.add(var.set_enable_on_boot(False))
CORE.data.setdefault(KEY_ETHERNET, {})[ETHERNET_TYPE_KEY] = config[CONF_TYPE]
if CONF_MANUAL_IP in config:
@@ -590,16 +576,10 @@ async def _to_code_esp32(var: cg.Pvariable, config: ConfigType) -> None:
)
cg.add(var.add_phy_register(reg))
# Disable WiFi when using Ethernet alone to save memory.
# When network: priority: lists both interfaces, WiFi must remain enabled.
net_priority = CORE.data.get(KEY_NETWORK_PRIORITY, [])
priority_ifaces = {e["interface"] for e in net_priority}
running_with_wifi = "wifi" in priority_ifaces and "ethernet" in priority_ifaces
if not running_with_wifi:
# Disable WiFi when using Ethernet to save memory
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False)
# Also disable WiFi/BT coexistence since WiFi is disabled
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
# Disable WiFi when using Ethernet to save memory
add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENABLED", False)
# Also disable WiFi/BT coexistence since WiFi is disabled
add_idf_sdkconfig_option("CONFIG_SW_COEXIST_ENABLE", False)
# Re-enable ESP-IDF's Ethernet driver (excluded by default to save compile time)
include_builtin_idf_component("esp_eth")
@@ -685,17 +665,6 @@ def _final_validate_rmii_pins(config: ConfigType) -> None:
def _final_validate(config: ConfigType) -> ConfigType:
"""Final validation for Ethernet component."""
# Allow ethernet + wifi coexistence only when both are declared in network: priority:
full = fv.full_config.get()
net_priority = full.get("network", {}).get(CONF_PRIORITY, [])
priority_ifaces = {e["interface"] for e in net_priority}
has_priority_config = "ethernet" in priority_ifaces and "wifi" in priority_ifaces
if "wifi" in full and not has_priority_config:
raise cv.Invalid(
"Component ethernet cannot be used together with component wifi "
"unless both are listed under 'network: priority:'"
)
_final_validate_spi(config)
_final_validate_rmii_pins(config)
return config
@@ -124,17 +124,6 @@ class EthernetComponent final : public Component {
void on_powerdown() override { powerdown(); }
bool is_connected() { return this->state_ == EthernetComponentState::CONNECTED; }
// Per-interface lifecycle (parallels WiFiComponent::enable/disable/is_disabled).
// enable_on_boot defaults to true; when false, setup() runs all the driver/netif
// installation but skips esp_eth_start(), keeping the link cold until enable() is
// called. This is the primary lever for memory reclamation in multi-interface
// configurations where only one interface should carry traffic at a time.
void set_enable_on_boot(bool enable_on_boot) { this->enable_on_boot_ = enable_on_boot; }
void enable();
void disable();
bool is_disabled() { return this->disabled_; }
bool is_enabled() { return !this->disabled_; }
void set_type(EthernetType type);
#ifdef USE_ETHERNET_MANUAL_IP
void set_manual_ip(const ManualIP &manual_ip);
@@ -205,16 +194,6 @@ class EthernetComponent final : public Component {
void finish_connect_();
void dump_connect_params_();
#ifdef USE_ESP32
// ESP-IDF only: defers the SPI bus init, netif creation, MAC/PHY install, driver
// install, netif attach, and event handler registration (which together allocate
// ~3-8KB of DMA-capable internal SRAM via SPI driver state + eth driver RX queue)
// until ethernet actually needs to come up. Idempotent — guarded by the
// ethernet_initialized_ flag. Called from setup() when enable_on_boot_=true, or
// from enable() on first runtime enable. Mirrors wifi_lazy_init_() in WiFi.
void ethernet_lazy_init_();
#endif
#ifdef USE_ETHERNET_IP_STATE_LISTENERS
void notify_ip_state_listeners_();
#endif
@@ -308,17 +287,6 @@ class EthernetComponent final : public Component {
bool started_{false};
bool connected_{false};
bool got_ipv4_address_{false};
// Codegen-time YAML option. When false, setup() defers esp_eth_start().
bool enable_on_boot_{true};
// Mirror of "is the link intentionally stopped" — set when setup() honors
// enable_on_boot=false, cleared by enable(), set again by disable().
bool disabled_{false};
#ifdef USE_ESP32
// Tracks whether ethernet_lazy_init_() has completed successfully. Allows enable()
// to be called at runtime after enable_on_boot:false without re-allocating, and
// ensures setup() skips the heavy init when enable_on_boot_ is false.
bool ethernet_initialized_{false};
#endif
#if LWIP_IPV6
uint8_t ipv6_count_{0};
bool ipv6_setup_done_{false};
@@ -5,7 +5,6 @@
#include "esphome/core/application.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "w5500_custom_spi.h"
#include <lwip/dns.h>
#include <cinttypes>
@@ -138,24 +137,6 @@ void EthernetComponent::setup() {
delay(300); // NOLINT
}
if (this->enable_on_boot_) {
this->ethernet_lazy_init_();
if (!this->ethernet_initialized_) {
// lazy_init bailed early via ESPHL_ERROR_CHECK or mark_failed; nothing more to do.
return;
}
esp_err_t err = esp_eth_start(this->eth_handle_);
ESPHL_ERROR_CHECK(err, "ETH start error");
} else {
ESP_LOGCONFIG(TAG, "Skipping init (enable_on_boot: false)");
this->disabled_ = true;
}
}
void EthernetComponent::ethernet_lazy_init_() {
if (this->ethernet_initialized_)
return;
esp_err_t err;
#ifdef USE_ETHERNET_SPI
@@ -182,7 +163,11 @@ void EthernetComponent::ethernet_lazy_init_() {
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
#endif
// Network interface setup handled by network component
err = esp_netif_init();
ESPHL_ERROR_CHECK(err, "ETH netif init error");
err = esp_event_loop_create_default();
ESPHL_ERROR_CHECK(err, "ETH event loop error");
esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH();
this->eth_netif_ = esp_netif_new(&cfg);
@@ -222,10 +207,6 @@ void EthernetComponent::ethernet_lazy_init_() {
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
w5500_config.poll_period_ms = this->polling_interval_;
#endif
// Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait
// path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which
// runs the driver's init().
install_w5500_async_spi(w5500_config);
#elif defined(USE_ETHERNET_DM9051)
dm9051_config.int_gpio_num = this->interrupt_pin_;
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
@@ -389,41 +370,9 @@ void EthernetComponent::ethernet_lazy_init_() {
ESPHL_ERROR_CHECK(err, "GOT IPv6 event handler register error");
#endif /* USE_NETWORK_IPV6 */
this->ethernet_initialized_ = true;
}
void EthernetComponent::enable() {
if (!this->disabled_)
return;
ESP_LOGD(TAG, "Enabling");
this->ethernet_lazy_init_();
if (!this->ethernet_initialized_) {
ESP_LOGE(TAG, "Cannot enable - init failed");
return;
}
esp_err_t err = esp_eth_start(this->eth_handle_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "esp_eth_start failed: %s", esp_err_to_name(err));
return;
}
this->disabled_ = false;
// The ETH_EVENT_START handler will set started_=true; the loop state machine
// will then drive the STOPPED -> CONNECTING -> CONNECTED transitions.
this->enable_loop();
}
void EthernetComponent::disable() {
if (this->disabled_)
return;
ESP_LOGD(TAG, "Disabling");
esp_err_t err = esp_eth_stop(this->eth_handle_);
if (err != ESP_OK) {
ESP_LOGW(TAG, "esp_eth_stop failed: %s — disabling anyway", esp_err_to_name(err));
}
this->disabled_ = true;
// ETH_EVENT_STOP will clear started_; loop() will transition to STOPPED.
/* start Ethernet driver state machine */
err = esp_eth_start(this->eth_handle_);
ESPHL_ERROR_CHECK(err, "ETH start error");
}
void EthernetComponent::dump_config() {
@@ -537,8 +486,6 @@ void EthernetComponent::dump_config() {
network::IPAddresses EthernetComponent::get_ip_addresses() {
network::IPAddresses addresses;
if (!this->ethernet_initialized_)
return addresses; // all-zero IPs
esp_netif_ip_info_t ip;
esp_err_t err = esp_netif_get_ip_info(this->eth_netif_, &ip);
if (err != ESP_OK) {
@@ -761,10 +708,6 @@ void EthernetComponent::start_connect_() {
}
void EthernetComponent::dump_connect_params_() {
if (!this->ethernet_initialized_) {
ESP_LOGCONFIG(TAG, " uninitialized/disabled");
return;
}
esp_netif_ip_info_t ip;
esp_netif_get_ip_info(this->eth_netif_, &ip);
const ip_addr_t *dns_ip1;
@@ -832,13 +775,6 @@ void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy
#endif
void EthernetComponent::get_eth_mac_address_raw(uint8_t *mac) {
if (!this->ethernet_initialized_) {
// External callers (sendspin, ethernet_info, mdns, etc.) may ask for the MAC
// before/regardless of whether ethernet is enabled. Fall back to the system MAC
// assigned to the ETH interface — same value the driver would have returned.
esp_read_mac(mac, ESP_MAC_ETH);
return;
}
esp_err_t err;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_MAC_ADDR, mac);
ESPHL_ERROR_CHECK(err, "ETH_CMD_G_MAC error");
@@ -858,8 +794,6 @@ const char *EthernetComponent::get_eth_mac_address_pretty_into_buffer(
}
eth_duplex_t EthernetComponent::get_duplex_mode() {
if (!this->ethernet_initialized_)
return ETH_DUPLEX_HALF;
esp_err_t err;
eth_duplex_t duplex_mode;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_DUPLEX_MODE, &duplex_mode);
@@ -868,8 +802,6 @@ eth_duplex_t EthernetComponent::get_duplex_mode() {
}
eth_speed_t EthernetComponent::get_link_speed() {
if (!this->ethernet_initialized_)
return ETH_SPEED_10M;
esp_err_t err;
eth_speed_t speed;
err = esp_eth_ioctl(this->eth_handle_, ETH_CMD_G_SPEED, &speed);
@@ -361,23 +361,6 @@ void EthernetComponent::set_cs_pin(uint8_t cs_pin) { this->cs_pin_ = cs_pin; }
void EthernetComponent::set_interrupt_pin(int8_t interrupt_pin) { this->interrupt_pin_ = interrupt_pin; }
void EthernetComponent::set_reset_pin(int8_t reset_pin) { this->reset_pin_ = reset_pin; }
void EthernetComponent::enable() {
// RP2040 uses arduino-pico's LwipIntfDev which manages link state internally;
// there is no clean enable/disable hook today. The YAML option is accepted on
// RP2040 for schema parity but has no effect.
if (!this->disabled_)
return;
ESP_LOGW(TAG, "enable_on_boot/disable not supported");
this->disabled_ = false;
}
void EthernetComponent::disable() {
if (this->disabled_)
return;
ESP_LOGW(TAG, "enable_on_boot/disable not supported");
this->disabled_ = true;
}
} // namespace esphome::ethernet
#endif // USE_ETHERNET && USE_RP2040
@@ -1,118 +0,0 @@
#include "w5500_custom_spi.h"
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
#include <driver/spi_master.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>
#include <cstring>
#include <new>
namespace esphome::ethernet {
namespace {
// Per-device context returned by init() and handed back to read/write/deinit.
struct W5500CustomSpiContext {
spi_device_handle_t handle;
SemaphoreHandle_t lock;
};
// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger
// transfers (the frame payloads) use the blocking, DMA-backed transmit.
constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64;
constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50;
void *w5500_custom_spi_init(const void *spi_config) {
const auto *config = static_cast<const eth_w5500_config_t *>(spi_config);
auto *ctx = new (std::nothrow) W5500CustomSpiContext{};
if (ctx == nullptr) {
return nullptr;
}
// The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control
// byte in the address phase; mirror what the stock driver configures.
spi_device_interface_config_t devcfg = *config->spi_devcfg;
devcfg.command_bits = 16;
devcfg.address_bits = 8;
if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) {
delete ctx;
return nullptr;
}
ctx->lock = xSemaphoreCreateMutex();
if (ctx->lock == nullptr) {
spi_bus_remove_device(ctx->handle);
delete ctx;
return nullptr;
}
return ctx;
}
esp_err_t w5500_custom_spi_deinit(void *spi_ctx) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_bus_remove_device(ctx->handle);
vSemaphoreDelete(ctx->lock);
delete ctx;
return ESP_OK;
}
// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size.
// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register
// accesses stay on the cheaper polling path. Used by both read and write.
esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) {
if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) {
return ESP_ERR_TIMEOUT;
}
esp_err_t ret;
if (len > W5500_SPI_BULK_THRESHOLD) {
ret = spi_device_transmit(ctx->handle, trans);
} else {
ret = spi_device_polling_transmit(ctx->handle, trans);
}
xSemaphoreGive(ctx->lock);
return ret;
}
esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_transaction_t trans = {};
trans.cmd = static_cast<uint16_t>(cmd);
trans.addr = addr;
trans.length = 8 * len;
trans.tx_buffer = data;
return w5500_custom_spi_transfer(ctx, &trans, len);
}
esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) {
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
spi_transaction_t trans = {};
// Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary
// overwrites of adjacent registers (same guard the stock driver uses).
const bool use_rxdata = len <= 4;
trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0;
trans.cmd = static_cast<uint16_t>(cmd);
trans.addr = addr;
trans.length = 8 * len;
trans.rx_buffer = data;
esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len);
if (use_rxdata && (ret == ESP_OK)) {
memcpy(data, trans.rx_data, len);
}
return ret;
}
} // namespace
void install_w5500_async_spi(eth_w5500_config_t &config) {
// Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and
// spi_devcfg back out of it. The self-reference is valid because both the config and the
// spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init().
config.custom_spi_driver.config = &config;
config.custom_spi_driver.init = w5500_custom_spi_init;
config.custom_spi_driver.deinit = w5500_custom_spi_deinit;
config.custom_spi_driver.read = w5500_custom_spi_read;
config.custom_spi_driver.write = w5500_custom_spi_write;
}
} // namespace esphome::ethernet
#endif // USE_ESP32 && USE_ETHERNET_W5500
@@ -1,35 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
#include <esp_idf_version.h>
// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t
// is no longer reachable through esp_eth.h and needs the explicit header.
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#include <esp_eth_mac_w5500.h>
#else
#include <esp_eth.h>
#endif
namespace esphome::ethernet {
// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path.
//
// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which
// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame,
// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX
// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers
// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps
// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait
// is cheaper than an interrupt round-trip.
//
// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back
// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay
// alive until esp_eth_mac_new_w5500() returns.
void install_w5500_async_spi(eth_w5500_config_t &config);
} // namespace esphome::ethernet
#endif // USE_ESP32 && USE_ETHERNET_W5500
@@ -19,7 +19,7 @@ void FastLEDLightOutput::dump_config() {
ESP_LOGCONFIG(TAG,
"FastLED light:\n"
" Num LEDs: %u\n"
" Max refresh rate: %" PRIu32,
" Max refresh rate: %u",
this->num_leds_, this->max_refresh_rate_.value_or(0));
}
void FastLEDLightOutput::write_state(light::LightState *state) {
@@ -206,7 +206,6 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() {
break;
case ENROLL_MISMATCH:
ESP_LOGE(TAG, "Scans do not match");
[[fallthrough]];
default:
return this->data_[0];
}
@@ -15,16 +15,6 @@ void FT5x06Touchscreen::setup() {
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
// wait 200ms after reset.
this->set_timeout(200, [this] { this->continue_setup_(); });
}
@@ -49,6 +39,15 @@ void FT5x06Touchscreen::continue_setup_() {
this->mark_failed();
return;
}
// reading the chip registers to get max x/y does not seem to work.
if (this->display_ != nullptr) {
if (this->x_raw_max_ == this->x_raw_min_) {
this->x_raw_max_ = this->display_->get_native_width();
}
if (this->y_raw_max_ == this->y_raw_min_) {
this->y_raw_max_ = this->display_->get_native_height();
}
}
}
void FT5x06Touchscreen::update_touches() {
@@ -72,7 +71,7 @@ void FT5x06Touchscreen::update_touches() {
uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]);
uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]);
ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
if (status == 0 || status == 2) {
this->add_raw_touch_position_(id, x, y);
}
+1 -1
View File
@@ -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(LOG_STR(ESP_LOG_MSG_COMM_FAIL));
this->mark_failed(ESP_LOG_MSG_COMM_FAIL);
return;
}
}
+1
View File
@@ -125,6 +125,7 @@ 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:
@@ -17,9 +17,9 @@ void HomeassistantSensor::setup() {
}
if (this->attribute_ != nullptr) {
ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
} else {
ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
}
this->publish_state(*val);
});
@@ -89,10 +89,10 @@ def _set_num_channels_from_config(config):
def _set_stream_limits(config):
if config.get(CONF_SPDIF_MODE, False):
# SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate
# SPDIF mode: fixed to 16-bit stereo at configured sample rate
audio.set_stream_limits(
min_bits_per_sample=16,
max_bits_per_sample=32,
max_bits_per_sample=16,
min_channels=2,
max_channels=2,
min_sample_rate=config.get(CONF_SAMPLE_RATE),
@@ -213,6 +213,9 @@ def _final_validate(config):
)
if config[CONF_CHANNEL] != CONF_STEREO:
raise cv.Invalid("SPDIF mode only supports stereo channel configuration")
# bits_per_sample is converted to float by the schema
if config[CONF_BITS_PER_SAMPLE] != 16:
raise cv.Invalid("SPDIF mode only supports 16 bits per sample")
if not config[CONF_USE_APLL]:
raise cv.Invalid(
"SPDIF mode requires 'use_apll: true' for accurate clock generation"
@@ -138,30 +138,26 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_.
xQueueReset(this->write_records_queue_);
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT;
// Ensure ring buffer duration is at least the duration of all DMA buffers
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames (~4 ms at 48 kHz),
// not the ~15 ms a standard I2S DMA buffer holds. Derive the DMA floor from actual block size.
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration);
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames
const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES;
const size_t bytes_to_fill_single_dma_buffer =
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT;
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers.
const size_t requested_ring_buffer_bytes =
(this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes);
bool successful_setup = false;
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
{
if (transfer_buffer != nullptr) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, bytes_to_fill_single_dma_buffer,
static_cast<uint8_t>(bytes_per_frame));
if (audio_source != nullptr) {
if (temp_ring_buffer.use_count() == 1) {
transfer_buffer->set_source(temp_ring_buffer);
this->audio_ring_buffer_ = temp_ring_buffer;
successful_setup = true;
}
@@ -177,8 +173,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
// on_sent events drain in lockstep without crediting any audio frames.
this->spdif_encoder_->set_preload_mode(true);
for (size_t i = 0; i < SPDIF_DMA_BUFFERS_COUNT; i++) {
// i2s_channel_preload_data is non-blocking (returns immediately when the preload buffer fills), so no wait.
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(0);
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
if (preload_err != ESP_OK) {
break; // DMA preload buffer full or error
}
@@ -302,24 +297,24 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
if (!this->pause_state_) {
while (real_frames_in_block < SPDIF_BLOCK_SAMPLES) {
if (audio_source->available() == 0) {
size_t bytes_read = audio_source->fill(read_timeout_ticks, false);
if (transfer_buffer->available() == 0) {
size_t bytes_read = transfer_buffer->transfer_data_from_source(read_timeout_ticks);
if (bytes_read == 0) {
break; // No upstream data within the read budget; silence-pad the remainder.
}
uint8_t *new_data = audio_source->mutable_data();
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
this->apply_software_volume_(new_data, bytes_read);
this->swap_esp32_mono_samples_(new_data, bytes_read);
}
const uint32_t frames_still_needed = SPDIF_BLOCK_SAMPLES - real_frames_in_block;
const size_t bytes_still_needed = this->current_stream_info_.frames_to_bytes(frames_still_needed);
const size_t bytes_to_feed = std::min(audio_source->available(), bytes_still_needed);
const size_t bytes_to_feed = std::min(transfer_buffer->available(), bytes_still_needed);
uint32_t blocks_sent = 0;
size_t pcm_consumed = 0;
esp_err_t err = this->spdif_encoder_->write(audio_source->data(), bytes_to_feed, write_timeout_ticks,
&blocks_sent, &pcm_consumed);
esp_err_t err = this->spdif_encoder_->write(transfer_buffer->get_buffer_start(), bytes_to_feed,
write_timeout_ticks, &blocks_sent, &pcm_consumed);
if (err != ESP_OK) {
// A failed (or timed-out) send leaves an unsent block in the encoder's stitch buffer;
// resuming would credit the next iteration's bytes against an old block. Bail and
@@ -330,7 +325,7 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
}
if (pcm_consumed > 0) {
audio_source->consume(pcm_consumed);
transfer_buffer->decrease_buffer_length(pcm_consumed);
real_frames_in_block += this->current_stream_info_.bytes_to_frames(pcm_consumed);
}
if (blocks_sent > 0) {
@@ -392,7 +387,9 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
this->spdif_encoder_->reset();
}
audio_source.reset();
if (transfer_buffer != nullptr) {
transfer_buffer.reset();
}
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
@@ -411,9 +408,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
this->sample_rate_, audio_stream_info.get_sample_rate());
return ESP_ERR_NOT_SUPPORTED;
}
const uint8_t bits_per_sample = audio_stream_info.get_bits_per_sample();
if (bits_per_sample != 16 && bits_per_sample != 24 && bits_per_sample != 32) {
ESP_LOGE(TAG, "Only supports 16, 24, or 32 bits per sample (got %u)", (unsigned) bits_per_sample);
if (audio_stream_info.get_bits_per_sample() != 16) {
ESP_LOGE(TAG, "Only supports 16 bits per sample");
return ESP_ERR_NOT_SUPPORTED;
}
if (audio_stream_info.get_channels() != 2) {
@@ -421,8 +417,11 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
return ESP_ERR_NOT_SUPPORTED;
}
// Tell the encoder what input width to expect. 32-bit input is truncated to 24-bit on the wire.
this->spdif_encoder_->set_bytes_per_sample(bits_per_sample / 8);
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO &&
(i2s_slot_bit_width_t) audio_stream_info.get_bits_per_sample() > this->slot_bit_width_) {
ESP_LOGE(TAG, "Stream bits per sample must be less than or equal to the speaker's configuration");
return ESP_ERR_NOT_SUPPORTED;
}
if (!this->parent_->try_lock()) {
ESP_LOGE(TAG, "Parent bus is busy");
@@ -2,7 +2,6 @@
#ifdef USE_ESP32
#include <driver/gpio.h>
#include <driver/i2s_std.h>
#include "esphome/components/audio/audio.h"
@@ -100,7 +99,7 @@ void I2SAudioSpeakerBase::loop() {
}
if (event_group_bits & SpeakerEventGroupBits::ERR_ESP_NO_MEM) {
ESP_LOGE(TAG, "Speaker task setup failed (allocation, preload, or channel enable)");
ESP_LOGE(TAG, "Not enough memory");
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
}
@@ -300,15 +299,6 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() {
i2s_channel_disable(this->tx_handle_);
i2s_del_channel(this->tx_handle_);
this->tx_handle_ = nullptr;
// i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only
// clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
gpio_reset_pin(this->dout_pin_);
gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT);
gpio_set_level(this->dout_pin_, 0);
}
this->parent_->unlock();
}
@@ -19,6 +19,7 @@
namespace esphome::i2s_audio {
// Shared constants used by both standard and SPDIF speaker implementations
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t TASK_STACK_SIZE = 4096;
static constexpr ssize_t TASK_PRIORITY = 19;
@@ -35,7 +36,9 @@ enum SpeakerEventGroupBits : uint32_t {
ERR_ESP_NO_MEM = (1 << 19),
ERR_DROPPED_EVENT = (1 << 20), // ISR overflowed the event queue, dropping a completion event
ERR_PARTIAL_WRITE = (1 << 21), // i2s_channel_write returned fewer bytes than requested
ERR_PARTIAL_WRITE = (1 << 21), // a DMA write returned fewer bytes than requested (or the encoder
// failed to commit a complete block), which breaks the lockstep
// invariant for every subsequent event
ERR_LOCKSTEP_DESYNC = (1 << 22), // i2s_event_queue_ and write_records_queue_ fell out of sync
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
@@ -16,16 +16,8 @@ namespace esphome::i2s_audio {
static const char *const TAG = "i2s_audio.speaker.std";
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
static constexpr size_t DMA_BUFFERS_COUNT = 4;
// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight,
// doubled so that a transient backlog never overruns the queue (which would desync the lockstep
// invariant between i2s_event_queue_ and write_records_queue_).
static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT * 2;
// Generous timeout for ``i2s_channel_write`` blocking. A buffer frees roughly every
// DMA_BUFFER_DURATION_MS, so a multiple of that gives plenty of slack against scheduling jitter
// without masking real failures.
static constexpr TickType_t WRITE_TIMEOUT_TICKS = pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS * (DMA_BUFFERS_COUNT + 1));
static constexpr size_t I2S_EVENT_QUEUE_COUNT = DMA_BUFFERS_COUNT + 1;
void I2SAudioSpeaker::dump_config() {
I2SAudioSpeakerBase::dump_config();
@@ -52,78 +44,31 @@ void I2SAudioSpeaker::run_speaker_task() {
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices.
const size_t ring_buffer_size =
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
const uint32_t frames_per_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
const size_t dma_buffer_bytes = this->current_stream_info_.frames_to_bytes(frames_per_dma_buffer);
const size_t ring_buffer_size = this->current_stream_info_.ms_to_bytes(ring_buffer_duration);
const uint32_t frames_to_fill_single_dma_buffer = this->current_stream_info_.ms_to_frames(DMA_BUFFER_DURATION_MS);
const size_t bytes_to_fill_single_dma_buffer =
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
bool successful_setup = false;
std::unique_ptr<audio::AudioSourceTransferBuffer> transfer_buffer =
audio::AudioSourceTransferBuffer::create(bytes_to_fill_single_dma_buffer);
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
// Pre-zeroed buffer used to silence-pad each DMA descriptor whenever real audio doesn't fully fill it.
RAMAllocator<uint8_t> silence_allocator;
uint8_t *silence_buffer = silence_allocator.allocate(dma_buffer_bytes);
if (silence_buffer != nullptr) {
memset(silence_buffer, 0, dma_buffer_bytes);
if (transfer_buffer != nullptr) {
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
audio_source =
audio::RingBufferAudioSource::create(temp_ring_buffer, dma_buffer_bytes, static_cast<uint8_t>(bytes_per_frame));
if (audio_source != nullptr) {
// audio_source is nullptr if the ring buffer fails to allocate
if (temp_ring_buffer.use_count() == 1) {
transfer_buffer->set_source(temp_ring_buffer);
this->audio_ring_buffer_ = temp_ring_buffer;
successful_setup = true;
}
}
if (successful_setup) {
// Preload every DMA descriptor with silence and push a matching zero-real-frames record per buffer.
// This guarantees that every on_sent event has a corresponding write record from the start, so
// ``i2s_event_queue_`` and ``write_records_queue_`` stay in lockstep for the entire task lifetime.
for (size_t i = 0; i < DMA_BUFFERS_COUNT; i++) {
size_t bytes_loaded = 0;
esp_err_t err = i2s_channel_preload_data(this->tx_handle_, silence_buffer, dma_buffer_bytes, &bytes_loaded);
if (err != ESP_OK || bytes_loaded != dma_buffer_bytes) {
ESP_LOGV(TAG, "Failed to preload silence into DMA buffer %u (err=%d, loaded=%u)", (unsigned) i, (int) err,
(unsigned) bytes_loaded);
successful_setup = false;
break;
}
uint32_t zero_real_frames = 0;
if (xQueueSend(this->write_records_queue_, &zero_real_frames, 0) != pdTRUE) {
// Should never happen: the queue was just reset and is sized for DMA_BUFFERS_COUNT * 2 entries.
ESP_LOGV(TAG, "Failed to push preload write record");
successful_setup = false;
break;
}
}
}
if (successful_setup) {
// Register the on_sent callback BEFORE enabling the channel so the very first transmitted buffer
// generates a queued event that pairs with the first preloaded silence record.
const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb};
i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this);
if (i2s_channel_enable(this->tx_handle_) != ESP_OK) {
ESP_LOGV(TAG, "Failed to enable I2S channel");
successful_setup = false;
}
}
if (!successful_setup) {
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_ESP_NO_MEM);
} else {
bool stop_gracefully = false;
// Number of records currently in ``write_records_queue_`` that carry real audio. Used by graceful
// stop to wait until every real-audio buffer has been confirmed played by an ISR event.
uint32_t pending_real_buffers = 0;
bool tx_dma_underflow = true;
uint32_t frames_written = 0;
uint32_t last_data_received_time = millis();
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_RUNNING);
@@ -132,21 +77,11 @@ void I2SAudioSpeaker::run_speaker_task() {
// - Paused, OR
// - No timeout configured, OR
// - Timeout hasn't elapsed since last data
//
// Always-fill model: every iteration writes exactly one DMA buffer's worth, mixing real audio
// and silence padding as needed. The blocking ``i2s_channel_write`` paces the loop at the DMA
// consumption rate, and every buffer write is matched 1:1 with a record on ``write_records_queue_``.
//
// While paused, the real-audio fill is skipped and the entire DMA buffer is filled with silence;
// the same blocking ``i2s_channel_write`` provides natural pacing (one buffer per ~DMA_BUFFER_DURATION_MS),
// so the lockstep invariant is preserved without burning CPU.
while (this->pause_state_ || !this->timeout_.has_value() ||
(millis() - last_data_received_time) <= this->timeout_.value()) {
uint32_t event_group_bits = xEventGroupGetBits(this->event_group_);
if (event_group_bits & SpeakerEventGroupBits::COMMAND_STOP) {
// COMMAND_STOP is set both by user-initiated stop() and by the ISR when it drops a completion
// event (paired with ERR_DROPPED_EVENT so loop() can distinguish the two cases).
xEventGroupClearBits(this->event_group_, SpeakerEventGroupBits::COMMAND_STOP);
ESP_LOGV(TAG, "Exiting: COMMAND_STOP received");
break;
@@ -162,126 +97,89 @@ void I2SAudioSpeaker::run_speaker_task() {
break;
}
// Drain ISR-stamped completion events. Each event corresponds 1:1 with a write_records_queue_
// entry by construction (preloaded records at startup, plus exactly one record pushed per
// iteration alongside exactly one DMA-buffer-sized write).
int64_t write_timestamp;
bool lockstep_broken = false;
while (xQueueReceive(this->i2s_event_queue_, &write_timestamp, 0)) {
uint32_t real_frames = 0;
if (xQueueReceive(this->write_records_queue_, &real_frames, 0) != pdTRUE) {
// Should never happen: would indicate the lockstep invariant is broken.
ESP_LOGV(TAG, "Event without matching write record");
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
lockstep_broken = true;
// Receives timing events from the I2S on_sent callback. If actual audio data was sent in this event, it passes
// on the timing info via the audio_output_callback.
uint32_t frames_sent = frames_to_fill_single_dma_buffer;
if (frames_to_fill_single_dma_buffer > frames_written) {
tx_dma_underflow = true;
frames_sent = frames_written;
const uint32_t frames_zeroed = frames_to_fill_single_dma_buffer - frames_written;
write_timestamp -= this->current_stream_info_.frames_to_microseconds(frames_zeroed);
} else {
tx_dma_underflow = false;
}
frames_written -= frames_sent;
// Standard I2S mode: fire callback immediately for each event
if (frames_sent > 0) {
this->audio_output_callback_(frames_sent, write_timestamp);
}
}
if (this->pause_state_) {
// Pause state is accessed atomically, so thread safe
// Delay so the task yields, then skip transferring audio data
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
continue;
}
// Wait half the duration of the data already written to the DMA buffers for new audio data
// The millisecond helper modifies the frames_written variable, so use the microsecond helper and divide by 1000
uint32_t read_delay = (this->current_stream_info_.frames_to_microseconds(frames_written) / 1000) / 2;
size_t bytes_read = transfer_buffer->transfer_data_from_source(pdMS_TO_TICKS(read_delay));
uint8_t *new_data = transfer_buffer->get_buffer_end() - bytes_read;
if (bytes_read > 0) {
this->apply_software_volume_(new_data, bytes_read);
this->swap_esp32_mono_samples_(new_data, bytes_read);
}
if (transfer_buffer->available() == 0) {
if (stop_gracefully && tx_dma_underflow) {
break;
}
if (real_frames > 0) {
pending_real_buffers--;
// Real audio is packed at the start of each DMA buffer with any silence padding on the
// tail, so the real audio finished playing earlier than the buffer-completion timestamp
// by the duration of the trailing zeros.
const uint32_t silence_frames = frames_per_dma_buffer - real_frames;
const int64_t adjusted_ts =
write_timestamp - this->current_stream_info_.frames_to_microseconds(silence_frames);
this->audio_output_callback_(real_frames, adjusted_ts);
vTaskDelay(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS / 2));
} else {
size_t bytes_written = 0;
if (tx_dma_underflow) {
// Temporarily disable channel and callback to reset the I2S driver's internal DMA buffer queue
i2s_channel_disable(this->tx_handle_);
const i2s_event_callbacks_t null_callbacks = {.on_sent = nullptr};
i2s_channel_register_event_callback(this->tx_handle_, &null_callbacks, this);
i2s_channel_preload_data(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
&bytes_written);
} else {
// Audio is already playing, use regular write to add to the DMA buffers
i2s_channel_write(this->tx_handle_, transfer_buffer->get_buffer_start(), transfer_buffer->available(),
&bytes_written, DMA_BUFFER_DURATION_MS);
}
}
if (lockstep_broken) {
break;
}
// Graceful stop: exit only after the source's exposed chunk is drained, the underlying ring
// buffer has nothing left to hand over, and every real-audio buffer we submitted has been
// confirmed played. ``has_buffered_data()`` returns bytes still sitting in the ring buffer
// awaiting fill().
if (stop_gracefully && audio_source->available() == 0 && !this->has_buffered_data() &&
pending_real_buffers == 0) {
ESP_LOGV(TAG, "Exiting: graceful stop complete");
break;
}
// Compose exactly one DMA buffer's worth: drain as much real audio as the source currently
// exposes (may take multiple fill() calls when crossing a ring buffer wrap), then pad any
// remainder with silence. All writes pack into the next free DMA descriptor in order, so the
// descriptor ends up holding [real audio][silence padding].
size_t bytes_written_total = 0;
size_t real_bytes_total = 0;
bool partial_write_failure = false;
if (!this->pause_state_) {
while (bytes_written_total < dma_buffer_bytes) {
size_t bytes_read = audio_source->fill(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS) / 2, false);
if (bytes_read > 0) {
uint8_t *new_data = audio_source->mutable_data() + audio_source->available() - bytes_read;
this->apply_software_volume_(new_data, bytes_read);
this->swap_esp32_mono_samples_(new_data, bytes_read);
}
const size_t to_write = std::min(audio_source->available(), dma_buffer_bytes - bytes_written_total);
if (to_write == 0) {
// Ring buffer has nothing more to hand over right now; pad the rest of this DMA buffer
// with silence so the lockstep invariant (one write per iteration) is preserved.
break;
}
size_t bw = 0;
i2s_channel_write(this->tx_handle_, audio_source->data(), to_write, &bw, WRITE_TIMEOUT_TICKS);
if (bw != to_write) {
// A short real-audio write breaks DMA descriptor alignment for every subsequent event;
// the only safe recovery is to restart the task.
ESP_LOGV(TAG, "Partial real audio write: %u of %u bytes", (unsigned) bw, (unsigned) to_write);
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
partial_write_failure = true;
break;
}
audio_source->consume(bw);
bytes_written_total += bw;
real_bytes_total += bw;
}
if (real_bytes_total > 0) {
if (bytes_written > 0) {
last_data_received_time = millis();
frames_written += this->current_stream_info_.bytes_to_frames(bytes_written);
transfer_buffer->decrease_buffer_length(bytes_written);
if (tx_dma_underflow) {
tx_dma_underflow = false;
// Enable the on_sent callback and channel after preload
xQueueReset(this->i2s_event_queue_);
const i2s_event_callbacks_t callbacks = {.on_sent = i2s_on_sent_cb};
i2s_channel_register_event_callback(this->tx_handle_, &callbacks, this);
i2s_channel_enable(this->tx_handle_);
}
}
}
if (partial_write_failure) {
break;
}
const size_t silence_bytes = dma_buffer_bytes - bytes_written_total;
if (silence_bytes > 0) {
size_t bw = 0;
i2s_channel_write(this->tx_handle_, silence_buffer, silence_bytes, &bw, WRITE_TIMEOUT_TICKS);
if (bw != silence_bytes) {
// Same descriptor-alignment hazard as a partial real-audio write.
ESP_LOGV(TAG, "Partial silence write: %u of %u bytes", (unsigned) bw, (unsigned) silence_bytes);
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_PARTIAL_WRITE);
break;
}
}
const uint32_t real_frames_in_buffer = this->current_stream_info_.bytes_to_frames(real_bytes_total);
// Push the matching write record. Capacity headroom in I2S_EVENT_QUEUE_COUNT guarantees this
// succeeds even with a transient backlog of unprocessed events; if it ever fails the lockstep
// invariant is broken and every subsequent timestamp would be silently wrong, so bail.
if (xQueueSend(this->write_records_queue_, &real_frames_in_buffer, 0) != pdTRUE) {
ESP_LOGV(TAG, "Exiting: write records queue full");
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::ERR_LOCKSTEP_DESYNC);
break;
}
if (real_frames_in_buffer > 0) {
pending_real_buffers++;
}
}
}
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPING);
audio_source.reset();
if (silence_buffer != nullptr) {
silence_allocator.deallocate(silence_buffer, dma_buffer_bytes);
silence_buffer = nullptr;
if (transfer_buffer != nullptr) {
transfer_buffer.reset();
}
xEventGroupSetBits(this->event_group_, SpeakerEventGroupBits::TASK_STOPPED);
@@ -402,7 +300,7 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
return err;
}
// The speaker task will enable the channel after preloading.
i2s_channel_enable(this->tx_handle_);
return ESP_OK;
}
@@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start)
static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel
// BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33
// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero.
// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33.
static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33;
// Constexpr BMC encoder for compile-time LUT generation.
@@ -36,43 +36,21 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) {
return bmc;
}
// Compile-time parity helper (constexpr-friendly, runs only at LUT build time).
static constexpr uint32_t bmc_lut_parity(uint32_t value, uint32_t num_bits) {
uint32_t p = 0;
for (uint32_t b = 0; b < num_bits; b++)
p ^= (value >> b) & 1u;
return p;
}
// Combined BMC + phase-delta lookup tables.
// Each entry packs the BMC pattern (lower bits, phase=high start) together with
// a phase-mask delta in bits 16-31 (0xFFFF if the input has odd parity, else 0).
// XORing the delta into the running phase mask propagates parity across chunks
// without an explicit popcount.
// 4-bit BMC lookup table: 16 entries x uint32_t = 64 bytes in flash.
// Bits 0-7 : 8-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
// 4-bit BMC lookup table: 16 entries (16 bytes in flash)
// Index: 4-bit data value (0-15), always phase=true start
static constexpr auto BMC_LUT_4 = [] {
std::array<uint32_t, 16> t{};
for (uint32_t i = 0; i < 16; i++) {
uint32_t bmc = bmc_lut_encode(i, 4);
uint32_t delta = bmc_lut_parity(i, 4) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
std::array<uint8_t, 16> t{};
for (uint32_t i = 0; i < 16; i++)
t[i] = static_cast<uint8_t>(bmc_lut_encode(i, 4));
return t;
}();
// 8-bit BMC lookup table: 256 entries x uint32_t = 1024 bytes in flash.
// Bits 0-15 : 16-bit BMC pattern (phase=high start)
// Bits 16-31 : phase-mask delta (0xFFFFu if odd parity, else 0)
// 8-bit BMC lookup table: 256 entries (512 bytes in flash)
// Index: 8-bit data value (0-255), always phase=true start
static constexpr auto BMC_LUT_8 = [] {
std::array<uint32_t, 256> t{};
for (uint32_t i = 0; i < 256; i++) {
uint32_t bmc = bmc_lut_encode(i, 8);
uint32_t delta = bmc_lut_parity(i, 8) ? 0xFFFF0000u : 0u;
t[i] = bmc | delta;
}
std::array<uint16_t, 256> t{};
for (uint32_t i = 0; i < 256; i++)
t[i] = bmc_lut_encode(i, 8);
return t;
}();
@@ -85,7 +63,7 @@ bool SPDIFEncoder::setup() {
}
ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES);
// Build initial channel status block with default sample rate and width
// Build initial channel status block with default sample rate
this->build_channel_status_();
this->reset();
@@ -95,7 +73,7 @@ bool SPDIFEncoder::setup() {
void SPDIFEncoder::reset() {
this->spdif_block_ptr_ = this->spdif_block_buf_.get();
this->frame_in_block_ = 0;
this->block_buf_is_silence_block_ = false;
this->is_left_channel_ = true;
}
void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
@@ -106,27 +84,31 @@ void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
}
}
void SPDIFEncoder::set_bytes_per_sample(uint8_t bytes_per_sample) {
if (bytes_per_sample != 2 && bytes_per_sample != 3 && bytes_per_sample != 4) {
ESP_LOGE(TAG, "Unsupported bytes per sample: %u", (unsigned) bytes_per_sample);
return;
}
if (this->bytes_per_sample_ != bytes_per_sample) {
this->bytes_per_sample_ = bytes_per_sample;
this->build_channel_status_();
// Discard any partial block built at the previous width so we never mix widths on the wire.
this->reset();
ESP_LOGD(TAG, "Input width set to %u-bit", (unsigned) bytes_per_sample * 8);
}
}
void SPDIFEncoder::build_channel_status_() {
// IEC 60958-3 Consumer Channel Status Block (192 bits = 24 bytes)
// Transmitted LSB-first within each byte, one bit per frame via C bit.
// Any cached silence block was built for the previous channel status; it is now stale.
this->block_buf_is_silence_block_ = false;
// Transmitted LSB-first within each byte, one bit per frame via C bit
//
// Byte 0: Control bits
// Bit 0: 0 = Consumer format (not professional AES3)
// Bit 1: 0 = PCM audio (not non-audio data like AC3)
// Bit 2: 0 = No copyright assertion
// Bits 3-5: 000 = No pre-emphasis
// Bits 6-7: 00 = Mode 0 (basic consumer format)
//
// Byte 1: Category code (0x00 = general, 0x01 = CD, etc.)
//
// Byte 2: Source/channel numbers
// Bits 0-3: Source number (0 = unspecified)
// Bits 4-7: Channel number (0 = unspecified)
//
// Byte 3: Sample frequency and clock accuracy
// Bits 0-3: Sample frequency code
// Bits 4-5: Clock accuracy (00 = Level II, ±1000 ppm, appropriate for ESP32)
// Bits 6-7: Reserved (0)
//
// Bytes 4-23: Reserved (zeros for basic compliance)
// Clear all bytes first
this->channel_status_.fill(0);
// Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0
@@ -158,148 +140,132 @@ void SPDIFEncoder::build_channel_status_() {
// Byte 3: freq_code in bits 0-3, clock accuracy (00) in bits 4-5
this->channel_status_[3] = freq_code; // Clock accuracy bits 4-5 are already 0
// Byte 4: Word length encoding (IEC 60958-3 consumer)
// bit 0: max length flag (0 = max 20 bits, 1 = max 24 bits)
// bits 1-3: word length code relative to the max
// For our supported widths:
// 16-bit (max 20): 0b0010 = 0x02 -- "16 bits, max 20"
// 24-bit (max 24): 0b1101 = 0x0D -- "24 bits, max 24"
// 32-bit input is truncated to 24-bit on the wire, so use the 24-bit code.
uint8_t word_length_code;
switch (this->bytes_per_sample_) {
case 2:
word_length_code = 0x02;
break;
case 3: // Shared case
case 4:
word_length_code = 0x0D;
break;
default:
word_length_code = 0x00; // not specified
break;
// Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.)
}
HOT void SPDIFEncoder::encode_sample_(const uint8_t *pcm_sample) {
// ============================================================================
// Build raw 32-bit subframe (IEC 60958 format)
// ============================================================================
// Bit layout:
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
// Bits 4-7: Auxiliary audio data (zeros for 16-bit audio)
// Bits 8-11: Audio LSB extension (zeros for 16-bit audio)
// Bits 12-27: 16-bit audio sample (MSB-aligned in 20-bit audio field)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// Place 16-bit audio sample at bits 12-27 (little-endian input: [0]=LSB, [1]=MSB)
uint32_t raw_subframe = (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
// V = 0 (valid audio), U = 0 (no user data)
// C = channel status bit for current frame (same bit used for both L and R subframes)
bool c_bit = this->get_channel_status_bit_(this->frame_in_block_);
if (c_bit) {
raw_subframe |= (1U << 30);
}
this->channel_status_[4] = word_length_code;
}
// Extract the C bit for the given frame from channel_status_ and shift it into bit 30
// so it can be OR'd directly into a raw subframe.
ESPHOME_ALWAYS_INLINE static inline uint32_t c_bit_for_frame(const std::array<uint8_t, 24> &channel_status,
uint32_t frame) {
return static_cast<uint32_t>((channel_status[frame >> 3] >> (frame & 7)) & 1u) << 30;
}
// Calculate even parity over bits 4-30
// This ensures consistent BMC ending phase regardless of audio content
uint32_t bits_4_30 = (raw_subframe >> 4) & 0x07FFFFFF; // 27 bits (4-30)
uint32_t ones_count = __builtin_popcount(bits_4_30);
uint32_t parity = ones_count & 1; // 1 if odd count, 0 if even
raw_subframe |= parity << 31; // Set P bit to make total even
// ============================================================================
// IEC 60958 subframe bit layout
// ============================================================================
// Bits 0-3: Preamble (handled separately, not in raw_subframe)
// Bits 4-7: Auxiliary audio data / 24-bit audio LSB
// Bits 8-11: Audio LSB extension (zero for 16-bit, low nibble of audio for 24-bit)
// Bits 12-27: Audio sample (16 high bits in 16-bit mode, mid 16 bits in 24-bit mode)
// Bit 28: V (Validity) - 0 = valid audio
// Bit 29: U (User data) - 0
// Bit 30: C (Channel status) - from channel status block
// Bit 31: P (Parity) - even parity over bits 4-31
// ============================================================================
// Build a raw IEC 60958 subframe from PCM little-endian input of width Bps bytes.
// Caller is responsible for OR-ing in the C bit and parity.
template<uint8_t Bps> ESPHOME_ALWAYS_INLINE static inline uint32_t build_raw_subframe(const uint8_t *pcm_sample) {
static_assert(Bps == 2 || Bps == 3 || Bps == 4, "Unsupported bytes per sample");
if constexpr (Bps == 2) {
// 16-bit input: MSB-aligned in the 20-bit audio field, bits 12-27.
return (static_cast<uint32_t>(pcm_sample[1]) << 20) | (static_cast<uint32_t>(pcm_sample[0]) << 12);
} else if constexpr (Bps == 3) {
// 24-bit input: full 24-bit audio field, bits 4-27.
return (static_cast<uint32_t>(pcm_sample[2]) << 20) | (static_cast<uint32_t>(pcm_sample[1]) << 12) |
(static_cast<uint32_t>(pcm_sample[0]) << 4);
} else { // Bps == 4
// 32-bit input truncated to 24-bit: drop the lowest byte.
return (static_cast<uint32_t>(pcm_sample[3]) << 20) | (static_cast<uint32_t>(pcm_sample[2]) << 12) |
(static_cast<uint32_t>(pcm_sample[1]) << 4);
}
}
// BMC-encode a subframe and write the two output uint32 words to dst. Caller passes
// raw_subframe with the C bit set (bit 30) and the P bit cleared (bit 31 = 0). P is
// derived from the cumulative parity-mask delta of the per-byte LUT lookups.
//
// I2S halfword swap means word[0] transmits as: bits 24-31, 16-23, 8-15, 0-7.
// word[1] transmits as: bits 16-31, 0-15. Within each halfword, MSB-first.
// All preambles end at phase HIGH, so phase=true at the start of bit 4.
//
// P-bit derivation: BMC_LUT_*'s upper half encodes the parity of the input chunk. Each
// chunk's parity delta is shifted down (`lut >> 16`) into a phase_mask that lives in the
// low 16 bits, so the same value can also be XORed against subsequent BMC patterns to
// invert phase. XOR'ing those deltas through all chunks (with bit 31 = 0) yields the
// parity of bits 4-30 in the low bits of phase_mask -- the required value of the P bit
// for even total parity. The BMC of bit 31 lives in bit 0 of the high-byte BMC output
// (i = 7 maps to position (8-1-7)*2 = 0); flipping the source bit flips only the lower
// BMC bit (= phase XOR bit), so applying P is `bmc_24_31 ^= phase_mask & 1u`.
template<uint8_t Bps>
ESPHOME_ALWAYS_INLINE static inline void bmc_encode_subframe(uint32_t raw_subframe, uint8_t preamble, uint32_t *dst) {
if constexpr (Bps == 2) {
// 16-bit path: bits 4-11 are zero, encoded inline as BMC_ZERO_NIBBLE constants.
// Eight zero source bits with start phase=HIGH end at phase=HIGH (popcount of zeros is even),
// so encoding of bits 12-15 starts at phase=true. Zeros contribute 0 to parity.
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = lut_n & 0xFFu;
uint32_t phase_mask = lut_n >> 16; // 0xFFFFu if odd parity, else 0
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t lut_m = BMC_LUT_8[byte_mid];
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_m >> 16;
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint32_t lut_h = BMC_LUT_8[byte_hi];
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_h >> 16;
// phase_mask now reflects parity of bits 4-30. Apply P by flipping bit 0 of bmc_24_31.
bmc_24_31 ^= phase_mask & 1u;
dst[0] = bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
// ============================================================================
// Select preamble based on position in block and channel
// ============================================================================
// B = block start (left channel, frame 0 of 192-frame block)
// M = left channel (frames 1-191)
// W = right channel (all frames)
uint8_t preamble;
if (this->is_left_channel_) {
preamble = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
} else {
// 24-bit (and 32-bit truncated) path: bits 4-11 are live audio.
uint32_t byte_lo = (raw_subframe >> 4) & 0xFF;
uint32_t lut_l = BMC_LUT_8[byte_lo];
uint32_t bmc_4_11 = lut_l & 0xFFFFu;
uint32_t phase_mask = lut_l >> 16; // 0xFFFFu if odd parity, else 0
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t lut_n = BMC_LUT_4[nibble];
uint32_t bmc_12_15 = (lut_n & 0xFFu) ^ (phase_mask & 0xFFu);
phase_mask ^= lut_n >> 16;
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t lut_m = BMC_LUT_8[byte_mid];
uint32_t bmc_16_23 = (lut_m & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_m >> 16;
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF; // bit 7 (= P) is 0 by precondition
uint32_t lut_h = BMC_LUT_8[byte_hi];
uint32_t bmc_24_31 = (lut_h & 0xFFFFu) ^ phase_mask;
phase_mask ^= lut_h >> 16;
bmc_24_31 ^= phase_mask & 1u;
// word[0]: bits 24-31 = preamble, bits 8-23 = bmc(4-11), bits 0-7 = bmc(12-15)
// word[1]: bits 16-31 = bmc(16-23), bits 0-15 = bmc(24-31)
dst[0] = bmc_12_15 | (bmc_4_11 << 8) | (static_cast<uint32_t>(preamble) << 24);
dst[1] = bmc_24_31 | (bmc_16_23 << 16);
preamble = PREAMBLE_W;
}
}
template<uint8_t Bps> void SPDIFEncoder::encode_silence_frame_() {
static constexpr uint8_t SILENCE[4] = {0, 0, 0, 0};
uint32_t raw = build_raw_subframe<Bps>(SILENCE) | c_bit_for_frame(this->channel_status_, this->frame_in_block_);
uint8_t preamble_l = (this->frame_in_block_ == 0) ? PREAMBLE_B : PREAMBLE_M;
bmc_encode_subframe<Bps>(raw, preamble_l, this->spdif_block_ptr_);
bmc_encode_subframe<Bps>(raw, PREAMBLE_W, this->spdif_block_ptr_ + 2);
this->spdif_block_ptr_ += 4;
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
this->frame_in_block_ = 0;
// ============================================================================
// BMC encode the data portion (bits 4-31) using lookup tables
// ============================================================================
// The I2S uses 16-bit halfword swap: bits 16-31 transmit before bits 0-15.
// This applies to BOTH word[0] and word[1].
//
// word[0] transmission order: [16-23] → [24-31] → [0-7] → [8-15]
// For correct S/PDIF subframe order (preamble → aux → audio):
// - bits 16-23: preamble (8 BMC bits)
// - bits 24-31: BMC(subframe bits 4-7) - first aux nibble
// - bits 0-7: BMC(subframe bits 8-11) - second aux nibble
// - bits 8-15: BMC(subframe bits 12-15) - audio low nibble
//
// word[1] transmission order: [16-31] → [0-15]
// For correct S/PDIF subframe order:
// - bits 16-31: BMC(subframe bits 16-23) - audio mid byte
// - bits 0-15: BMC(subframe bits 24-31) - audio high nibble + VUCP
// ============================================================================
// All preambles end at phase HIGH. Bits 4-11 are always zero for 16-bit audio;
// two zero nibbles flip phase 8 times total → back to HIGH.
// So bits 12-15 always start encoding at phase=true.
// Bits 12-15: 4-bit LUT lookup (always phase=true start)
uint32_t nibble = (raw_subframe >> 12) & 0xF;
uint32_t bmc_12_15 = BMC_LUT_4[nibble];
// Phase tracking via branchless XOR mask:
// - 0x0000 means phase=true (use LUT value directly)
// - 0xFFFF means phase=false (complement LUT value)
// End phase = start XOR (popcount & 1) since zero-bits flip phase,
// and for even bit widths: #zeros parity == popcount parity.
uint32_t phase_mask = -(__builtin_popcount(nibble) & 1u) & 0xFFFF;
// Bits 16-23: 8-bit LUT lookup with phase correction
uint32_t byte_mid = (raw_subframe >> 16) & 0xFF;
uint32_t bmc_16_23 = BMC_LUT_8[byte_mid] ^ phase_mask;
phase_mask ^= -(__builtin_popcount(byte_mid) & 1u) & 0xFFFF;
// Bits 24-31: 8-bit LUT lookup with phase correction
uint32_t byte_hi = (raw_subframe >> 24) & 0xFF;
uint32_t bmc_24_31 = BMC_LUT_8[byte_hi] ^ phase_mask;
// ============================================================================
// Combine with correct positioning for I2S transmission
// ============================================================================
// I2S with halfword swap: transmits bits 16-31, then bits 0-15.
// Within each halfword, MSB (highest bit) is transmitted first.
//
// For upper halfword (bits 16-31): bit 31 → bit 16
// For lower halfword (bits 0-15): bit 15 → bit 0
//
// Desired S/PDIF order: preamble → bmc_4_7 → bmc_8_11 → bmc_12_15
//
// word[0] layout for correct transmission:
// bits 24-31: preamble (transmitted 1st, as MSB of upper halfword)
// bits 16-23: BMC_ZERO_NIBBLE (transmitted 2nd, aux bits 4-7)
// bits 8-15: BMC_ZERO_NIBBLE (transmitted 3rd, aux bits 8-11)
// bits 0-7: bmc_12_15 (transmitted 4th, audio low nibble)
//
// word[1] layout:
// bits 16-31: bmc_16_23 (transmitted 5th)
// bits 0-15: bmc_24_31 (transmitted 6th)
this->spdif_block_ptr_[0] =
bmc_12_15 | (BMC_ZERO_NIBBLE << 8) | (BMC_ZERO_NIBBLE << 16) | (static_cast<uint32_t>(preamble) << 24);
this->spdif_block_ptr_[1] = bmc_24_31 | (bmc_16_23 << 16);
this->spdif_block_ptr_ += 2;
// ============================================================================
// Update position tracking
// ============================================================================
if (!this->is_left_channel_) {
// Completed a stereo frame, advance frame counter
if (++this->frame_in_block_ >= SPDIF_BLOCK_SAMPLES) {
this->frame_in_block_ = 0;
}
}
this->is_left_channel_ = !this->is_left_channel_;
}
esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
@@ -329,162 +295,79 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
return err;
}
template<uint8_t Bps>
HOT esp_err_t SPDIFEncoder::write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait,
uint32_t *blocks_sent, size_t *bytes_consumed) {
const uint8_t *pcm_data = src;
const uint8_t *const pcm_end = src + size;
uint32_t block_count = 0;
// Hot state lives in locals so the compiler can keep it in registers across the
// per-frame encoding work; byte writes through block_ptr may alias the member fields,
// which would block register allocation if the encoding read them directly from this->*.
uint32_t *block_ptr = this->spdif_block_ptr_;
uint32_t *const block_buf = this->spdif_block_buf_.get();
uint32_t *const block_end = block_buf + SPDIF_BLOCK_SIZE_U32;
uint32_t frame = this->frame_in_block_;
const std::array<uint8_t, 24> &channel_status = this->channel_status_;
auto save_state = [&]() {
this->spdif_block_ptr_ = block_ptr;
this->frame_in_block_ = static_cast<uint8_t>(frame);
};
auto report_out_params = [&]() {
if (blocks_sent != nullptr)
*blocks_sent = block_count;
if (bytes_consumed != nullptr)
*bytes_consumed = pcm_data - src;
};
// Send a completed block if the buffer is full, propagating any error.
// send_block_ resets this->spdif_block_ptr_ to block_buf on success and leaves it
// unchanged on error -- mirror both behaviors in our local block_ptr.
auto maybe_send = [&]() -> esp_err_t {
if (block_ptr >= block_end) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
return ESP_OK;
};
// Hot path: encode L+R pairs in two peeled sub-loops. Frame 0 carries the only
// buffer-full check and uses PREAMBLE_B (a block fills exactly when frame wraps from
// 191 back to 0). Frames 1..191 use PREAMBLE_M and need no buffer-full check or
// preamble branch. The encoding body is inlined here so block_ptr lives in a register
// for the duration of the loop.
while (pcm_data + 2 * Bps <= pcm_end) {
if (frame == 0) {
esp_err_t err = maybe_send();
if (err != ESP_OK)
return err;
uint32_t c_bit = c_bit_for_frame(channel_status, 0);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_B, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
frame = 1;
pcm_data += 2 * Bps;
}
// The inner loop runs until min(SPDIF_BLOCK_SAMPLES, frame + input_frames). The
// input-size bound is folded into end_frame so a single `frame < end_frame` test
// governs termination.
uint32_t input_frames = static_cast<uint32_t>(pcm_end - pcm_data) / (2u * Bps);
uint32_t end_frame = SPDIF_BLOCK_SAMPLES;
if (frame + input_frames < end_frame)
end_frame = frame + input_frames;
while (frame < end_frame) {
uint32_t c_bit = c_bit_for_frame(channel_status, frame);
uint32_t raw_l = build_raw_subframe<Bps>(pcm_data) | c_bit;
uint32_t raw_r = build_raw_subframe<Bps>(pcm_data + Bps) | c_bit;
bmc_encode_subframe<Bps>(raw_l, PREAMBLE_M, block_ptr);
bmc_encode_subframe<Bps>(raw_r, PREAMBLE_W, block_ptr + 2);
block_ptr += 4;
++frame;
pcm_data += 2 * Bps;
}
if (frame >= SPDIF_BLOCK_SAMPLES)
frame = 0;
size_t SPDIFEncoder::get_pending_pcm_bytes() const {
if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) {
return 0;
}
// Send any complete block that was just finished.
if (block_ptr >= block_end) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
save_state();
report_out_params();
return err;
}
block_ptr = block_buf;
++block_count;
}
save_state();
report_out_params();
return ESP_OK;
// Each PCM sample (2 bytes) produces 2 uint32_t values in the SPDIF buffer
// So pending uint32s / 2 = pending samples, and each sample is 2 bytes
size_t pending_uint32s = this->spdif_block_ptr_ - this->spdif_block_buf_.get();
size_t pending_samples = pending_uint32s / 2;
return pending_samples * 2; // 2 bytes per sample
}
HOT esp_err_t SPDIFEncoder::write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed) {
if (size > 0) {
// Real PCM is about to be encoded into the buffer, so it is no longer a full-silence block.
this->block_buf_is_silence_block_ = false;
}
switch (this->bytes_per_sample_) {
case 2:
return this->write_typed_<2>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 3:
return this->write_typed_<3>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
case 4:
return this->write_typed_<4>(src, size, ticks_to_wait, blocks_sent, bytes_consumed);
default:
return ESP_ERR_INVALID_STATE;
}
}
const uint8_t *pcm_data = src;
const uint8_t *pcm_end = src + size;
uint32_t block_count = 0;
template<uint8_t Bps> esp_err_t SPDIFEncoder::flush_with_silence_typed_(TickType_t ticks_to_wait) {
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty) and
// send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
const bool was_empty = (this->spdif_block_ptr_ == this->spdif_block_buf_.get());
// Continuous-silence idle case: a full silence block is byte-identical every time for the
// active channel status, so when the buffer already holds one, re-send it as-is.
if (was_empty && this->block_buf_is_silence_block_) {
return this->send_block_(ticks_to_wait);
while (pcm_data < pcm_end) {
// Check if there's a pending complete block from a previous failed send
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = pcm_data - src;
}
return err;
}
++block_count;
}
// Pad with silence frames at the configured width.
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_silence_frame_<Bps>();
}
// The buffer is a reusable full-silence block only if it was built entirely from silence; a
// partial real-audio block padded out with silence is not.
this->block_buf_is_silence_block_ = was_empty;
// Encode one 16-bit sample
this->encode_sample_(pcm_data);
pcm_data += 2;
}
return this->send_block_(ticks_to_wait);
// Send any complete block that was just finished
if (this->spdif_block_ptr_ >= &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
esp_err_t err = this->send_block_(ticks_to_wait);
if (err != ESP_OK) {
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = pcm_data - src;
}
return err;
}
++block_count;
}
if (blocks_sent != nullptr) {
*blocks_sent = block_count;
}
if (bytes_consumed != nullptr) {
*bytes_consumed = size;
}
return ESP_OK;
}
esp_err_t SPDIFEncoder::flush_with_silence(TickType_t ticks_to_wait) {
switch (this->bytes_per_sample_) {
case 2:
return this->flush_with_silence_typed_<2>(ticks_to_wait);
case 3:
return this->flush_with_silence_typed_<3>(ticks_to_wait);
case 4:
return this->flush_with_silence_typed_<4>(ticks_to_wait);
default:
return ESP_ERR_INVALID_STATE;
// If a complete block is already pending (from a previous failed send), emit just that block.
// Otherwise pad the partial block with silence (or generate a full silence block if empty)
// and send. Always emits exactly one block on success.
if (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
static const uint8_t SILENCE[2] = {0, 0};
while (this->spdif_block_ptr_ < &this->spdif_block_buf_[SPDIF_BLOCK_SIZE_U32]) {
this->encode_sample_(SILENCE);
}
}
return this->send_block_(ticks_to_wait);
}
} // namespace esphome::i2s_audio
@@ -24,6 +24,8 @@ static constexpr uint16_t SPDIF_BLOCK_SIZE_BYTES = SPDIF_BLOCK_SAMPLES * (EMULAT
static constexpr uint32_t SPDIF_BLOCK_SIZE_U32 = SPDIF_BLOCK_SIZE_BYTES / sizeof(uint32_t); // 3072 bytes / 4 = 768
// I2S frame count for one SPDIF block (for new driver where frame = 8 bytes for 32-bit stereo)
static constexpr uint32_t SPDIF_BLOCK_I2S_FRAMES = SPDIF_BLOCK_SIZE_BYTES / 8; // 3072 / 8 = 384 frames
// PCM bytes needed for one complete SPDIF block (192 stereo frames * 2 bytes per sample * 2 channels)
static constexpr uint16_t SPDIF_PCM_BYTES_PER_BLOCK = SPDIF_BLOCK_SAMPLES * 2 * 2; // = 768 bytes
/// Callback signature for block completion (raw function pointer for minimal overhead)
/// @param user_ctx User context pointer passed during callback registration
@@ -62,16 +64,8 @@ class SPDIFEncoder {
/// @brief Check if currently in preload mode
bool is_preload_mode() const { return this->preload_mode_; }
/// @brief Set input PCM width: 2 = 16-bit, 3 = 24-bit, 4 = 32-bit (truncated to 24-bit on the wire).
/// Must be called before write() if input width changes from the default (16-bit). Triggers a
/// channel-status rebuild to reflect the new word length.
void set_bytes_per_sample(uint8_t bytes_per_sample);
/// @brief Get the configured input PCM width in bytes per sample
uint8_t get_bytes_per_sample() const { return this->bytes_per_sample_; }
/// @brief Convert PCM audio data to SPDIF BMC encoded data
/// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample)
/// @param src Source PCM audio data (16-bit stereo)
/// @param size Size of source data in bytes
/// @param ticks_to_wait Timeout for blocking writes
/// @param blocks_sent Optional pointer to receive the number of complete SPDIF blocks sent
@@ -80,6 +74,17 @@ class SPDIFEncoder {
esp_err_t write(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent = nullptr,
size_t *bytes_consumed = nullptr);
/// @brief Get the number of PCM bytes currently pending in the partial block buffer
/// @return Number of pending PCM bytes (0 to SPDIF_PCM_BYTES_PER_BLOCK - 1)
size_t get_pending_pcm_bytes() const;
/// @brief Get the number of PCM frames currently pending in the partial block buffer
/// @return Number of pending PCM frames (0 to SPDIF_BLOCK_SAMPLES - 1)
uint32_t get_pending_frames() const { return this->get_pending_pcm_bytes() / 4; }
/// @brief Check if there is a partial block pending
bool has_pending_data() const { return this->spdif_block_ptr_ != this->spdif_block_buf_.get(); }
/// @brief Emit one complete SPDIF block: pad any pending partial block with silence and send,
/// or send a full silence block if nothing is pending. Always produces exactly one block on success.
/// @param ticks_to_wait Timeout for blocking writes
@@ -90,7 +95,7 @@ class SPDIFEncoder {
void reset();
/// @brief Set the sample rate for Channel Status Block encoding
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000)
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000, 96000)
/// Call this before writing audio data to ensure correct channel status.
void set_sample_rate(uint32_t sample_rate);
@@ -98,19 +103,8 @@ class SPDIFEncoder {
uint32_t get_sample_rate() const { return this->sample_rate_; }
protected:
/// @brief Encode a single stereo silence frame at the current block position.
/// @note Used only by flush_with_silence_typed_ to pad; the hot write path inlines the
/// encoding body directly into write_typed_ to keep block_ptr / frame_in_block_ in registers.
template<uint8_t Bps> void encode_silence_frame_();
/// @brief Templated write loop. Called from the public write() via runtime dispatch on bytes_per_sample_.
template<uint8_t Bps>
HOT esp_err_t write_typed_(const uint8_t *src, size_t size, TickType_t ticks_to_wait, uint32_t *blocks_sent,
size_t *bytes_consumed);
/// @brief Templated flush-with-silence. Pads the pending block with zeros at the configured width
/// (or builds a full silence block when nothing is pending) and sends it. Always emits one block.
template<uint8_t Bps> esp_err_t flush_with_silence_typed_(TickType_t ticks_to_wait);
/// @brief Encode a single 16-bit PCM sample into the current block position
HOT void encode_sample_(const uint8_t *pcm_sample);
/// @brief Send the completed block via the appropriate callback
esp_err_t send_block_(TickType_t ticks_to_wait);
@@ -118,6 +112,15 @@ class SPDIFEncoder {
/// @brief Build the channel status block from current configuration
void build_channel_status_();
/// @brief Get the channel status bit for a specific frame
/// @param frame Frame number (0-191)
/// @return The C bit value for this frame
ESPHOME_ALWAYS_INLINE inline bool get_channel_status_bit_(uint8_t frame) const {
// Channel status is 192 bits transmitted over 192 frames
// Bit N is transmitted in frame N, LSB-first within each byte
return (this->channel_status_[frame >> 3] >> (frame & 7)) & 1;
}
// Member ordering optimized to minimize padding (largest alignment first)
// 4-byte aligned members (pointers and uint32_t)
@@ -130,13 +133,9 @@ class SPDIFEncoder {
uint32_t sample_rate_{48000}; // Sample rate for Channel Status Block encoding
// 1-byte aligned members (grouped together to avoid internal padding)
uint8_t bytes_per_sample_{2}; // Input PCM width: 2/3/4 (16/24/32-bit). 32-bit truncates to 24-bit on the wire.
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool preload_mode_{false}; // Whether to use preload callback vs write callback
// True when spdif_block_buf_ currently holds a complete full-silence block valid for the active
// channel status. A full silence block is deterministic for a given sample rate and word length,
// so when this is set flush_with_silence() can re-send the buffer verbatim instead of re-encoding.
bool block_buf_is_silence_block_{false};
uint8_t frame_in_block_{0}; // 0-191, tracks stereo frame position within block
bool is_left_channel_{true}; // Alternates L/R for stereo samples
bool preload_mode_{false}; // Whether to use preload callback vs write callback
// Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames)
// Placed last since std::array<uint8_t> has 1-byte alignment
+1 -1
View File
@@ -319,7 +319,7 @@ void Inkplate::fill(Color color) {
memset(this->partial_buffer_, fill, this->get_buffer_length_());
}
ESP_LOGV(TAG, "Fill finished (%" PRIu32 "ms)", millis() - start_time);
ESP_LOGV(TAG, "Fill finished (%ums)", millis() - start_time);
}
void Inkplate::display() {
@@ -106,6 +106,7 @@ void RfProxy::setup() {
void RfProxy::dump_config() {
ESP_LOGCONFIG(TAG,
"RF Proxy '%s'\n"
" Backend: remote_transmitter/receiver\n"
" Supports Transmitter: %s\n"
" Supports Receiver: %s",
this->get_name().c_str(), YESNO(this->traits_.get_supports_transmitter()),
@@ -123,9 +124,7 @@ void RfProxy::dump_config() {
}
void RfProxy::control(const radio_frequency::RadioFrequencyCall &call) {
// RF: no IR carrier modulation. Any RF front-end coordination (state turnaround, retuning)
// happens via the radio_frequency entity's on_control trigger and remote_transmitter's
// on_transmit/on_complete triggers — wired up in user YAML.
// RF: no IR carrier modulation
transmit_raw_timings(this->transmitter_, 0, call);
}
+1 -4
View File
@@ -43,10 +43,7 @@ class IrRfProxy : public infrared::Infrared {
#endif // USE_IR_RF
#ifdef USE_RADIO_FREQUENCY
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend.
/// Driver-agnostic: integration with specific RF front-end chips (CC1101, RFM69, etc.) is done
/// in YAML by wiring their actions to `remote_transmitter`'s on_transmit/on_complete triggers and
/// to this entity's on_control trigger (see radio_frequency component docs).
/// RfProxy - Radio Frequency platform implementation using remote_transmitter/receiver as backend
class RfProxy : public radio_frequency::RadioFrequency {
public:
RfProxy() = default;
@@ -35,19 +35,17 @@ def _final_validate(config: ConfigType) -> None:
if CONF_REMOTE_TRANSMITTER_ID not in config:
return
transmitter_id = config[CONF_REMOTE_TRANSMITTER_ID]
full_config = fv.full_config.get()
transmitter_path = full_config.get_path_for_id(config[CONF_REMOTE_TRANSMITTER_ID])[
:-1
]
transmitter_path = full_config.get_path_for_id(transmitter_id)[:-1]
transmitter_config = full_config.get_config_for_path(transmitter_path)
duty_percent = transmitter_config.get(CONF_CARRIER_DUTY_PERCENT)
if duty_percent is not None and duty_percent != 100:
raise cv.Invalid(
f"Transmitter '{config[CONF_REMOTE_TRANSMITTER_ID]}' must have "
f"'{CONF_CARRIER_DUTY_PERCENT}' set to 100% for RF transmission. "
"Dedicated RF hardware handles modulation; applying a carrier duty cycle "
"would corrupt the signal"
f"Transmitter '{transmitter_id}' must have '{CONF_CARRIER_DUTY_PERCENT}' "
"set to 100% for RF transmission. Dedicated RF hardware handles modulation; "
"applying a carrier duty cycle would corrupt the signal"
)
@@ -13,9 +13,7 @@ import subprocess
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
# - LN882H: stock linker has no glob for ".sram.text", so we inject
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
# immediately after KEEP(*(.vectors)), so the vector table stays at
# __copysection_ram0_start (0x20000000) for correct Cortex-M4 VTOR alignment.
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH).
#
# All families also get a post-link summary showing where IRAM_ATTR landed.
@@ -29,11 +27,7 @@ _KEEP_LINE = (
"__esphome_sram_text_end = .; "
+ _MARKER + "\n"
)
# Inject after KEEP(*(.vectors)) so the vector table stays at
# __copysection_ram0_start (0x20000000). Cortex-M4 VTOR requires a 512-byte-
# aligned address; injecting before the vectors would push them to an
# unaligned offset and mis-route every IRQ handler.
_LN_COPY = re.compile(r"(KEEP\(\*\(\.vectors\)\)[^\n]*\n)")
_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)")
def _detect(env):
@@ -62,7 +56,7 @@ KNOWN_VARIANTS = frozenset({
def _inject_keep(host_section):
"""Return a patcher that injects _KEEP_LINE after `host_section` match."""
"""Return a patcher that injects _KEEP_LINE at the top of `host_section`."""
def patch(content):
if _MARKER in content:
return content
+2 -31
View File
@@ -86,22 +86,10 @@ class EffectRef:
component_path: list[str | int] # path_context when the action was validated
@dataclass
class EffectCycleRef:
"""A pending light.effect.next/previous action to validate.
Records that the referenced light needs at least one effect configured.
"""
light_id: ID
component_path: list[str | int]
@dataclass
class LightData:
gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr
effect_refs: list[EffectRef] = field(default_factory=list)
effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list)
def _get_data() -> LightData:
@@ -172,15 +160,13 @@ def _final_validate(config: ConfigType) -> ConfigType:
this never runs but the ID validator will catch the missing light ID separately.
"""
data = _get_data()
if not data.effect_refs and not data.effect_cycle_refs:
if not data.effect_refs:
return config
# Drain the lists so we only validate once even though
# Drain the list so we only validate once even though
# FINAL_VALIDATE_SCHEMA runs for each light platform instance.
refs = data.effect_refs
data.effect_refs = []
cycle_refs = data.effect_cycle_refs
data.effect_cycle_refs = []
fconf = fv.full_config.get()
@@ -202,21 +188,6 @@ def _final_validate(config: ConfigType) -> ConfigType:
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
for ref in cycle_refs:
try:
light_path = fconf.get_path_for_id(ref.light_id)[:-1]
light_config = fconf.get_config_for_path(light_path)
except KeyError:
continue
if not light_config.get(CONF_EFFECTS):
raise cv.FinalExternalInvalid(
f"Light '{ref.light_id}' has no effects configured, but a "
f"'light.effect.next' or 'light.effect.previous' action "
f"references it. Add at least one effect to the light.",
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
)
return config
-41
View File
@@ -104,47 +104,6 @@ template<bool HasTransitionLength, typename... Ts> class DimRelativeAction : pub
transition_length_{};
};
// Cycle through the light's configured effects. `Forward` selects direction
// at compile time so the chosen branch is the only one that gets instantiated
// per action site. `include_none` is runtime so a single set of templates
// covers both the "wrap through None" and "skip None" variants.
template<bool Forward, typename... Ts> class LightEffectCycleAction : public Action<Ts...> {
public:
explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {}
void set_include_none(bool include_none) { this->include_none_ = include_none; }
void play(const Ts &...) override {
size_t count = this->parent_->get_effect_count();
if (count == 0) {
return;
}
uint32_t current = this->parent_->get_current_effect_index();
uint32_t next;
if (this->include_none_) {
uint32_t total = static_cast<uint32_t>(count) + 1;
if constexpr (Forward) {
next = (current + 1) % total;
} else {
next = (current + total - 1) % total;
}
} else {
if constexpr (Forward) {
next = (current % static_cast<uint32_t>(count)) + 1;
} else {
next = (current <= 1) ? static_cast<uint32_t>(count) : current - 1;
}
}
auto call = this->parent_->turn_on();
call.set_effect(next);
call.perform();
}
protected:
LightState *parent_;
bool include_none_{false};
};
template<typename... Ts> class LightIsOnCondition : public Condition<Ts...> {
public:
explicit LightIsOnCondition(LightState *state) : state_(state) {}
+2 -74
View File
@@ -26,8 +26,8 @@ from esphome.const import (
CONF_WARM_WHITE,
CONF_WHITE,
)
from esphome.core import CORE, ID, EsphomeError, Lambda
from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType
from esphome.core import CORE, EsphomeError, Lambda
from esphome.cpp_generator import LambdaExpression
from esphome.types import ConfigType
from .types import (
@@ -39,15 +39,12 @@ from .types import (
DimRelativeAction,
LightCall,
LightControlAction,
LightEffectCycleAction,
LightIsOffCondition,
LightIsOnCondition,
LightState,
ToggleAction,
)
CONF_INCLUDE_NONE = "include_none"
@automation.register_action(
"light.toggle",
@@ -256,75 +253,6 @@ async def light_control_to_code(config, action_id, template_arg, args):
return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda)
def _record_effect_cycle_ref(config: ConfigType) -> ConfigType:
"""Record a cycle-action reference for later validation against the target light."""
from . import EffectCycleRef, _get_data
_get_data().effect_cycle_refs.append(
EffectCycleRef(
light_id=config[CONF_ID],
component_path=path_context.get(),
)
)
return config
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(LightState),
cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean,
}
)
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref)
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id(
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA
)
@automation.register_action(
"light.effect.next",
LightEffectCycleAction,
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
synchronous=True,
)
async def light_effect_next_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
return await _light_effect_cycle_to_code(config, action_id, template_arg, True)
@automation.register_action(
"light.effect.previous",
LightEffectCycleAction,
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
synchronous=True,
)
async def light_effect_previous_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
return await _light_effect_cycle_to_code(config, action_id, template_arg, False)
async def _light_effect_cycle_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
forward: bool,
) -> MockObj:
paren = await cg.get_variable(config[CONF_ID])
cycle_template_arg = cg.TemplateArguments(forward, *template_arg)
var = cg.new_Pvariable(action_id, cycle_template_arg, paren)
cg.add(var.set_include_none(config[CONF_INCLUDE_NONE]))
return var
CONF_RELATIVE_BRIGHTNESS = "relative_brightness"
LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema(
{
-1
View File
@@ -39,7 +39,6 @@ LIMIT_MODES = {
# Actions
ToggleAction = light_ns.class_("ToggleAction", automation.Action)
LightControlAction = light_ns.class_("LightControlAction", automation.Action)
LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action)
DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action)
AddressableSet = light_ns.class_("AddressableSet", automation.Action)
LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition)
+7 -7
View File
@@ -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
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
( # 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
)
""" # noqa
matches = re.findall(cfmt, value[CONF_FORMAT], flags=re.VERBOSE)
-5
View File
@@ -55,7 +55,6 @@ from .automation import layers_to_code, lvgl_update
from .defines import (
CONF_ALIGN_TO_LAMBDA_ID,
LOGGER,
add_lv_use,
get_focused_widgets,
get_lv_images_used,
get_refreshed_widgets,
@@ -72,7 +71,6 @@ from .keypads import KEYPADS_CONFIG, keypads_to_code
from .lv_validation import lv_bool
from .lvcode import LvContext, LvglComponent, lv_event_t_ptr, lvgl_static
from .schemas import (
BASE_PROPS,
DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA,
STYLE_REMAP,
@@ -102,7 +100,6 @@ from .widgets import (
get_screen_active,
set_obj_properties,
)
from .widgets.img import CONF_IMAGE
# Import only what we actually use directly in this file
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
@@ -436,8 +433,6 @@ async def to_code(configs):
# This must be done after all widgets are created
styles_used = df.get_styles_used()
if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used):
add_lv_use(CONF_IMAGE)
for use in df.get_lv_uses():
df.add_define(f"LV_USE_{use.upper()}")
cg.add_define(f"USE_LVGL_{use.upper()}")
+14 -14
View File
@@ -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
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
f # type
( # 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
)
""",
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
(?:hh|h|ll|l|j|z|t|L|w|I|I32|I64)? # size
[cCdiouxXeEfgGaAnpsSZ] # type
( # 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
)
""",
flags=re.VERBOSE,
+1 -1
View File
@@ -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_.contains(key_idx)) {
if (self->key_map_.count(key_idx) != 0) {
self->send_key_(self->key_map_[key_idx]);
return;
}
+4 -6
View File
@@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
lv_style_set_text_font(style, font->get_lv_font());
}
#endif
#ifdef USE_IMAGE
#ifdef USE_LVGL_IMAGE
#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE)
#if LV_USE_IMAGE
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
#endif // LV_USE_IMAGE
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
@@ -93,8 +93,7 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) {
inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) {
::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc());
}
#endif
#endif // USE_LVGL_IMAGE
#ifdef USE_LVGL_ANIMIMG
inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images) {
auto *dsc = static_cast<std::vector<lv_image_dsc_t *> *>(lv_obj_get_user_data(img));
@@ -110,7 +109,6 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size());
}
#endif // USE_LVGL_ANIMIMG
#endif // USE_IMAGE
#ifdef USE_LVGL_METER
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
+10 -32
View File
@@ -1,5 +1,4 @@
from collections.abc import Callable
from typing import Any
from esphome import config_validation as cv
from esphome.automation import Trigger, validate_automation
@@ -535,16 +534,7 @@ def strip_defaults(schema: cv.Schema):
return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()})
# Keyed by (id(widget_type), id(extras)); strong refs in the value keep both
# alive so id() can't be recycled.
_CONTAINER_SCHEMA_CACHE: dict[
tuple[int, int], tuple[Any, Any, Callable[[Any], Any]]
] = {}
def container_schema(
widget_type: WidgetType, extras: Any = None
) -> Callable[[Any], Any]:
def container_schema(widget_type: WidgetType, extras=None):
"""
Create a schema for a container widget of a given type. All obj properties are available, plus
the extras passed in, plus any defined for the specific widget being specified.
@@ -552,31 +542,19 @@ def container_schema(
:param extras: Additional options to be made available, e.g. layout properties for children
:return: The schema for this type of widget.
"""
cache_key = (id(widget_type), id(extras))
cached = _CONTAINER_SCHEMA_CACHE.get(cache_key)
if cached is not None:
cached_widget_type, cached_extras, cached_validator = cached
if cached_widget_type is widget_type and cached_extras is extras:
return cached_validator
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
# Delayed evaluation for recursion
cached_schema: cv.Schema | None = None
schema = schema.extend(widget_type.schema)
def get_schema() -> cv.Schema:
nonlocal cached_schema
if cached_schema is None:
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
cached_schema = schema.extend(widget_type.schema)
return cached_schema
def validator(value: Any) -> Any:
def validator(value):
value = value or {}
return append_layout_schema(get_schema(), value)(value)
return append_layout_schema(schema, value)(value)
_CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator)
return validator
-2
View File
@@ -9,7 +9,6 @@ from .defines import (
CONF_THEME,
LValidator,
add_lv_use,
get_styles_used,
get_theme_widget_map,
literal,
)
@@ -26,7 +25,6 @@ def has_style_props(config) -> bool:
async def style_set(svar, style):
for prop, validator in ALL_STYLES.items():
if (value := style.get(prop)) is not None:
get_styles_used().add(prop)
if isinstance(validator, LValidator):
value = await validator.process(value)
if isinstance(value, list):
+3 -33
View File
@@ -1,6 +1,4 @@
from collections.abc import Callable
import sys
from typing import Any
from esphome import codegen as cg, config_validation as cv
from esphome.automation import register_action
@@ -17,7 +15,6 @@ from esphome.const import (
from esphome.core import ID, EsphomeError, TimePeriod
from esphome.coroutine import FakeAwaitable
from esphome.cpp_generator import MockObj
from esphome.schema_extractors import EnableSchemaExtraction
from esphome.types import Expression
from ..defines import (
@@ -76,34 +73,6 @@ from ..types import (
EVENT_LAMB = "event_lamb__"
def _build_update_schema(widget_type: "WidgetType") -> Schema:
# Local import: ..schemas imports WidgetType from this module.
from ..schemas import base_update_schema
return base_update_schema(widget_type, widget_type.parts).extend(
widget_type.modify_schema
)
def _update_action_schema(
widget_type: "WidgetType",
) -> Schema | Callable[[Any], Any]:
# Eager when extracting so build_language_schema.py sees the mapping;
# lazy otherwise to skip ~200 ms of import-time voluptuous work.
if EnableSchemaExtraction:
return _build_update_schema(widget_type)
cached: Schema | None = None
def validator(value: Any) -> Any:
nonlocal cached
if cached is None:
cached = _build_update_schema(widget_type)
return cached(value)
return validator
class WidgetType:
"""
Describes a type of Widget, e.g. "bar" or "line"
@@ -144,17 +113,18 @@ class WidgetType:
# Local import to avoid circular import
from ..automation import update_to_code
from ..schemas import WIDGET_TYPES
from ..schemas import WIDGET_TYPES, base_update_schema
if not is_mock:
if self.name in WIDGET_TYPES:
raise EsphomeError(f"Duplicate definition of widget type '{self.name}'")
WIDGET_TYPES[self.name] = self
# Register the update action automatically, adding widget-specific properties
register_action(
f"lvgl.{self.name}.update",
ObjUpdateAction,
_update_action_schema(self),
base_update_schema(self, self.parts).extend(self.modify_schema),
synchronous=True,
)(update_to_code)
@@ -23,13 +23,7 @@ static const size_t DATA_TIMEOUT_MS = 50;
static const uint32_t RING_BUFFER_DURATION_MS = 120;
#ifdef CONFIG_IDF_TARGET_ESP32P4
// ESP32-P4 PIE-optimized esp-nn kernels (e.g. depthwise_conv_s8_ch1_pie) require
// significantly more stack than other variants, causing stack protection faults at 3072.
static const uint32_t INFERENCE_TASK_STACK_SIZE = 8192;
#else
static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072;
#endif
static const UBaseType_t INFERENCE_TASK_PRIORITY = 3;
enum EventGroupBits : uint32_t {
+2 -2
View File
@@ -130,8 +130,8 @@ ClimateTraits AirConditioner::traits() {
void AirConditioner::dump_config() {
ESP_LOGCONFIG(Constants::TAG,
"MideaDongle:\n"
" [x] Period: %" PRIu32 "ms\n"
" [x] Response timeout: %" PRIu32 "ms\n"
" [x] Period: %dms\n"
" [x] Response timeout: %dms\n"
" [x] Request attempts: %d",
this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts());
#ifdef USE_REMOTE_TRANSMITTER
+2 -69
View File
@@ -1,11 +1,8 @@
from esphome import automation
import esphome.codegen as cg
from esphome.components import climate, uart
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL
from esphome.core import ID
from esphome.cpp_generator import MockObj
from esphome.types import ConfigType, TemplateArgsType
from esphome.const import CONF_UPDATE_INTERVAL
from esphome.types import ConfigType
DEPENDENCIES = ["uart"]
AUTO_LOAD = ["climate"]
@@ -22,18 +19,6 @@ MitsubishiCN105Climate = mitsubishi_ns.class_(
uart.UARTDevice,
)
SetRemoteTemperatureAction = mitsubishi_ns.class_(
"SetRemoteTemperatureAction",
automation.Action,
cg.Parented.template(MitsubishiCN105Climate),
)
ClearRemoteTemperatureAction = mitsubishi_ns.class_(
"ClearRemoteTemperatureAction",
automation.Action,
cg.Parented.template(MitsubishiCN105Climate),
)
CONFIG_SCHEMA = (
climate.climate_schema(MitsubishiCN105Climate)
.extend(uart.UART_DEVICE_SCHEMA)
@@ -68,55 +53,3 @@ async def to_code(config: ConfigType) -> None:
config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL]
)
)
@automation.register_action(
"climate.mitsubishi_cn105.set_remote_temperature",
SetRemoteTemperatureAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
cv.Required(CONF_TEMPERATURE): cv.templatable(
cv.All(
cv.temperature,
cv.Range(min=8.0, max=39.5),
)
),
}
),
synchronous=True,
)
async def set_remote_temperature_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
temperature = await cg.templatable(config[CONF_TEMPERATURE], args, float)
cg.add(var.set_temperature(temperature))
return var
@automation.register_action(
"climate.mitsubishi_cn105.clear_remote_temperature",
ClearRemoteTemperatureAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(MitsubishiCN105Climate),
}
),
synchronous=True,
)
async def clear_remote_temperature_action_to_code(
config: ConfigType,
action_id: ID,
template_arg: cg.TemplateArguments,
args: TemplateArgsType,
) -> MockObj:
var = cg.new_Pvariable(action_id, template_arg)
await cg.register_parented(var, config[CONF_ID])
return var
@@ -1,4 +1,3 @@
#include <algorithm>
#include <array>
#include <cmath>
#include <numeric>
@@ -8,7 +7,7 @@ namespace esphome::mitsubishi_cn105 {
static const char *const TAG = "mitsubishi_cn105.driver";
static constexpr uint32_t RESPONSE_TIMEOUT_MS = 2000;
static constexpr uint32_t WRITE_TIMEOUT_MS = 2000;
static constexpr uint8_t TARGET_TEMPERATURE_ENC_A_OFFSET = 31;
@@ -30,85 +29,44 @@ static constexpr uint8_t STATUS_MSG_ROOM_TEMP = 0x03;
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_REQUEST = 0x41;
static constexpr uint8_t PACKET_TYPE_WRITE_SETTINGS_RESPONSE = 0x61;
template<auto Unknown, size_t N> struct LookupMap {
using value_type = decltype(Unknown);
static constexpr auto UNKNOWN_VALUE = Unknown;
const std::array<value_type, N> table;
constexpr value_type lookup(uint8_t raw) const { return (raw < N) ? this->table[raw] : UNKNOWN_VALUE; }
constexpr bool reverse_lookup(value_type value, uint8_t &out) const {
static_assert(N <= std::numeric_limits<uint8_t>::max());
if (value == UNKNOWN_VALUE) {
return false;
}
for (uint8_t i = 0; i < static_cast<uint8_t>(N); ++i) {
if (this->table[i] == value) {
out = i;
return true;
}
}
return false;
}
constexpr bool is_valid(value_type value) const {
uint8_t raw;
return reverse_lookup(value, raw);
}
};
template<auto Unknown, class T, std::size_t N> static constexpr auto make_map(const T (&values)[N]) {
return LookupMap<Unknown, N>{std::to_array(values)};
}
static constexpr auto PROTOCOL_MODE_MAP = make_map<MitsubishiCN105::Mode::UNKNOWN>({
MitsubishiCN105::Mode::UNKNOWN, // 0x00
static constexpr std::array<std::optional<MitsubishiCN105::Mode>, 9> PROTOCOL_MODE_MAP = {
std::nullopt, // 0x00
MitsubishiCN105::Mode::HEAT, // 0x01
MitsubishiCN105::Mode::DRY, // 0x02
MitsubishiCN105::Mode::COOL, // 0x03
MitsubishiCN105::Mode::UNKNOWN, // 0x04
MitsubishiCN105::Mode::UNKNOWN, // 0x05
MitsubishiCN105::Mode::UNKNOWN, // 0x06
std::nullopt, // 0x04
std::nullopt, // 0x05
std::nullopt, // 0x06
MitsubishiCN105::Mode::FAN_ONLY, // 0x07
MitsubishiCN105::Mode::AUTO // 0x08
});
};
static constexpr auto PROTOCOL_FAN_MODE_MAP = make_map<MitsubishiCN105::FanMode::UNKNOWN>({
static constexpr std::array<std::optional<MitsubishiCN105::FanMode>, 7> PROTOCOL_FAN_MODE_MAP = {
MitsubishiCN105::FanMode::AUTO, // 0x00
MitsubishiCN105::FanMode::QUIET, // 0x01
MitsubishiCN105::FanMode::SPEED_1, // 0x02
MitsubishiCN105::FanMode::SPEED_2, // 0x03
MitsubishiCN105::FanMode::UNKNOWN, // 0x04
std::nullopt, // 0x04
MitsubishiCN105::FanMode::SPEED_3, // 0x05
MitsubishiCN105::FanMode::SPEED_4 // 0x06
});
};
static constexpr auto PROTOCOL_VANE_MODE_MAP = make_map<MitsubishiCN105::VaneMode::UNKNOWN>({
MitsubishiCN105::VaneMode::AUTO, // 0x00
MitsubishiCN105::VaneMode::POSITION_1, // 0x01
MitsubishiCN105::VaneMode::POSITION_2, // 0x02
MitsubishiCN105::VaneMode::POSITION_3, // 0x03
MitsubishiCN105::VaneMode::POSITION_4, // 0x04
MitsubishiCN105::VaneMode::POSITION_5, // 0x05
MitsubishiCN105::VaneMode::UNKNOWN, // 0x06
MitsubishiCN105::VaneMode::SWING // 0x07
});
template<typename T, size_t N>
static constexpr std::optional<T> lookup(const std::array<std::optional<T>, N> &table, uint8_t value) {
return (value < N) ? table[value] : std::nullopt;
}
static constexpr auto PROTOCOL_WIDE_VANE_MODE_MAP = make_map<MitsubishiCN105::WideVaneMode::UNKNOWN>({
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x00
MitsubishiCN105::WideVaneMode::FAR_LEFT, // 0x01
MitsubishiCN105::WideVaneMode::LEFT, // 0x02
MitsubishiCN105::WideVaneMode::CENTER, // 0x03
MitsubishiCN105::WideVaneMode::RIGHT, // 0x04
MitsubishiCN105::WideVaneMode::FAR_RIGHT, // 0x05
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x06
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x07
MitsubishiCN105::WideVaneMode::LEFT_RIGHT, // 0x08
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x09
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0A
MitsubishiCN105::WideVaneMode::UNKNOWN, // 0x0B
MitsubishiCN105::WideVaneMode::SWING // 0x0C
});
template<typename T, size_t N>
static constexpr bool reverse_lookup(const std::array<std::optional<T>, N> &table, T value, uint8_t &placeholder) {
for (size_t i = 0; i < N; ++i) {
const auto &table_value = table[i];
if (table_value.has_value() && table_value == value) {
placeholder = i;
return true;
}
}
return false;
}
static constexpr uint8_t checksum(const uint8_t *bytes, size_t length) {
return static_cast<uint8_t>(0xFC - std::accumulate(bytes, bytes + length, uint8_t{0}));
@@ -123,7 +81,7 @@ static constexpr auto make_packet(uint8_t type, const std::array<uint8_t, Payloa
return packet;
}
static constexpr float decode_temperature(int temp_a, int temp_b, int delta) {
static float decode_temperature(int temp_a, int temp_b, int delta) {
return temp_b != 0 ? (temp_b - 128) / 2.0f : delta + temp_a;
}
@@ -132,31 +90,23 @@ static constexpr auto CONNECT_PACKET = make_packet(PACKET_TYPE_CONNECT_REQUEST,
void MitsubishiCN105::initialize() { this->set_state_(State::CONNECTING); }
bool MitsubishiCN105::update() {
switch (this->state_) {
case State::WAITING_FOR_SCHEDULED_STATUS_UPDATE:
if (this->pending_updates_.any()) {
this->status_update_wait_credit_ms_ =
std::min(this->update_interval_ms_, get_loop_time_ms() - this->operation_start_ms_);
this->set_state_(State::APPLYING_SETTINGS);
return false;
}
if (this->has_timed_out_(this->update_interval_ms_)) {
this->set_state_(State::UPDATING_STATUS);
return false;
}
break;
if (const auto start = this->status_update_start_ms_) {
if (this->pending_updates_.any()) {
this->cancel_waiting_and_transition_to_(State::APPLYING_SETTINGS);
return false;
}
case State::CONNECTING:
case State::UPDATING_STATUS:
case State::APPLYING_SETTINGS:
if (this->has_timed_out_(RESPONSE_TIMEOUT_MS)) {
this->set_state_(State::READ_TIMEOUT);
return false;
}
break;
if ((get_loop_time_ms() - *start) >= this->update_interval_ms_) {
this->cancel_waiting_and_transition_to_(State::UPDATING_STATUS);
return false;
}
}
default:
break;
if (const auto start = this->write_timeout_start_ms_; start && (get_loop_time_ms() - *start) >= WRITE_TIMEOUT_MS) {
this->write_timeout_start_ms_.reset();
this->frame_parser_.reset();
this->set_state_(State::READ_TIMEOUT);
return false;
}
return this->frame_parser_.read_and_parse(this->device_, [this](uint8_t type, const uint8_t *payload, size_t len) {
@@ -218,6 +168,7 @@ void MitsubishiCN105::did_transition_(State to) {
break;
case State::CONNECTED:
this->write_timeout_start_ms_.reset();
this->current_status_msg_type_ = STATUS_MSG_SETTINGS;
this->set_state_(State::UPDATING_STATUS);
break;
@@ -227,6 +178,7 @@ void MitsubishiCN105::did_transition_(State to) {
break;
case State::STATUS_UPDATED: {
this->write_timeout_start_ms_.reset();
if (this->pending_updates_.any() && this->is_status_initialized()) {
this->set_state_(State::APPLYING_SETTINGS);
} else if (this->current_status_msg_type_ == STATUS_MSG_SETTINGS && this->should_request_room_temperature_()) {
@@ -239,23 +191,22 @@ void MitsubishiCN105::did_transition_(State to) {
}
case State::SCHEDULE_NEXT_STATUS_UPDATE:
this->operation_start_ms_ = get_loop_time_ms() - this->status_update_wait_credit_ms_;
this->status_update_wait_credit_ms_ = 0;
this->status_update_start_ms_ = get_loop_time_ms();
this->current_status_msg_type_ = STATUS_MSG_SETTINGS;
this->set_state_(State::WAITING_FOR_SCHEDULED_STATUS_UPDATE);
break;
case State::APPLYING_SETTINGS:
this->apply_settings_();
this->pending_updates_.clear();
break;
case State::SETTINGS_APPLIED:
this->write_timeout_start_ms_.reset();
this->set_state_(State::SCHEDULE_NEXT_STATUS_UPDATE);
break;
case State::READ_TIMEOUT:
this->frame_parser_.reset();
this->status_update_wait_credit_ms_ = 0;
this->set_state_(State::CONNECTING);
break;
@@ -279,7 +230,7 @@ bool MitsubishiCN105::should_request_room_temperature_() const {
void MitsubishiCN105::send_packet_(const uint8_t *packet, size_t len) {
FrameParser::dump_buffer_vv("TX", packet, len);
this->device_.write_array(packet, len);
this->operation_start_ms_ = get_loop_time_ms();
this->write_timeout_start_ms_ = get_loop_time_ms();
}
void MitsubishiCN105::update_status_() {
@@ -287,6 +238,11 @@ void MitsubishiCN105::update_status_() {
this->send_packet_(make_packet(PACKET_TYPE_STATUS_REQUEST, payload));
}
void MitsubishiCN105::cancel_waiting_and_transition_to_(State state) {
this->status_update_start_ms_.reset();
this->set_state_(state);
}
bool MitsubishiCN105::process_rx_packet_(uint8_t type, const uint8_t *payload, size_t len) {
switch (type) {
case PACKET_TYPE_CONNECT_RESPONSE:
@@ -322,10 +278,9 @@ bool MitsubishiCN105::process_status_packet_(const uint8_t *payload, size_t len)
this->set_state_(State::STATUS_UPDATED);
}
bool changed =
previous.power_on != this->status_.power_on || previous.mode != this->status_.mode ||
previous.fan_mode != this->status_.fan_mode || previous.target_temperature != this->status_.target_temperature ||
previous.vane_mode != this->status_.vane_mode || previous.wide_vane_mode != this->status_.wide_vane_mode;
bool changed = previous.power_on != this->status_.power_on || previous.mode != this->status_.mode ||
previous.fan_mode != this->status_.fan_mode ||
previous.target_temperature != this->status_.target_temperature;
if (this->is_room_temperature_enabled()) {
changed |= previous.room_temperature != this->status_.room_temperature;
@@ -354,31 +309,22 @@ bool MitsubishiCN105::parse_status_settings_(const uint8_t *payload, size_t len)
return false;
}
if (!this->pending_updates_.contains(UpdateFlag::POWER)) {
if (!this->pending_updates_.has(UpdateFlag::POWER)) {
this->status_.power_on = payload[2] != 0;
}
this->use_temperature_encoding_b_ = payload[10] != 0;
if (!this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
if (!this->pending_updates_.has(UpdateFlag::TEMPERATURE)) {
this->status_.target_temperature = decode_temperature(-payload[4], payload[10], TARGET_TEMPERATURE_ENC_A_OFFSET);
}
if (!this->pending_updates_.contains(UpdateFlag::MODE)) {
if (!this->pending_updates_.has(UpdateFlag::MODE)) {
const bool i_see = payload[3] > 0x08;
this->status_.mode = PROTOCOL_MODE_MAP.lookup(payload[3] - (i_see ? 0x08 : 0));
this->status_.mode = lookup(PROTOCOL_MODE_MAP, payload[3] - (i_see ? 0x08 : 0)).value_or(Mode::UNKNOWN);
}
if (!this->pending_updates_.contains(UpdateFlag::FAN)) {
this->status_.fan_mode = PROTOCOL_FAN_MODE_MAP.lookup(payload[5]);
}
if (!this->pending_updates_.contains(UpdateFlag::VANE)) {
this->status_.vane_mode = PROTOCOL_VANE_MODE_MAP.lookup(payload[6]);
}
this->set_wide_vane_high_bit_ = (payload[9] & 0xF0) == 0x80;
if (!this->pending_updates_.contains(UpdateFlag::WIDE_VANE)) {
this->status_.wide_vane_mode = PROTOCOL_WIDE_VANE_MODE_MAP.lookup(payload[9] & 0x0F);
if (!this->pending_updates_.has(UpdateFlag::FAN)) {
this->status_.fan_mode = lookup(PROTOCOL_FAN_MODE_MAP, payload[5]).value_or(FanMode::UNKNOWN);
}
return true;
@@ -396,27 +342,6 @@ bool MitsubishiCN105::parse_status_room_temperature_(const uint8_t *payload, siz
return true;
}
void MitsubishiCN105::set_remote_temperature(float temperature) {
if (std::isnan(temperature)) {
ESP_LOGD(TAG, "Ignoring NaN remote temperature");
return;
}
if (temperature < 8.0f || temperature > 39.5f) {
ESP_LOGD(TAG, "Ignoring out-of-range remote temperature: %.1f", temperature);
return;
}
this->set_remote_temperature_half_deg_(static_cast<uint8_t>(std::round(temperature * 2.0f)));
}
void MitsubishiCN105::clear_remote_temperature() {
this->set_remote_temperature_half_deg_(REMOTE_TEMPERATURE_DISABLED);
}
void MitsubishiCN105::set_remote_temperature_half_deg_(uint8_t temperature_half_deg) {
this->remote_temperature_half_deg_ = temperature_half_deg;
this->pending_updates_.set(UpdateFlag::REMOTE_TEMPERATURE);
}
void MitsubishiCN105::set_power(bool power_on) {
this->status_.power_on = power_on;
this->pending_updates_.set(UpdateFlag::POWER);
@@ -432,7 +357,8 @@ void MitsubishiCN105::set_target_temperature(float target_temperature) {
}
void MitsubishiCN105::set_mode(Mode mode) {
if (!PROTOCOL_MODE_MAP.is_valid(mode)) {
uint8_t placeholder;
if (!reverse_lookup(PROTOCOL_MODE_MAP, mode, placeholder)) {
ESP_LOGD(TAG, "Setting invalid mode: %u", static_cast<uint8_t>(mode));
return;
}
@@ -441,7 +367,8 @@ void MitsubishiCN105::set_mode(Mode mode) {
}
void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
if (!PROTOCOL_FAN_MODE_MAP.is_valid(fan_mode)) {
uint8_t placeholder;
if (!reverse_lookup(PROTOCOL_FAN_MODE_MAP, fan_mode, placeholder)) {
ESP_LOGD(TAG, "Setting invalid fan mode: %u", static_cast<uint8_t>(fan_mode));
return;
}
@@ -449,80 +376,31 @@ void MitsubishiCN105::set_fan_mode(FanMode fan_mode) {
this->pending_updates_.set(UpdateFlag::FAN);
}
void MitsubishiCN105::set_vane_mode(VaneMode vane_mode) {
if (!PROTOCOL_VANE_MODE_MAP.is_valid(vane_mode)) {
ESP_LOGD(TAG, "Setting invalid vane mode: %u", static_cast<uint8_t>(vane_mode));
return;
}
this->status_.vane_mode = vane_mode;
this->pending_updates_.set(UpdateFlag::VANE);
}
void MitsubishiCN105::set_wide_vane_mode(WideVaneMode wide_vane_mode) {
if (!PROTOCOL_WIDE_VANE_MODE_MAP.is_valid(wide_vane_mode)) {
ESP_LOGD(TAG, "Setting invalid wide vane mode: %u", static_cast<uint8_t>(wide_vane_mode));
return;
}
this->status_.wide_vane_mode = wide_vane_mode;
this->pending_updates_.set(UpdateFlag::WIDE_VANE);
}
void MitsubishiCN105::apply_settings_() {
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload{};
std::array<uint8_t, REQUEST_PAYLOAD_LEN> payload = {0x01};
// Apply all other pending settings first; handle REMOTE_TEMPERATURE last
if (this->pending_updates_.contains_only(UpdateFlag::REMOTE_TEMPERATURE)) {
payload[0] = 0x07;
if (this->remote_temperature_half_deg_ == REMOTE_TEMPERATURE_DISABLED) {
payload[3] = 0x80;
if (this->pending_updates_.has(UpdateFlag::POWER)) {
payload[1] |= 0x01;
payload[3] = this->status_.power_on ? 0x01 : 0x00;
}
if (this->pending_updates_.has(UpdateFlag::TEMPERATURE)) {
payload[1] |= 0x04;
if (this->use_temperature_encoding_b_) {
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
} else {
payload[1] = 0x01;
payload[2] = static_cast<uint8_t>(this->remote_temperature_half_deg_ - 16);
payload[3] = static_cast<uint8_t>(this->remote_temperature_half_deg_ + 128);
}
this->pending_updates_.clear(UpdateFlag::REMOTE_TEMPERATURE);
} else {
payload[0] = 0x01;
if (this->pending_updates_.contains(UpdateFlag::POWER)) {
payload[1] |= 0x01;
payload[3] = this->status_.power_on ? 0x01 : 0x00;
payload[5] = static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
}
}
if (this->pending_updates_.contains(UpdateFlag::TEMPERATURE)) {
payload[1] |= 0x04;
if (this->use_temperature_encoding_b_) {
payload[14] = static_cast<uint8_t>(std::round(this->status_.target_temperature * 2.0f) + 128);
} else {
payload[5] =
static_cast<uint8_t>(TARGET_TEMPERATURE_ENC_A_OFFSET - std::round(this->status_.target_temperature));
}
}
if (this->pending_updates_.has(UpdateFlag::MODE) &&
reverse_lookup(PROTOCOL_MODE_MAP, this->status_.mode, payload[4])) {
payload[1] |= 0x02;
}
if (this->pending_updates_.contains(UpdateFlag::MODE) &&
PROTOCOL_MODE_MAP.reverse_lookup(this->status_.mode, payload[4])) {
payload[1] |= 0x02;
}
if (this->pending_updates_.contains(UpdateFlag::FAN) &&
PROTOCOL_FAN_MODE_MAP.reverse_lookup(this->status_.fan_mode, payload[6])) {
payload[1] |= 0x08;
}
if (this->pending_updates_.contains(UpdateFlag::VANE) &&
PROTOCOL_VANE_MODE_MAP.reverse_lookup(this->status_.vane_mode, payload[7])) {
payload[1] |= 0x10;
}
if (this->pending_updates_.contains(UpdateFlag::WIDE_VANE) &&
PROTOCOL_WIDE_VANE_MODE_MAP.reverse_lookup(this->status_.wide_vane_mode, payload[13])) {
payload[2] |= 0x01;
if (this->set_wide_vane_high_bit_) {
payload[13] |= 0x80;
}
}
this->pending_updates_.clear(UpdateFlag::POWER, UpdateFlag::TEMPERATURE, UpdateFlag::MODE, UpdateFlag::FAN,
UpdateFlag::VANE, UpdateFlag::WIDE_VANE);
if (this->pending_updates_.has(UpdateFlag::FAN) &&
reverse_lookup(PROTOCOL_FAN_MODE_MAP, this->status_.fan_mode, payload[6])) {
payload[1] |= 0x08;
}
this->send_packet_(make_packet(PACKET_TYPE_WRITE_SETTINGS_REQUEST, payload));
@@ -2,7 +2,6 @@
#include <optional>
#include "esphome/components/uart/uart.h"
#include "esphome/core/finite_set_mask.h"
namespace esphome::mitsubishi_cn105 {
@@ -29,36 +28,12 @@ class MitsubishiCN105 {
UNKNOWN,
};
enum class VaneMode : uint8_t {
AUTO,
POSITION_1,
POSITION_2,
POSITION_3,
POSITION_4,
POSITION_5,
SWING,
UNKNOWN,
};
enum class WideVaneMode : uint8_t {
FAR_LEFT,
LEFT,
CENTER,
RIGHT,
FAR_RIGHT,
LEFT_RIGHT,
SWING,
UNKNOWN,
};
struct Status {
float target_temperature{NAN};
float room_temperature{NAN};
bool power_on{false};
float target_temperature{NAN};
Mode mode{Mode::UNKNOWN};
FanMode fan_mode{FanMode::UNKNOWN};
VaneMode vane_mode{VaneMode::UNKNOWN};
WideVaneMode wide_vane_mode{WideVaneMode::UNKNOWN};
float room_temperature{NAN};
};
explicit MitsubishiCN105(uart::UARTDevice &device) : device_(device) {}
@@ -85,10 +60,6 @@ class MitsubishiCN105 {
void set_target_temperature(float target_temperature);
void set_mode(Mode mode);
void set_fan_mode(FanMode fan_mode);
void set_vane_mode(VaneMode vane_mode);
void set_wide_vane_mode(WideVaneMode mode);
void set_remote_temperature(float temperature);
void clear_remote_temperature();
protected:
enum class State : uint8_t {
@@ -120,27 +91,20 @@ class MitsubishiCN105 {
};
enum class UpdateFlag : uint8_t {
TEMPERATURE = 0,
POWER = 1,
MODE = 2,
FAN = 3,
VANE = 4,
WIDE_VANE = 5,
REMOTE_TEMPERATURE = 6,
TEMPERATURE = 1 << 0,
POWER = 1 << 1,
MODE = 1 << 2,
FAN = 1 << 3,
};
struct UpdateFlags {
template<typename... Flags> void set(Flags... flags) { (this->mask_.insert(flags), ...); }
template<typename... Flags> void clear(Flags... flags) { (this->mask_.erase(flags), ...); }
bool any() const { return !this->mask_.empty(); }
bool contains(UpdateFlag flag) const { return this->mask_.count(flag); }
bool contains_only(UpdateFlag flag) const { return this->mask_.get_mask() == Mask{flag}.get_mask(); }
void set(UpdateFlag f) { flags_ |= static_cast<uint8_t>(f); }
void clear() { flags_ = 0; }
bool any() const { return flags_ != 0; }
bool has(UpdateFlag f) const { return (flags_ & static_cast<uint8_t>(f)) != 0; }
protected:
using Mask =
FiniteSetMask<UpdateFlag, DefaultBitPolicy<UpdateFlag, static_cast<int>(UpdateFlag::REMOTE_TEMPERATURE) + 1>>;
Mask mask_;
uint8_t flags_{0};
};
void set_state_(State new_state);
@@ -152,30 +116,25 @@ class MitsubishiCN105 {
bool parse_status_room_temperature_(const uint8_t *payload, size_t len);
void send_packet_(const uint8_t *packet, size_t len);
void update_status_();
void cancel_waiting_and_transition_to_(State state);
bool should_request_room_temperature_() const;
void apply_settings_();
bool has_timed_out_(uint32_t timeout) const { return ((get_loop_time_ms() - this->operation_start_ms_) >= timeout); }
void set_remote_temperature_half_deg_(uint8_t temperature_half_deg);
template<typename T> void send_packet_(const T &packet) { this->send_packet_(packet.data(), packet.size()); }
static bool should_transition(State from, State to);
static const LogString *state_to_string(State state);
uart::UARTDevice &device_;
uint32_t update_interval_ms_{1000};
uint32_t status_update_wait_credit_ms_{0};
uint32_t operation_start_ms_{0};
uint32_t room_temperature_min_interval_ms_{60000};
std::optional<uint32_t> write_timeout_start_ms_;
std::optional<uint32_t> status_update_start_ms_;
std::optional<uint32_t> last_room_temperature_update_ms_;
Status status_{};
State state_{State::NOT_CONNECTED};
UpdateFlags pending_updates_;
bool use_temperature_encoding_b_{false};
bool set_wide_vane_high_bit_{false};
FrameParser frame_parser_;
uint8_t current_status_msg_type_{0};
static constexpr uint8_t REMOTE_TEMPERATURE_DISABLED = 0;
uint8_t remote_temperature_half_deg_{REMOTE_TEMPERATURE_DISABLED};
FrameParser frame_parser_;
};
} // namespace esphome::mitsubishi_cn105
@@ -56,7 +56,7 @@ void MitsubishiCN105Climate::dump_config() {
ESP_LOGCONFIG(TAG, " Current temperature min interval: %" PRIu32 " ms",
this->hp_.get_room_temperature_min_interval());
} else {
ESP_LOGCONFIG(TAG, " Current temperature: DISABLED");
ESP_LOGCONFIG(TAG, " Current temperature: disabled");
}
ESP_LOGCONFIG(TAG,
" Update interval: %" PRIu32 " ms\n"
@@ -1,6 +1,5 @@
#pragma once
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/components/climate/climate.h"
#include "esphome/components/uart/uart.h"
@@ -19,11 +18,8 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
climate::ClimateTraits traits() override;
void control(const climate::ClimateCall &call) override;
void set_update_interval(uint32_t ms) { this->hp_.set_update_interval(ms); }
void set_current_temperature_min_interval(uint32_t ms) { this->hp_.set_room_temperature_min_interval(ms); }
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
void set_update_interval(uint32_t ms) { hp_.set_update_interval(ms); }
void set_current_temperature_min_interval(uint32_t ms) { hp_.set_room_temperature_min_interval(ms); }
protected:
void apply_values_();
@@ -31,18 +27,4 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
MitsubishiCN105 hp_;
};
template<typename... Ts>
class SetRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
TEMPLATABLE_VALUE(float, temperature)
void play(const Ts &...x) override { this->parent_->set_remote_temperature(this->temperature_.value(x...)); }
};
template<typename... Ts>
class ClearRemoteTemperatureAction : public Action<Ts...>, public Parented<MitsubishiCN105Climate> {
public:
void play(const Ts &...x) override { this->parent_->clear_remote_temperature(); }
};
} // namespace esphome::mitsubishi_cn105
@@ -182,7 +182,7 @@ void SourceSpeaker::loop() {
break;
}
case speaker::STATE_RUNNING:
if (!this->audio_source_->has_buffered_data() &&
if (!this->transfer_buffer_->has_buffered_data() &&
(this->pending_playback_frames_.load(std::memory_order_acquire) == 0)) {
// No audio data in buffer waiting to get mixed and no frames are pending playback
if ((this->timeout_ms_.has_value() && ((millis() - this->last_seen_data_ms_) > this->timeout_ms_.value())) ||
@@ -254,12 +254,15 @@ void SourceSpeaker::send_command_(uint32_t command_bit, bool wake_loop) {
void SourceSpeaker::start() { this->send_command_(SOURCE_SPEAKER_COMMAND_START, true); }
esp_err_t SourceSpeaker::start_() {
const size_t bytes_per_frame = this->audio_stream_info_.frames_to_bytes(1);
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
// avoids unnecessary single-frame splices.
const size_t ring_buffer_size =
(this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
if (this->audio_source_.use_count() == 0) {
const size_t ring_buffer_size = this->audio_stream_info_.ms_to_bytes(this->buffer_duration_ms_);
if (this->transfer_buffer_.use_count() == 0) {
this->transfer_buffer_ =
audio::AudioSourceTransferBuffer::create(this->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS));
if (this->transfer_buffer_ == nullptr) {
return ESP_ERR_NO_MEM;
}
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
if (!temp_ring_buffer) {
temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
@@ -268,15 +271,9 @@ esp_err_t SourceSpeaker::start_() {
if (!temp_ring_buffer) {
return ESP_ERR_NO_MEM;
} else {
this->transfer_buffer_->set_source(temp_ring_buffer);
}
std::unique_ptr<audio::RingBufferAudioSource> source = audio::RingBufferAudioSource::create(
temp_ring_buffer, this->audio_stream_info_.ms_to_bytes(TRANSFER_BUFFER_DURATION_MS),
static_cast<uint8_t>(bytes_per_frame));
if (source == nullptr) {
return ESP_ERR_NO_MEM;
}
this->audio_source_ = std::move(source);
}
return this->parent_->start(this->audio_stream_info_);
@@ -287,7 +284,7 @@ void SourceSpeaker::stop() { this->send_command_(SOURCE_SPEAKER_COMMAND_STOP); }
void SourceSpeaker::finish() { this->send_command_(SOURCE_SPEAKER_COMMAND_FINISH); }
bool SourceSpeaker::has_buffered_data() const {
return ((this->audio_source_.use_count() > 0) && this->audio_source_->has_buffered_data());
return ((this->transfer_buffer_.use_count() > 0) && this->transfer_buffer_->has_buffered_data());
}
void SourceSpeaker::set_mute_state(bool mute_state) {
@@ -304,18 +301,16 @@ void SourceSpeaker::set_volume(float volume) {
float SourceSpeaker::get_volume() { return this->parent_->get_output_speaker()->get_volume(); }
size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::RingBufferAudioSource> &audio_source,
size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::AudioSourceTransferBuffer> &transfer_buffer,
TickType_t ticks_to_wait) {
if (audio_source->available() > 0) {
// Existing exposure was ducked when fill() promoted it; do not re-duck on partial-consume re-entry.
return 0;
}
// Store current offset, as these samples are already ducked
const size_t current_length = transfer_buffer->available();
size_t bytes_read = audio_source->fill(ticks_to_wait, false);
size_t bytes_read = transfer_buffer->transfer_data_from_source(ticks_to_wait);
uint32_t samples_to_duck = this->audio_stream_info_.bytes_to_samples(bytes_read);
if (samples_to_duck > 0) {
int16_t *current_buffer = reinterpret_cast<int16_t *>(audio_source->mutable_data());
int16_t *current_buffer = reinterpret_cast<int16_t *>(transfer_buffer->get_buffer_start() + current_length);
duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_,
&this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_,
@@ -411,7 +406,7 @@ void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_t
void SourceSpeaker::enter_stopping_state_() {
this->state_ = speaker::STATE_STOPPING;
this->stopping_start_ms_ = millis();
this->audio_source_.reset();
this->transfer_buffer_.reset();
}
void MixerSpeaker::dump_config() {
@@ -617,9 +612,9 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Pre-allocate vectors to avoid heap allocation in the loop (max 8 source speakers per schema)
FixedVector<SourceSpeaker *> speakers_with_data;
FixedVector<std::shared_ptr<audio::RingBufferAudioSource>> audio_sources_with_data;
FixedVector<std::shared_ptr<audio::AudioSourceTransferBuffer>> transfer_buffers_with_data;
speakers_with_data.init(this_mixer->source_speakers_.size());
audio_sources_with_data.init(this_mixer->source_speakers_.size());
transfer_buffers_with_data.init(this_mixer->source_speakers_.size());
while (true) {
uint32_t event_group_bits = xEventGroupGetBits(this_mixer->event_group_);
@@ -634,27 +629,27 @@ void MixerSpeaker::audio_mixer_task(void *params) {
this_mixer->audio_stream_info_.value().bytes_to_frames(output_transfer_buffer->free());
speakers_with_data.clear();
audio_sources_with_data.clear();
transfer_buffers_with_data.clear();
for (auto &speaker : this_mixer->source_speakers_) {
if (speaker->is_running() && !speaker->get_pause_state()) {
// Speaker is running and not paused, so it possibly can provide audio data
std::shared_ptr<audio::RingBufferAudioSource> audio_source = speaker->get_audio_source().lock();
if (audio_source.use_count() == 0) {
// No audio source allocated, so skip processing this speaker
std::shared_ptr<audio::AudioSourceTransferBuffer> transfer_buffer = speaker->get_transfer_buffer().lock();
if (transfer_buffer.use_count() == 0) {
// No transfer buffer allocated, so skip processing this speaker
continue;
}
speaker->process_data_from_source(audio_source, 0); // Exposes and ducks audio from source ring buffers
speaker->process_data_from_source(transfer_buffer, 0); // Transfers and ducks audio from source ring buffers
if (audio_source->available() > 0) {
// Retain shared ownership across the mixing pass so the source isn't released mid-mix
audio_sources_with_data.push_back(audio_source);
if (transfer_buffer->available() > 0) {
// Store the locked transfer buffers in their own vector to avoid releasing ownership until after the loop
transfer_buffers_with_data.push_back(transfer_buffer);
speakers_with_data.push_back(speaker);
}
}
}
if (audio_sources_with_data.empty()) {
if (transfer_buffers_with_data.empty()) {
// No audio available for transferring, block task temporarily
delay(TASK_DELAY_MS);
continue;
@@ -662,7 +657,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
uint32_t frames_to_mix = output_frames_free;
if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) {
if ((transfer_buffers_with_data.size() == 1) || this_mixer->queue_mode_) {
// Only one speaker has audio data, just copy samples over
audio::AudioStreamInfo active_stream_info = speakers_with_data[0]->get_audio_stream_info();
@@ -672,10 +667,10 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Speaker's sample rate matches the output speaker's, copy directly
const uint32_t frames_available_in_buffer =
active_stream_info.bytes_to_frames(audio_sources_with_data[0]->available());
active_stream_info.bytes_to_frames(transfer_buffers_with_data[0]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
copy_frames(reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data()), active_stream_info,
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
copy_frames(reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start()),
active_stream_info, reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
// Set playback delay for newly contributing source
@@ -687,7 +682,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Update source speaker pending frames
speakers_with_data[0]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix));
transfer_buffers_with_data[0]->decrease_buffer_length(active_stream_info.frames_to_bytes(frames_to_mix));
// Update output transfer buffer length and pipeline frame count
output_transfer_buffer->increase_buffer_length(
@@ -714,25 +709,25 @@ void MixerSpeaker::audio_mixer_task(void *params) {
}
} else {
// Determine how many frames to mix
for (size_t i = 0; i < audio_sources_with_data.size(); ++i) {
const uint32_t frames_available_in_buffer =
speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_with_data[i]->available());
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
const uint32_t frames_available_in_buffer = speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(
transfer_buffers_with_data[i]->available());
frames_to_mix = std::min(frames_to_mix, frames_available_in_buffer);
}
const int16_t *primary_buffer = reinterpret_cast<const int16_t *>(audio_sources_with_data[0]->data());
int16_t *primary_buffer = reinterpret_cast<int16_t *>(transfer_buffers_with_data[0]->get_buffer_start());
audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info();
// Mix two streams together
for (size_t i = 1; i < audio_sources_with_data.size(); ++i) {
for (size_t i = 1; i < transfer_buffers_with_data.size(); ++i) {
mix_audio_samples(primary_buffer, primary_stream_info,
reinterpret_cast<const int16_t *>(audio_sources_with_data[i]->data()),
reinterpret_cast<int16_t *>(transfer_buffers_with_data[i]->get_buffer_start()),
speakers_with_data[i]->get_audio_stream_info(),
reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end()),
this_mixer->audio_stream_info_.value(), frames_to_mix);
if (i != audio_sources_with_data.size() - 1) {
if (i != transfer_buffers_with_data.size() - 1) {
// Need to mix more streams together, point primary buffer and stream info to the already mixed output
primary_buffer = reinterpret_cast<const int16_t *>(output_transfer_buffer->get_buffer_end());
primary_buffer = reinterpret_cast<int16_t *>(output_transfer_buffer->get_buffer_end());
primary_stream_info = this_mixer->audio_stream_info_.value();
}
}
@@ -740,8 +735,8 @@ void MixerSpeaker::audio_mixer_task(void *params) {
// Get current pipeline depth for delay calculation (before incrementing)
uint32_t current_pipeline_frames = this_mixer->frames_in_pipeline_.load(std::memory_order_acquire);
// Update source audio source consumption and add new audio durations to the source speaker pending playbacks
for (size_t i = 0; i < audio_sources_with_data.size(); ++i) {
// Update source transfer buffer lengths and add new audio durations to the source speaker pending playbacks
for (size_t i = 0; i < transfer_buffers_with_data.size(); ++i) {
// Set playback delay for newly contributing sources
if (!speakers_with_data[i]->has_contributed_.load(std::memory_order_acquire)) {
speakers_with_data[i]->playback_delay_frames_.store(current_pipeline_frames, std::memory_order_release);
@@ -749,7 +744,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
}
speakers_with_data[i]->pending_playback_frames_.fetch_add(frames_to_mix, std::memory_order_release);
audio_sources_with_data[i]->consume(
transfer_buffers_with_data[i]->decrease_buffer_length(
speakers_with_data[i]->get_audio_stream_info().frames_to_bytes(frames_to_mix));
}
@@ -67,13 +67,11 @@ class SourceSpeaker : public speaker::Speaker, public Component {
void set_pause_state(bool pause_state) override { this->pause_state_ = pause_state; }
bool get_pause_state() const override { return this->pause_state_; }
/// @brief Exposes the next ring buffer chunk (zero-copy) and ducks the freshly exposed bytes in place.
/// If the source still has bytes from a prior partial consume, this is a no-op (those bytes were already
/// ducked on the fill that exposed them).
/// @param audio_source Locked shared_ptr to the audio source (must be valid, not null)
/// @brief Transfers audio from the ring buffer into the transfer buffer. Ducks audio while transferring.
/// @param transfer_buffer Locked shared_ptr to the transfer buffer (must be valid, not null)
/// @param ticks_to_wait FreeRTOS ticks to wait while waiting to read from the ring buffer.
/// @return Number of bytes newly exposed from the ring buffer.
size_t process_data_from_source(std::shared_ptr<audio::RingBufferAudioSource> &audio_source,
/// @return Number of bytes transferred from the ring buffer.
size_t process_data_from_source(std::shared_ptr<audio::AudioSourceTransferBuffer> &transfer_buffer,
TickType_t ticks_to_wait);
/// @brief Sets the ducking level for the source speaker.
@@ -85,7 +83,7 @@ class SourceSpeaker : public speaker::Speaker, public Component {
void set_parent(MixerSpeaker *parent) { this->parent_ = parent; }
void set_timeout(uint32_t ms) { this->timeout_ms_ = ms; }
std::weak_ptr<audio::RingBufferAudioSource> get_audio_source() { return this->audio_source_; }
std::weak_ptr<audio::AudioSourceTransferBuffer> get_transfer_buffer() { return this->transfer_buffer_; }
protected:
friend class MixerSpeaker;
@@ -108,7 +106,7 @@ class SourceSpeaker : public speaker::Speaker, public Component {
MixerSpeaker *parent_;
std::shared_ptr<audio::RingBufferAudioSource> audio_source_;
std::shared_ptr<audio::AudioSourceTransferBuffer> transfer_buffer_;
std::weak_ptr<ring_buffer::RingBuffer> ring_buffer_;
uint32_t buffer_duration_ms_;

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