mirror of
https://github.com/esphome/esphome.git
synced 2026-06-30 20:46:08 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a7b1c411e | |||
| bcac422e9e | |||
| c870ad328e | |||
| 1ba8b838da | |||
| 956c2a9780 | |||
| 5cc7719ae4 | |||
| aab7177f07 | |||
| 7674170600 | |||
| 758189fe56 | |||
| f4607cb521 | |||
| 0adfd08270 | |||
| d96ad02b9f | |||
| a6a0a404ae | |||
| b8336cddf2 | |||
| 255d4c6b65 |
@@ -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
-1
@@ -1 +1 @@
|
||||
593fd53fa09944a59af3f38521e31d87fe10b60326b8d82bb76413c5149b312c
|
||||
96c95feaa60831da5f43e3c6a7c7a3a237e17c5d12995a730dbc3884c8dcd11c
|
||||
|
||||
@@ -1 +1 @@
|
||||
../AGENTS.md
|
||||
../.ai/instructions.md
|
||||
@@ -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 }}
|
||||
|
||||
@@ -252,8 +252,6 @@ jobs:
|
||||
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 }}
|
||||
@@ -263,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
|
||||
@@ -299,8 +296,6 @@ jobs:
|
||||
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
|
||||
@@ -310,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'
|
||||
@@ -423,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:
|
||||
@@ -784,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
|
||||
@@ -830,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
|
||||
@@ -849,7 +804,7 @@ jobs:
|
||||
cache-key: ${{ needs.common.outputs.cache-key }}
|
||||
|
||||
- name: Cache ESPHome
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ~/.esphome-idf
|
||||
key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
|
||||
@@ -908,7 +863,7 @@ jobs:
|
||||
|
||||
- name: Save ESPHome cache
|
||||
if: github.ref == 'refs/heads/dev'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ~/.esphome-idf
|
||||
key: ${{ runner.os }}-esphome-${{ needs.common.outputs.cache-key }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
name: Add Dashboard Deprecation Comment
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
# All API calls (pulls.listFiles + issues.{list,create,update}Comment) are performed with
|
||||
# the App token minted below, so the workflow's GITHUB_TOKEN does not need any scopes.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
dashboard-deprecation-comment:
|
||||
name: Dashboard deprecation comment
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
# pulls.listFiles + issues.{list,create,update}Comment on PRs. For PR resources
|
||||
# the issues.*Comment APIs require the pull-requests scope, not issues.
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Add dashboard deprecation comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
const commentMarker = "<!-- This comment was generated automatically by the dashboard-deprecation-comment workflow. -->";
|
||||
|
||||
const commentBody = `Thanks for opening this PR!
|
||||
|
||||
Heads up: the legacy ESPHome dashboard (\`esphome/dashboard/\` and \`tests/dashboard/\`) is **deprecated** and is being replaced by [ESPHome Device Builder](https://github.com/esphome/device-builder). We are not adding new features to the legacy dashboard and it will eventually be removed from this repository.
|
||||
|
||||
What this means for your PR:
|
||||
|
||||
- **New features / enhancements**: please port the change to [esphome/device-builder](https://github.com/esphome/device-builder) instead. We are unlikely to review or merge new dashboard features here.
|
||||
- **Bug fixes**: small fixes may still be considered, but please check first whether the same issue exists in Device Builder, where the fix will have a longer life.
|
||||
- **Security issues**: please do not file a public PR. Report privately via [GitHub security advisories](https://github.com/esphome/esphome/security/advisories/new) so we can coordinate a fix.
|
||||
|
||||
We appreciate the contribution and apologize for the friction; flagging this early so your time isn't spent on a change that may not land.
|
||||
|
||||
---
|
||||
(Added by the PR bot)
|
||||
|
||||
${commentMarker}`;
|
||||
|
||||
async function getDashboardChanges(github, owner, repo, prNumber) {
|
||||
const changedFiles = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100,
|
||||
}
|
||||
);
|
||||
|
||||
return changedFiles.filter(file =>
|
||||
file.filename.startsWith('esphome/dashboard/') ||
|
||||
file.filename.startsWith('tests/dashboard/')
|
||||
);
|
||||
}
|
||||
|
||||
async function findBotComment(github, owner, repo, prNumber) {
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
issue_number: prNumber,
|
||||
per_page: 100,
|
||||
}
|
||||
);
|
||||
|
||||
return comments.find(comment =>
|
||||
comment.body.includes(commentMarker) && comment.user.type === "Bot"
|
||||
);
|
||||
}
|
||||
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
const dashboardChanges = await getDashboardChanges(github, owner, repo, prNumber);
|
||||
const existingComment = await findBotComment(github, owner, repo, prNumber);
|
||||
|
||||
if (dashboardChanges.length === 0) {
|
||||
// PR doesn't (or no longer) touches the legacy dashboard. If we previously
|
||||
// commented (e.g. files were removed in a later push), leave the comment in
|
||||
// place for history rather than thrash on edit/delete.
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingComment) {
|
||||
if (existingComment.body === commentBody) {
|
||||
return;
|
||||
}
|
||||
await github.rest.issues.updateComment({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
comment_id: existingComment.id,
|
||||
body: commentBody,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody,
|
||||
});
|
||||
}
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.5.2
|
||||
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
|
||||
|
||||
+3
-7
@@ -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
|
||||
|
||||
|
||||
+236
-82
@@ -50,7 +50,6 @@ from esphome.const import (
|
||||
CONF_TOPIC,
|
||||
CONF_USERNAME,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_PLATFORM,
|
||||
@@ -70,6 +69,7 @@ from esphome.util import (
|
||||
PICOTOOL_PACKAGE,
|
||||
FlashImage,
|
||||
detect_rp2040_bootsel,
|
||||
get_ltchiptool_path,
|
||||
get_picotool_path,
|
||||
get_serial_ports,
|
||||
is_picotool_usb_permission_error,
|
||||
@@ -734,22 +734,11 @@ 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)
|
||||
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
platform_run_compile = getattr(module, "run_compile", None)
|
||||
if platform_run_compile is not None and platform_run_compile(args, config):
|
||||
pass
|
||||
elif CORE.using_toolchain_esp_idf:
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
rc = toolchain.run_compile(config, CORE.verbose)
|
||||
@@ -865,9 +854,15 @@ def upload_using_esptool(
|
||||
elif CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain
|
||||
|
||||
flash_images = [
|
||||
FlashImage(path=toolchain.get_factory_firmware_path(), offset="0x0")
|
||||
]
|
||||
# For ESP-IDF the upload is a single factory image at 0x0 (it bundles
|
||||
# bootloader + partitions + app). The prebuilt-dir form ships that
|
||||
# single file at the canonical name so the dashboard can flash it
|
||||
# without re-deriving the ESP-IDF build path.
|
||||
factory_path = (
|
||||
CORE.prebuilt_artifact_path("firmware.factory.bin")
|
||||
or toolchain.get_factory_firmware_path()
|
||||
)
|
||||
flash_images = [FlashImage(path=factory_path, offset="0x0")]
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
@@ -943,9 +938,56 @@ def upload_using_esptool(
|
||||
return run_esptool(115200)
|
||||
|
||||
|
||||
def upload_using_ltchiptool(_config: ConfigType, port: str) -> int:
|
||||
"""Upload a libretiny ``.uf2`` directly via ``ltchiptool flash write``.
|
||||
|
||||
Bypasses PlatformIO so the dashboard's transparent install can flash a
|
||||
libretiny device from just a prebuilt ``.uf2`` and a serial port; no
|
||||
local build tree, ``platformio.ini`` or libretiny package install on the
|
||||
machine running ``esphome upload`` is required (ltchiptool itself is
|
||||
still required, but it ships with the libretiny PlatformIO platform or
|
||||
can be ``pip install``-ed independently).
|
||||
|
||||
Chip family is auto-detected from the UF2 header so we don't need to
|
||||
plumb through ``CORE.data[KEY_LIBRETINY][KEY_FAMILY]``.
|
||||
|
||||
The ``_config`` parameter is unused but kept for signature parity with
|
||||
``upload_using_platformio`` / ``upload_using_picotool`` so the dispatch
|
||||
in ``upload_program`` can stay symmetric.
|
||||
"""
|
||||
firmware = CORE.firmware_bin
|
||||
if not firmware.is_file():
|
||||
_LOGGER.error(
|
||||
"LibreTiny firmware file not found at %s. "
|
||||
"Make sure the project has been compiled first, or that "
|
||||
"--prebuilt-dir points at a directory containing firmware.uf2 "
|
||||
"(or firmware.bin -- libretiny emits the same UF2 content under "
|
||||
"both names).",
|
||||
firmware,
|
||||
)
|
||||
return 1
|
||||
|
||||
ltchiptool = get_ltchiptool_path()
|
||||
if ltchiptool is None:
|
||||
_LOGGER.error(
|
||||
"ltchiptool not found. Install the libretiny PlatformIO platform "
|
||||
"(esphome compile <yaml> with a libretiny board pulls it in) or "
|
||||
"run `pip install ltchiptool` in this environment."
|
||||
)
|
||||
return 1
|
||||
|
||||
_LOGGER.info("Uploading firmware to LibreTiny device via ltchiptool...")
|
||||
cmd = [str(ltchiptool), "flash", "write", "-d", port, str(firmware)]
|
||||
return run_external_process(*cmd)
|
||||
|
||||
|
||||
def upload_using_platformio(config: ConfigType, port: str) -> int:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
# `upload_program` routes around this helper when --prebuilt-dir is set
|
||||
# (libretiny→ltchiptool, RP2040→1200bps-touch+picotool), so PlatformIO
|
||||
# is only invoked when a local build tree is available.
|
||||
|
||||
# RP2040 platform-raspberrypi build recipe expects firmware.bin.signed for
|
||||
# the upload target, but 'nobuild' skips the build phase that creates it.
|
||||
# Create it here so the upload doesn't fail.
|
||||
@@ -984,14 +1026,31 @@ def upload_using_picotool(config: ConfigType) -> int:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
firmware_elf = Path(idedata.firmware_elf_path)
|
||||
|
||||
if not firmware_elf.is_file():
|
||||
_LOGGER.error(
|
||||
"Firmware ELF file not found at %s. "
|
||||
"Make sure the project has been compiled first.",
|
||||
firmware_elf,
|
||||
)
|
||||
# --prebuilt-dir ships canonical artifacts at the root of the directory,
|
||||
# not the full PlatformIO build tree, so the ELF may not be present.
|
||||
# picotool's "load" target accepts .uf2 / .bin / .elf, so fall back to
|
||||
# CORE.firmware_bin (which resolves to the prebuilt firmware.uf2 when set)
|
||||
# when no ELF is available.
|
||||
elf_path = Path(idedata.firmware_elf_path)
|
||||
if elf_path.is_file():
|
||||
firmware_file: Path = elf_path
|
||||
elif CORE.prebuilt_dir is not None and CORE.firmware_bin.is_file():
|
||||
firmware_file = CORE.firmware_bin
|
||||
else:
|
||||
if CORE.prebuilt_dir is not None:
|
||||
_LOGGER.error(
|
||||
"No firmware found for picotool: neither %s (from idedata) "
|
||||
"nor %s (prebuilt) exists.",
|
||||
elf_path,
|
||||
CORE.firmware_bin,
|
||||
)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Firmware ELF file not found at %s. "
|
||||
"Make sure the project has been compiled first.",
|
||||
elf_path,
|
||||
)
|
||||
return 1
|
||||
|
||||
picotool = get_picotool_path(idedata.cc_path)
|
||||
@@ -1009,7 +1068,7 @@ def upload_using_picotool(config: ConfigType) -> int:
|
||||
# so progress bars display in real-time with \r updates.
|
||||
# Capture stderr only so we can detect permission errors.
|
||||
result = subprocess.run(
|
||||
[str(picotool), "load", "-v", "-x", str(firmware_elf)],
|
||||
[str(picotool), "load", "-v", "-x", str(firmware_file)],
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=60,
|
||||
check=False,
|
||||
@@ -1038,6 +1097,61 @@ def upload_using_picotool(config: ConfigType) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _rp2040_serial_reset_to_bootsel(port: str, timeout: float = 10.0) -> bool:
|
||||
"""Reboot an arduino-pico RP2040 from running firmware into BOOTSEL mode.
|
||||
|
||||
arduino-pico's USB CDC handler treats a 1200bps "touch" (open the port at
|
||||
1200 baud, then close it) as a request to reboot into the rp2040
|
||||
bootloader, exposing the device as a picotool-loadable BOOTSEL endpoint.
|
||||
This is the same mechanism the arduino-pico PlatformIO recipe uses for
|
||||
serial uploads, lifted out so --prebuilt-dir uploads don't need to
|
||||
re-invoke PlatformIO.
|
||||
|
||||
Returns True once a BOOTSEL device shows up on the USB bus.
|
||||
"""
|
||||
import serial
|
||||
|
||||
# Look picotool up *before* triggering the reset. If it's missing, the
|
||||
# touch would leave the device stranded in BOOTSEL with nothing able to
|
||||
# flash it; bail out early so the user can recover (the device is still
|
||||
# running the old firmware and re-enumerates as the same serial port).
|
||||
picotool = _find_picotool()
|
||||
if picotool is None:
|
||||
_LOGGER.error(
|
||||
"picotool not found; cannot flash RP2040 after BOOTSEL reset. "
|
||||
"Ensure the RP2040 PlatformIO platform is installed (%s).",
|
||||
PICOTOOL_PACKAGE,
|
||||
)
|
||||
return False
|
||||
|
||||
_LOGGER.info("Rebooting %s into BOOTSEL via 1200bps touch...", port)
|
||||
try:
|
||||
ser = serial.Serial()
|
||||
ser.baudrate = 1200
|
||||
ser.port = port
|
||||
ser.open()
|
||||
# Small wait so the firmware sees the open before we close it; on a
|
||||
# fast host the open+close can otherwise happen inside a single USB
|
||||
# frame and the touch is missed.
|
||||
time.sleep(0.1)
|
||||
ser.close()
|
||||
except (OSError, serial.SerialException) as err:
|
||||
_LOGGER.error("Failed to open %s at 1200 baud for BOOTSEL reset: %s", port, err)
|
||||
return False
|
||||
|
||||
start = time.monotonic()
|
||||
while time.monotonic() - start < timeout:
|
||||
if detect_rp2040_bootsel(picotool).device_count > 0:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
_LOGGER.error(
|
||||
"RP2040 did not enter BOOTSEL within %.0fs after 1200bps touch on %s.",
|
||||
timeout,
|
||||
port,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _wait_for_serial_port(
|
||||
port: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
@@ -1095,10 +1209,74 @@ def check_permissions(port: str):
|
||||
)
|
||||
|
||||
|
||||
def _missing_prebuilt_flash_tool() -> str | None:
|
||||
"""Return the name of the platform-specific flash tool that's expected
|
||||
but not yet on disk, or None if no install step is needed.
|
||||
|
||||
ESP* paths use bundled esptool / smpclient. Libretiny + RP2040 paths
|
||||
use ltchiptool / picotool, which ship inside the PlatformIO platform
|
||||
package and aren't pip-installable on their own.
|
||||
"""
|
||||
if CORE.is_libretiny and get_ltchiptool_path() is None:
|
||||
return "ltchiptool"
|
||||
if CORE.target_platform == PLATFORM_RP2040 and _find_picotool() is None:
|
||||
return "picotool"
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_platform_packages_for_prebuilt_upload(config: ConfigType) -> int:
|
||||
"""Make sure the per-platform flash tool is available before a
|
||||
``--prebuilt-dir`` upload dispatches.
|
||||
|
||||
On a host that has never compiled the target platform locally (the
|
||||
dashboard's transparent-install scenario), the flash tool isn't on
|
||||
disk yet. Mirror what ``esphome compile`` would do up to the
|
||||
platform-install step (codegen + write platformio.ini), then run
|
||||
``pio run -t idedata`` so the platform is downloaded without paying
|
||||
for an actual compile.
|
||||
|
||||
Returns 0 if no install was needed or the install succeeded; non-zero
|
||||
on failure so the upload bails out with a clear status.
|
||||
"""
|
||||
tool_name = _missing_prebuilt_flash_tool()
|
||||
if tool_name is None:
|
||||
return 0
|
||||
|
||||
_LOGGER.info(
|
||||
"%s not found on this host; configuring the PlatformIO %s "
|
||||
"platform so the upload can flash the prebuilt firmware...",
|
||||
tool_name,
|
||||
CORE.target_platform,
|
||||
)
|
||||
rc = write_cpp(config)
|
||||
if rc != 0:
|
||||
return rc
|
||||
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
return toolchain.prepare_platform_for_upload(config, CORE.verbose)
|
||||
|
||||
|
||||
def upload_program(
|
||||
config: ConfigType, args: ArgsProtocol, devices: list[str]
|
||||
) -> tuple[int, str | None]:
|
||||
host = devices[0]
|
||||
|
||||
# --prebuilt-dir routes every per-platform upload helper at a directory of
|
||||
# prebuilt artifacts instead of the local build tree. Validate once here
|
||||
# so failures surface before we dispatch into platform-specific code that
|
||||
# would otherwise produce confusing "file not found" errors deep in the
|
||||
# esptool / picotool / PlatformIO call stacks.
|
||||
prebuilt_dir = getattr(args, "prebuilt_dir", None)
|
||||
if prebuilt_dir is not None:
|
||||
prebuilt_path = Path(prebuilt_dir).expanduser()
|
||||
if not prebuilt_path.is_dir():
|
||||
raise EsphomeError(f"--prebuilt-dir {prebuilt_dir} is not a directory")
|
||||
CORE.prebuilt_dir = prebuilt_path
|
||||
rc = _ensure_platform_packages_for_prebuilt_upload(config)
|
||||
if rc != 0:
|
||||
return rc, None
|
||||
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if getattr(module, "upload_program")(config, args, host):
|
||||
@@ -1137,6 +1315,27 @@ def upload_program(
|
||||
if CORE.target_platform in (PLATFORM_ESP32, PLATFORM_ESP8266):
|
||||
file = getattr(args, "file", None)
|
||||
exit_code = upload_using_esptool(config, host, file, args.upload_speed)
|
||||
elif CORE.is_libretiny and CORE.prebuilt_dir is not None:
|
||||
# Dashboard transparent-install case: flash a prebuilt .uf2 with
|
||||
# ltchiptool directly so the build tree isn't required. Without
|
||||
# --prebuilt-dir, the normal PlatformIO path is still used so this
|
||||
# is purely additive and existing libretiny serial flows are
|
||||
# unchanged.
|
||||
exit_code = upload_using_ltchiptool(config, host)
|
||||
elif CORE.target_platform == PLATFORM_RP2040 and CORE.prebuilt_dir is not None:
|
||||
# RP2040 serial + --prebuilt-dir: 1200bps-touch reboot into BOOTSEL,
|
||||
# then flash with picotool. Avoids the PlatformIO build-tree
|
||||
# requirement that upload_using_platformio imposes, mirroring the
|
||||
# libretiny→ltchiptool path. Without --prebuilt-dir the original
|
||||
# PlatformIO path is preserved for back-compat.
|
||||
if _rp2040_serial_reset_to_bootsel(host):
|
||||
exit_code = upload_using_picotool(config)
|
||||
else:
|
||||
# Touch reset failed: the helper already logged a specific
|
||||
# error (port open failure, picotool missing, or BOOTSEL
|
||||
# never enumerated). Be explicit about the failed exit code
|
||||
# rather than relying on the function-level default of 1.
|
||||
exit_code = 1
|
||||
elif CORE.target_platform == PLATFORM_RP2040 or CORE.is_libretiny:
|
||||
exit_code = upload_using_platformio(config, host)
|
||||
# else: Unknown target platform, exit_code remains 1
|
||||
@@ -1423,15 +1622,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 +2157,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 +2270,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."
|
||||
)
|
||||
@@ -2121,6 +2303,16 @@ def parse_args(argv):
|
||||
"--file",
|
||||
help="Manually specify the binary file to upload.",
|
||||
)
|
||||
parser_upload.add_argument(
|
||||
"--prebuilt-dir",
|
||||
help=(
|
||||
"Advanced: directory of prebuilt artifacts to flash instead of "
|
||||
"re-deriving paths from the local build tree. Intended for the "
|
||||
"ESPHome dashboard's transparent install flow on hosts that "
|
||||
"don't compile firmware themselves. End users should not need "
|
||||
"this flag."
|
||||
),
|
||||
)
|
||||
parser_upload.add_argument(
|
||||
"--ota-platform",
|
||||
choices=[CONF_ESPHOME, CONF_WEB_SERVER],
|
||||
@@ -2199,13 +2391,6 @@ def parse_args(argv):
|
||||
parser_run.add_argument(
|
||||
"--no-logs", help="Disable starting logs.", action="store_true"
|
||||
)
|
||||
|
||||
parser_run.add_argument(
|
||||
"--no-states",
|
||||
action="store_true",
|
||||
help="Do not show entity state changes in log output.",
|
||||
)
|
||||
|
||||
parser_run.add_argument(
|
||||
"--reset",
|
||||
"-r",
|
||||
@@ -2442,41 +2627,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
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1306,9 +1305,6 @@ void APIConnection::on_voice_assistant_announce_request(const VoiceAssistantAnno
|
||||
bool APIConnection::send_voice_assistant_get_configuration_response_(const VoiceAssistantConfigurationRequest &msg) {
|
||||
VoiceAssistantConfigurationResponse resp;
|
||||
if (!this->check_voice_assistant_api_connection_()) {
|
||||
// send_message encodes synchronously, so this stack local outlives the encode
|
||||
const std::vector<std::string> empty_wake_words;
|
||||
resp.active_wake_words = &empty_wake_words;
|
||||
return this->send_message(resp);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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,137 +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_();
|
||||
}
|
||||
}
|
||||
|
||||
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,86 +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 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ from esphome.const import (
|
||||
Toolchain,
|
||||
__version__,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError, HexInt, Library
|
||||
from esphome.core import CORE, HexInt, Library
|
||||
from esphome.core.config import BOARD_MAX_LENGTH
|
||||
from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.espidf.component import generate_idf_component
|
||||
@@ -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,21 +1753,19 @@ async def to_code(config):
|
||||
Path(__file__).parent / "iram_fix.py.script",
|
||||
)
|
||||
else:
|
||||
# Demote IDF's blanket -Werror to warnings so third-party libs
|
||||
# and user lambdas don't need a -Wno-error=<class> per warning.
|
||||
# The sdkconfig knob disables IDF's rewrite to -Werror=all (which
|
||||
# can't be globally undone); -Wno-error then handles the demotion.
|
||||
add_idf_sdkconfig_option("CONFIG_COMPILER_DISABLE_DEFAULT_ERRORS", False)
|
||||
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}")
|
||||
@@ -2515,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())
|
||||
)
|
||||
@@ -2537,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
|
||||
|
||||
|
||||
@@ -2565,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)
|
||||
@@ -2590,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]),
|
||||
)
|
||||
|
||||
@@ -2657,29 +2582,13 @@ def copy_files():
|
||||
|
||||
|
||||
def _decode_pc(config, addr):
|
||||
# _decode_pc runs from the api log processor's asyncio callback, which
|
||||
# only catches EsphomeError. Any other exception escaping here tears down
|
||||
# the protocol and triggers an infinite reconnect/replay loop. Convert
|
||||
# toolchain-resolution errors (e.g. missing build dir / cmake cache) into
|
||||
# EsphomeError so the caller can disable decoding cleanly.
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
from esphome.espidf import toolchain as idf_toolchain
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
try:
|
||||
addr2line_path = idf_toolchain.get_addr2line_path()
|
||||
firmware_elf_path = idf_toolchain.get_elf_path()
|
||||
except RuntimeError as err:
|
||||
raise EsphomeError(f"ESP-IDF toolchain not available: {err}") from err
|
||||
else:
|
||||
from esphome.platformio import toolchain
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
addr2line_path = idedata.addr2line_path
|
||||
firmware_elf_path = idedata.firmware_elf_path
|
||||
if not addr2line_path or not firmware_elf_path:
|
||||
idedata = toolchain.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
_LOGGER.debug("decode_pc no addr2line")
|
||||
return
|
||||
command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr]
|
||||
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||
try:
|
||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 1–100 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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -99,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -11,19 +11,11 @@
|
||||
#include "esphome/core/time_64.h"
|
||||
|
||||
// IRAM_ATTR places a function in executable RAM so it is callable from an
|
||||
// ISR even while flash is busy (XIP stall, OTA, logger flash write). All
|
||||
// LibreTiny families that need it share the same .sram.text input section
|
||||
// name; how that section is routed into RAM differs per family:
|
||||
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
|
||||
// RTL8710B: patch_linker.py.script injects KEEP(*(.sram.text*)) at the
|
||||
// top of .ram_image2.data (which IS in ltchiptool's
|
||||
// sections_ram). The stock linker has KEEP(*(.image2.ram.text*))
|
||||
// in .ram_image2.text but that output section is NOT in
|
||||
// ltchiptool's AmebaZ elf2bin sections_ram list, so code routed
|
||||
// there is dropped from the flashed binary.
|
||||
// LN882H: patch_linker.py.script injects KEEP(*(.sram.text*)) into
|
||||
// .flash_copysection (> RAM0 AT> FLASH), after KEEP(*(.vectors))
|
||||
// so the Cortex-M4 vector table stays 512-byte-aligned for VTOR.
|
||||
// ISR even while flash is busy (XIP stall, OTA, logger flash write).
|
||||
// Each family uses a section its stock linker already routes to RAM:
|
||||
// RTL8710B → .image2.ram.text, RTL8720C → .sram.text. LN882H is the
|
||||
// exception: its stock linker has no matching glob, so patch_linker.py
|
||||
// injects KEEP(*(.sram.text*)) into .flash_copysection at pre-link.
|
||||
//
|
||||
// BK72xx (all variants) are left as a no-op: their SDK wraps flash
|
||||
// operations in GLOBAL_INT_DISABLE() which masks FIQ + IRQ at the CPU for
|
||||
@@ -34,7 +26,13 @@
|
||||
// layer.
|
||||
#if defined(USE_BK72XX)
|
||||
#define IRAM_ATTR
|
||||
#elif defined(USE_LIBRETINY_VARIANT_RTL8710B)
|
||||
// Stock linker consumes *(.image2.ram.text*) into .ram_image2.text (> BD_RAM).
|
||||
#define IRAM_ATTR __attribute__((noinline, section(".image2.ram.text")))
|
||||
#else
|
||||
// RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
|
||||
// LN882H: patch_linker.py.script injects *(.sram.text*) into
|
||||
// .flash_copysection (> RAM0 AT> FLASH).
|
||||
#define IRAM_ATTR __attribute__((noinline, section(".sram.text")))
|
||||
#endif
|
||||
#define PROGMEM
|
||||
|
||||
@@ -6,22 +6,14 @@ import re
|
||||
import subprocess
|
||||
|
||||
# ESPHome marks ISR code IRAM_ATTR, which on LibreTiny maps to a per-family
|
||||
# section routed into RAM-executable memory (see esphome/core/hal.h). The
|
||||
# input section name is always .sram.text; only the output section it lands
|
||||
# in differs per family.
|
||||
# section routed into RAM-executable memory (see esphome/core/hal.h).
|
||||
#
|
||||
# This script is NOT loaded on BK72xx (IRAM_ATTR is a no-op there; the SDK
|
||||
# masks FIQ+IRQ around flash writes). On the remaining families:
|
||||
# - RTL8720C: stock linker consumes *(.sram.text*) into .ram.code_text.
|
||||
# - RTL8710B: stock linker has KEEP(*(.image2.ram.text*)) in .ram_image2.text,
|
||||
# but ltchiptool's AmebaZ elf2bin (soc/ambz/binary.py) does NOT list
|
||||
# .ram_image2.text in sections_ram, so code there is silently dropped from
|
||||
# the flashed image. Inject KEEP(*(.sram.text*)) at the top of
|
||||
# .ram_image2.data (which IS extracted) instead.
|
||||
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
|
||||
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
|
||||
# - LN882H: stock linker has no glob for ".sram.text", so we inject
|
||||
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH)
|
||||
# immediately after KEEP(*(.vectors)), so the vector table stays at
|
||||
# __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.
|
||||
|
||||
@@ -35,25 +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)")
|
||||
# Inject at the top of .ram_image2.data, before __data_start__ so our code
|
||||
# does not fall inside the data range markers. .ram_image2.data is one of the
|
||||
# sections ltchiptool's AmebaZ elf2bin extracts; BD_RAM is rwx so the code is
|
||||
# executable. AmbZ has no C runtime .data copy loop (the bootloader loads
|
||||
# image2 into BD_RAM whole) so the inline code is not clobbered after boot.
|
||||
#
|
||||
# The regex is intentionally strict (no attribute / ALIGN between the section
|
||||
# name and the opening brace, brace on its own line). If a future AmbZ SDK
|
||||
# linker template changes this format, _pre_link raises RuntimeError on the
|
||||
# unpatched .ld file(s), and the RTL8710B CI compile job in
|
||||
# tests/test_build_components fails on the PR, surfacing the mismatch loudly
|
||||
# rather than silently shipping a binary with IRAM_ATTR code dropped from
|
||||
# one or both OTA slots.
|
||||
_AMBZ_DATA = re.compile(r"(\.ram_image2\.data\s*:\s*\n?\s*\{\s*\n)")
|
||||
_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\n)")
|
||||
|
||||
|
||||
def _detect(env):
|
||||
@@ -82,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
|
||||
@@ -91,11 +65,12 @@ def _inject_keep(host_section):
|
||||
|
||||
|
||||
# Variants not listed here intentionally have no .ld patcher:
|
||||
# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_text.
|
||||
# - RTL8710B: hal.h uses section(".image2.ram.text") which the stock linker
|
||||
# already routes into .ram_image2.text (> BD_RAM).
|
||||
# - RTL8720C: stock linker already consumes *(.sram.text*).
|
||||
# - BK72xx (all): SDK masks FIQ+IRQ around flash writes, IRAM_ATTR is no-op.
|
||||
_PATCHERS_BY_VARIANT = {
|
||||
"LN882H": (_inject_keep(_LN_COPY),),
|
||||
"RTL8710B": (_inject_keep(_AMBZ_DATA),),
|
||||
}
|
||||
|
||||
|
||||
@@ -106,14 +81,13 @@ def _patchers_for(variant):
|
||||
def _pre_link(target, source, env):
|
||||
build_dir = env.subst("$BUILD_DIR")
|
||||
ld_files = [f for f in os.listdir(build_dir) if f.endswith(".ld")]
|
||||
patched = []
|
||||
unpatched = []
|
||||
patched = 0
|
||||
for name in ld_files:
|
||||
path = os.path.join(build_dir, name)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
original = fh.read()
|
||||
if _MARKER in original:
|
||||
patched.append(name)
|
||||
patched += 1
|
||||
continue
|
||||
content = original
|
||||
for fn in _patchers:
|
||||
@@ -122,9 +96,7 @@ def _pre_link(target, source, env):
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
print("ESPHome: patched {} for IRAM_ATTR placement".format(name))
|
||||
patched.append(name)
|
||||
else:
|
||||
unpatched.append(name)
|
||||
patched += 1
|
||||
if not patched:
|
||||
raise RuntimeError(
|
||||
"ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the "
|
||||
@@ -132,20 +104,6 @@ def _pre_link(target, source, env):
|
||||
build_dir
|
||||
)
|
||||
)
|
||||
# Every .ld in the build must be patched. RTL8710B generates one .ld per
|
||||
# OTA slot (xip1, xip2); if only one matches, the unpatched slot would
|
||||
# ship with IRAM_ATTR code dropped to zeros and brick the device on the
|
||||
# boot after an OTA into that slot.
|
||||
if unpatched:
|
||||
raise RuntimeError(
|
||||
"ESPHome: {} of {} .ld file(s) in {} were not patched for "
|
||||
"IRAM_ATTR: {}. The regex in patch_linker.py.script "
|
||||
"(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not "
|
||||
"these. Update the regex to cover all linker scripts.".format(
|
||||
len(unpatched), len(ld_files), build_dir,
|
||||
", ".join(unpatched), _variant,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Substrings matched against demangled names as a fallback on RTL8720C,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import pkgutil
|
||||
import re
|
||||
|
||||
from esphome.automation import Trigger, build_automation, validate_automation
|
||||
import esphome.codegen as cg
|
||||
@@ -31,14 +30,12 @@ import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_BUFFER_SIZE,
|
||||
CONF_ESPHOME,
|
||||
CONF_GROUP,
|
||||
CONF_ID,
|
||||
CONF_LAMBDA,
|
||||
CONF_LOG_LEVEL,
|
||||
CONF_ON_IDLE,
|
||||
CONF_PAGES,
|
||||
CONF_PLATFORMIO_OPTIONS,
|
||||
CONF_ROTATION,
|
||||
CONF_TIMEOUT,
|
||||
CONF_TRIGGER_ID,
|
||||
@@ -54,8 +51,6 @@ from . import defines as df, lv_validation as lvalid, widgets
|
||||
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 +67,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 +96,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
|
||||
@@ -155,28 +148,9 @@ def generate_lv_conf_h():
|
||||
all_defines = set(
|
||||
df.LV_DEFINES + tuple(f"LV_USE_{w.upper()}" for w in WIDGET_TYPES)
|
||||
)
|
||||
build_flags = (
|
||||
CORE.config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS).get("build_flags", [])
|
||||
)
|
||||
if not isinstance(build_flags, list):
|
||||
build_flags = [build_flags]
|
||||
# Extract define names from build flags like '-DLV_USE_CHART=1', '-D LV_USE_CHART',
|
||||
# or multiple defines in one string.
|
||||
define_pattern = r'-D\s*([A-Z_][A-Z0-9_]*)(?:=[^\s\'"\]]*)?'
|
||||
defines_from_flags = {
|
||||
m.group(1) for flag in build_flags for m in re.finditer(define_pattern, flag)
|
||||
}
|
||||
|
||||
# Get the defines that are actually used based on the config,
|
||||
# Get the defines that are actually used based on the config
|
||||
lv_defines = df.get_defines()
|
||||
clashes = defines_from_flags & lv_defines.keys()
|
||||
if clashes:
|
||||
LOGGER.warning(
|
||||
"Some defines are set both by ESPHome build flags and by LVGL configuration which may lead to unexpected behavior: %s",
|
||||
sorted(list(clashes)),
|
||||
)
|
||||
unused_defines = all_defines - lv_defines.keys() - defines_from_flags
|
||||
|
||||
unused_defines = all_defines - set(lv_defines)
|
||||
# Create the content of lv_conf.h with the used defines set to their value, and the unused defines disabled
|
||||
definitions = [as_macro(m, v) for m, v in lv_defines.items()] + [
|
||||
as_macro(m, "0") for m in unused_defines
|
||||
@@ -436,8 +410,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()}")
|
||||
|
||||
@@ -20,6 +20,7 @@ from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import Expression, SafeExpType
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
lvgl_ns = cg.esphome_ns.namespace("lvgl")
|
||||
|
||||
DOMAIN = "lvgl"
|
||||
KEY_COLOR_FORMATS = "color_formats"
|
||||
@@ -399,13 +400,6 @@ LV_EVENT_MAP = {
|
||||
|
||||
LV_PRESS_EVENTS = ("PRESS", "PRESSING", "RELEASE")
|
||||
|
||||
VALUE_ON_CHANGE = "on_change"
|
||||
VALUE_ON_UPDATE = "on_update"
|
||||
VALUE_ON_VALUE = "on_value"
|
||||
VALUE_ON_RELEASE = "on_release"
|
||||
|
||||
LV_VALUE_EVENTS = (VALUE_ON_CHANGE, VALUE_ON_UPDATE, VALUE_ON_VALUE, VALUE_ON_RELEASE)
|
||||
|
||||
|
||||
def is_press_event(event: str) -> bool:
|
||||
return event.removeprefix("on_").upper() in LV_PRESS_EVENTS
|
||||
@@ -794,7 +788,6 @@ CONF_SKIP = "skip"
|
||||
CONF_SYMBOL = "symbol"
|
||||
CONF_TAB_ID = "tab_id"
|
||||
CONF_TABS = "tabs"
|
||||
CONF_THEME = "theme"
|
||||
CONF_TICK_STYLE = "tick_style"
|
||||
CONF_TIME_FORMAT = "time_format"
|
||||
CONF_TILE = "tile"
|
||||
@@ -806,7 +799,7 @@ CONF_TOUCHSCREENS = "touchscreens"
|
||||
CONF_TRANSFORM_ROTATION = "transform_rotation"
|
||||
CONF_TRANSFORM_SCALE = "transform_scale"
|
||||
CONF_TRANSPARENCY_KEY = "transparency_key"
|
||||
CONF_TRIGGER = "trigger"
|
||||
CONF_THEME = "theme"
|
||||
CONF_UPDATE_ON_RELEASE = "update_on_release"
|
||||
CONF_UPDATE_WHEN_DISPLAY_IDLE = "update_when_display_idle"
|
||||
CONF_VISIBLE_ROW_COUNT = "visible_row_count"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,8 +20,7 @@ from esphome.cpp_generator import (
|
||||
)
|
||||
from esphome.yaml_util import ESPHomeDataBase
|
||||
|
||||
from .defines import literal
|
||||
from .types import lvgl_ns
|
||||
from .defines import literal, lvgl_ns
|
||||
|
||||
LVGL_COMP = "lv_component" # used as a lambda argument in lvgl_comp()
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import number
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ON_RELEASE, CONF_RESTORE_VALUE
|
||||
from esphome.const import CONF_RESTORE_VALUE
|
||||
from esphome.cpp_generator import MockObj
|
||||
|
||||
from ..defines import (
|
||||
CONF_ANIMATED,
|
||||
CONF_TRIGGER,
|
||||
CONF_UPDATE_ON_RELEASE,
|
||||
CONF_WIDGET,
|
||||
LOGGER,
|
||||
)
|
||||
from ..defines import CONF_ANIMATED, CONF_UPDATE_ON_RELEASE, CONF_WIDGET
|
||||
from ..lv_validation import animated
|
||||
from ..lvcode import (
|
||||
EVENT_ARG,
|
||||
@@ -20,8 +14,7 @@ from ..lvcode import (
|
||||
lv_obj,
|
||||
lvgl_static,
|
||||
)
|
||||
from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA
|
||||
from ..types import LvNumber, lvgl_ns
|
||||
from ..types import LV_EVENT, LvNumber, lvgl_ns
|
||||
from ..widgets import get_widgets, wait_for_widgets
|
||||
|
||||
LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component)
|
||||
@@ -29,22 +22,14 @@ LVGLNumber = lvgl_ns.class_("LVGLNumber", number.Number, cg.Component)
|
||||
CONFIG_SCHEMA = number.number_schema(LVGLNumber).extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(LvNumber),
|
||||
**VALUE_TRIGGER_SCHEMA,
|
||||
cv.Optional(CONF_ANIMATED, default=True): animated,
|
||||
cv.Optional(CONF_UPDATE_ON_RELEASE): cv.boolean,
|
||||
cv.Optional(CONF_UPDATE_ON_RELEASE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_RESTORE_VALUE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
trigger = config[CONF_TRIGGER]
|
||||
if CONF_UPDATE_ON_RELEASE in config:
|
||||
LOGGER.warning(
|
||||
"Option 'update_on_release' is deprecated and will be removed in 2026.11.0 - use 'trigger: on_release' instead"
|
||||
)
|
||||
if config[CONF_UPDATE_ON_RELEASE]:
|
||||
trigger = CONF_ON_RELEASE
|
||||
widget = await get_widgets(config, CONF_WIDGET)
|
||||
widget = widget[0]
|
||||
await wait_for_widgets()
|
||||
@@ -55,13 +40,19 @@ async def to_code(config):
|
||||
"value", MockObj("v") * MockObj(widget.get_scale()), config[CONF_ANIMATED]
|
||||
)
|
||||
lv_obj.send_event(widget.obj, UPDATE_EVENT, cg.nullptr)
|
||||
event_code = (
|
||||
LV_EVENT.VALUE_CHANGED
|
||||
if not config[CONF_UPDATE_ON_RELEASE]
|
||||
else LV_EVENT.RELEASED
|
||||
)
|
||||
var = await number.new_number(
|
||||
config,
|
||||
await control.get_lambda(),
|
||||
await value.get_lambda(),
|
||||
event_code,
|
||||
config[CONF_RESTORE_VALUE],
|
||||
max_value=await widget.type.get_max(widget.config),
|
||||
min_value=await widget.type.get_min(widget.config),
|
||||
max_value=widget.type.get_max(widget.config),
|
||||
min_value=widget.type.get_min(widget.config),
|
||||
step=widget.type.get_step(widget.config),
|
||||
)
|
||||
async with LambdaContext(EVENT_ARG) as event:
|
||||
@@ -69,8 +60,6 @@ async def to_code(config):
|
||||
await cg.register_component(var, config)
|
||||
cg.add(
|
||||
lvgl_static.add_event_cb(
|
||||
widget.obj,
|
||||
await event.get_lambda(),
|
||||
*TRIGGER_EVENT_MAP[trigger],
|
||||
widget.obj, await event.get_lambda(), UPDATE_EVENT, event_code
|
||||
)
|
||||
)
|
||||
|
||||
@@ -10,8 +10,12 @@ namespace esphome::lvgl {
|
||||
|
||||
class LVGLNumber : public number::Number, public Component {
|
||||
public:
|
||||
LVGLNumber(std::function<void(float)> control_lambda, std::function<float()> value_lambda, bool restore)
|
||||
: control_lambda_(std::move(control_lambda)), value_lambda_(std::move(value_lambda)), restore_(restore) {}
|
||||
LVGLNumber(std::function<void(float)> control_lambda, std::function<float()> value_lambda, lv_event_code_t event,
|
||||
bool restore)
|
||||
: control_lambda_(std::move(control_lambda)),
|
||||
value_lambda_(std::move(value_lambda)),
|
||||
event_(event),
|
||||
restore_(restore) {}
|
||||
|
||||
void setup() override {
|
||||
float value = this->value_lambda_();
|
||||
@@ -38,6 +42,7 @@ class LVGLNumber : public number::Number, public Component {
|
||||
}
|
||||
std::function<void(float)> control_lambda_;
|
||||
std::function<float()> value_lambda_;
|
||||
lv_event_code_t event_;
|
||||
bool restore_;
|
||||
ESPPreferenceObject pref_{};
|
||||
};
|
||||
|
||||
@@ -10,7 +10,6 @@ from esphome.const import (
|
||||
CONF_GROUP,
|
||||
CONF_ID,
|
||||
CONF_ON_BOOT,
|
||||
CONF_ON_UPDATE,
|
||||
CONF_ON_VALUE,
|
||||
CONF_STATE,
|
||||
CONF_TEXT,
|
||||
@@ -30,13 +29,7 @@ from .defines import (
|
||||
CONF_SCROLL_SNAP_Y,
|
||||
CONF_SCROLLBAR_MODE,
|
||||
CONF_TIME_FORMAT,
|
||||
CONF_TRIGGER,
|
||||
LV_GRAD_DIR,
|
||||
LV_VALUE_EVENTS,
|
||||
VALUE_ON_CHANGE,
|
||||
VALUE_ON_RELEASE,
|
||||
VALUE_ON_UPDATE,
|
||||
VALUE_ON_VALUE,
|
||||
get_remapped_uses,
|
||||
is_press_event,
|
||||
)
|
||||
@@ -48,9 +41,8 @@ from .layout import (
|
||||
grid_alignments,
|
||||
)
|
||||
from .lv_validation import lv_color, lv_font, lv_gradient, lv_image, opacity
|
||||
from .lvcode import UPDATE_EVENT, LvglComponent, lv_event_t_ptr
|
||||
from .lvcode import LvglComponent, lv_event_t_ptr
|
||||
from .types import (
|
||||
LV_EVENT,
|
||||
LVEncoderListener,
|
||||
LvType,
|
||||
lv_group_t,
|
||||
@@ -363,19 +355,6 @@ SET_STATE_SCHEMA = cv.Schema(
|
||||
FLAG_SCHEMA = cv.Schema({cv.Optional(flag): lvalid.lv_bool for flag in df.OBJ_FLAGS})
|
||||
FLAG_LIST = cv.ensure_list(df.LV_OBJ_FLAG.one_of)
|
||||
|
||||
VALUE_TRIGGER_SCHEMA = {
|
||||
cv.Optional(CONF_TRIGGER, default=CONF_ON_VALUE): cv.one_of(
|
||||
*LV_VALUE_EVENTS, lower=True
|
||||
),
|
||||
}
|
||||
|
||||
TRIGGER_EVENT_MAP = {
|
||||
VALUE_ON_CHANGE: (LV_EVENT.VALUE_CHANGED,),
|
||||
VALUE_ON_UPDATE: (UPDATE_EVENT,),
|
||||
VALUE_ON_VALUE: (LV_EVENT.VALUE_CHANGED, UPDATE_EVENT),
|
||||
VALUE_ON_RELEASE: (LV_EVENT.RELEASED,),
|
||||
}
|
||||
|
||||
|
||||
def part_schema(parts):
|
||||
"""
|
||||
@@ -391,7 +370,7 @@ def part_schema(parts):
|
||||
def automation_schema(typ: LvType):
|
||||
events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS
|
||||
if typ.has_on_value:
|
||||
events = events + (CONF_ON_VALUE, CONF_ON_UPDATE)
|
||||
events = events + (CONF_ON_VALUE,)
|
||||
args = typ.get_arg_type()
|
||||
|
||||
def get_trigger_args(event):
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
from esphome.components.sensor import Sensor, new_sensor, sensor_schema
|
||||
import esphome.config_validation as cv
|
||||
|
||||
from ..defines import CONF_TRIGGER, CONF_WIDGET
|
||||
from ..lvcode import EVENT_ARG, LambdaContext, LvContext, lv_add, lvgl_static
|
||||
from ..schemas import TRIGGER_EVENT_MAP, VALUE_TRIGGER_SCHEMA
|
||||
from ..types import LvNumber
|
||||
from ..defines import CONF_WIDGET
|
||||
from ..lvcode import (
|
||||
EVENT_ARG,
|
||||
UPDATE_EVENT,
|
||||
LambdaContext,
|
||||
LvContext,
|
||||
lv_add,
|
||||
lvgl_static,
|
||||
)
|
||||
from ..types import LV_EVENT, LvNumber
|
||||
from ..widgets import Widget, get_widgets, wait_for_widgets
|
||||
|
||||
CONFIG_SCHEMA = sensor_schema(Sensor).extend(
|
||||
{
|
||||
cv.Required(CONF_WIDGET): cv.use_id(LvNumber),
|
||||
**VALUE_TRIGGER_SCHEMA,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -28,6 +33,7 @@ async def to_code(config):
|
||||
lvgl_static.add_event_cb(
|
||||
widget.obj,
|
||||
await lamb.get_lambda(),
|
||||
*TRIGGER_EVENT_MAP[config[CONF_TRIGGER]],
|
||||
LV_EVENT.VALUE_CHANGED,
|
||||
UPDATE_EVENT,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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,7 +3,6 @@ import esphome.codegen as cg
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_ON_BOOT,
|
||||
CONF_ON_UPDATE,
|
||||
CONF_ON_VALUE,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_X,
|
||||
@@ -93,13 +92,6 @@ async def generate_triggers():
|
||||
UPDATE_EVENT,
|
||||
)
|
||||
|
||||
for conf in config.get(CONF_ON_UPDATE, ()):
|
||||
await add_trigger(
|
||||
conf,
|
||||
w,
|
||||
UPDATE_EVENT,
|
||||
)
|
||||
|
||||
await add_on_boot_triggers(config.get(CONF_ON_BOOT, ()))
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ from esphome.const import CONF_TEXT, CONF_VALUE
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.cpp_types import Component, esphome_ns
|
||||
|
||||
from .defines import lvgl_ns
|
||||
|
||||
|
||||
class LvType(cg.MockObjClass):
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -45,7 +47,6 @@ PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template())
|
||||
DrawEndTrigger = esphome_ns.class_(
|
||||
"Trigger<uint32_t, uint32_t>", automation.Trigger.template(cg.uint32, cg.uint32)
|
||||
)
|
||||
lvgl_ns = cg.esphome_ns.namespace("lvgl")
|
||||
IdleTrigger = lvgl_ns.class_("IdleTrigger", automation.Trigger.template())
|
||||
ObjUpdateAction = lvgl_ns.class_("ObjUpdateAction", automation.Action)
|
||||
LvglCondition = lvgl_ns.class_("LvglCondition", automation.Condition)
|
||||
|
||||
@@ -48,7 +48,6 @@ from ..defines import (
|
||||
join_enums,
|
||||
literal,
|
||||
)
|
||||
from ..lv_validation import lv_int
|
||||
from ..lvcode import (
|
||||
LvConditional,
|
||||
add_line_marks,
|
||||
@@ -208,10 +207,10 @@ class WidgetType:
|
||||
"""
|
||||
return ()
|
||||
|
||||
async def get_max(self, config: dict):
|
||||
def get_max(self, config: dict):
|
||||
return sys.maxsize
|
||||
|
||||
async def get_min(self, config: dict):
|
||||
def get_min(self, config: dict):
|
||||
return -sys.maxsize
|
||||
|
||||
def get_step(self, config: dict):
|
||||
@@ -638,8 +637,8 @@ async def widget_to_code(w_cnfig, w_type: WidgetType | str, parent) -> Widget:
|
||||
|
||||
|
||||
class NumberType(WidgetType):
|
||||
async def get_max(self, config: dict):
|
||||
return await lv_int.process(config.get(CONF_MAX_VALUE, 100))
|
||||
def get_max(self, config: dict):
|
||||
return int(config.get(CONF_MAX_VALUE, 100))
|
||||
|
||||
async def get_min(self, config: dict):
|
||||
return await lv_int.process(config.get(CONF_MIN_VALUE, 0))
|
||||
def get_min(self, config: dict):
|
||||
return int(config.get(CONF_MIN_VALUE, 0))
|
||||
|
||||
@@ -125,10 +125,10 @@ class SpinboxType(WidgetType):
|
||||
def get_uses(self):
|
||||
return CONF_TEXTAREA, CONF_LABEL
|
||||
|
||||
async def get_max(self, config: dict):
|
||||
def get_max(self, config: dict):
|
||||
return config[CONF_RANGE_TO]
|
||||
|
||||
async def get_min(self, config: dict):
|
||||
def get_min(self, config: dict):
|
||||
return config[CONF_RANGE_FROM]
|
||||
|
||||
def get_step(self, config: dict):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -500,9 +500,17 @@ def upload_program(config: ConfigType, args, host: str) -> bool:
|
||||
mcumgr_device = host
|
||||
|
||||
if mcumgr_device:
|
||||
firmware = Path(
|
||||
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
|
||||
).resolve()
|
||||
# `esphome upload --prebuilt-dir <path>` ships the MCUboot-signed
|
||||
# update image at the root of the prebuilt directory; prefer that so
|
||||
# the dashboard's transparent BLE install on a Bluetooth proxy works
|
||||
# without a local Zephyr build tree.
|
||||
prebuilt = CORE.prebuilt_artifact_path("app_update.bin")
|
||||
if prebuilt is not None:
|
||||
firmware = prebuilt.resolve()
|
||||
else:
|
||||
firmware = Path(
|
||||
CORE.relative_pioenvs_path(CORE.name, "zephyr", "app_update.bin")
|
||||
).resolve()
|
||||
asyncio.run(smpmgr_upload(mcumgr_device, firmware))
|
||||
return True # Handled: mcumgr OTA upload
|
||||
|
||||
|
||||
@@ -68,9 +68,6 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type)
|
||||
return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE;
|
||||
} else if (err == ESP_ERR_FLASH_OP_TIMEOUT || err == ESP_ERR_FLASH_OP_FAIL) {
|
||||
return OTA_RESPONSE_ERROR_WRITING_FLASH;
|
||||
} else if (err == ESP_ERR_OTA_PARTITION_CONFLICT) {
|
||||
// This error appears with 1 factory and 1 ota partition
|
||||
return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION;
|
||||
}
|
||||
return OTA_RESPONSE_ERROR_UNKNOWN;
|
||||
}
|
||||
|
||||
@@ -210,7 +210,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() {
|
||||
ESP_LOGE(TAG, "Cannot resolve running app partition at address 0x%" PRIX32, running_app_offset);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
|
||||
}
|
||||
ESP_LOGD(TAG, "Copying running app from 0x%" PRIX32 " to 0x%" PRIX32 " (size: 0x%zX)", running_app_part->address,
|
||||
ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address,
|
||||
plan.copy_dest_part->address, running_app_size);
|
||||
err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size);
|
||||
if (err != ESP_OK) {
|
||||
@@ -261,7 +261,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() {
|
||||
ESP_LOGE(TAG, "Selected app partition not found after partition table update");
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
|
||||
}
|
||||
ESP_LOGD(TAG, "Setting next boot partition to 0x%" PRIX32, new_boot_partition->address);
|
||||
ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address);
|
||||
err = esp_ota_set_boot_partition(new_boot_partition);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err);
|
||||
|
||||
@@ -215,7 +215,7 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
|
||||
If loading fails after cloning, attempts a revert and retry in case
|
||||
a prior cached checkout is stale.
|
||||
"""
|
||||
repo_root, revert = git.clone_or_update(
|
||||
repo_dir, revert = git.clone_or_update(
|
||||
url=config[CONF_URL],
|
||||
ref=config.get(CONF_REF),
|
||||
refresh=config[CONF_REFRESH],
|
||||
@@ -225,10 +225,6 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
|
||||
)
|
||||
files: list[dict[str, Any]] = []
|
||||
|
||||
# ``repo_root`` is the directory containing ``.git`` and must be passed
|
||||
# to git for symlink-stub resolution. ``repo_dir`` may be narrowed to a
|
||||
# subdirectory via the user's CONF_PATH and is used for file lookups.
|
||||
repo_dir = repo_root
|
||||
if base_path := config.get(CONF_PATH):
|
||||
repo_dir = repo_dir / base_path
|
||||
|
||||
@@ -240,37 +236,13 @@ def _process_remote_package(config: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
def _load_package_yaml(yaml_file: Path, filename: str) -> dict:
|
||||
"""Load a YAML file from a remote package, validating min_version."""
|
||||
|
||||
def _load(path: Path) -> dict | str | None:
|
||||
try:
|
||||
return yaml_util.load_yaml(path)
|
||||
except EsphomeError as e:
|
||||
raise cv.Invalid(
|
||||
f"{filename} is not a valid YAML file."
|
||||
f" Please check the file contents.\n{e}"
|
||||
) from e
|
||||
|
||||
new_yaml = _load(yaml_file)
|
||||
if not isinstance(new_yaml, dict):
|
||||
# On Windows, git defaults to core.symlinks=false unless the user
|
||||
# has Developer Mode enabled or is running elevated. Files stored
|
||||
# in the repo as symlinks (tree mode 120000) are then checked out
|
||||
# as plain text files containing the symlink target path, so
|
||||
# parsing them as YAML yields a bare scalar instead of a mapping.
|
||||
# Best-effort: follow the symlink target ourselves and re-load.
|
||||
target = git.resolve_symlink_stub(repo_root, yaml_file)
|
||||
if target is not None:
|
||||
new_yaml = _load(target)
|
||||
if not isinstance(new_yaml, dict):
|
||||
try:
|
||||
new_yaml = yaml_util.load_yaml(yaml_file)
|
||||
except EsphomeError as e:
|
||||
raise cv.Invalid(
|
||||
f"{filename} does not contain a YAML mapping at the top level "
|
||||
f"(got {type(new_yaml).__name__}). "
|
||||
f"If this file is a git symlink in the source repository, it "
|
||||
f"may not have been materialized correctly on your platform "
|
||||
f"(this is a known issue with git on Windows without Developer "
|
||||
f"Mode enabled). Try pointing your package at the real file "
|
||||
f"path instead."
|
||||
)
|
||||
f"{filename} is not a valid YAML file."
|
||||
f" Please check the file contents.\n{e}"
|
||||
) from e
|
||||
esphome_config = new_yaml.get(CONF_ESPHOME) or {}
|
||||
min_version = esphome_config.get(CONF_MIN_VERSION)
|
||||
if min_version is not None and cv.Version.parse(min_version) > cv.Version.parse(
|
||||
|
||||
@@ -150,7 +150,7 @@ void IRAM_ATTR PulseMeterSensor::edge_intr(PulseMeterSensor *sensor) {
|
||||
edge_state.last_sent_edge_us_ = now;
|
||||
state.last_detected_edge_us_ = now;
|
||||
state.last_rising_edge_us_ = now;
|
||||
state.count_ += 1;
|
||||
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
|
||||
}
|
||||
|
||||
// This ISR is bound to rising edges, so the pin is high
|
||||
@@ -173,7 +173,7 @@ void IRAM_ATTR PulseMeterSensor::pulse_intr(PulseMeterSensor *sensor) {
|
||||
} else if (length && !pulse_state.latched_ && sensor->last_pin_val_) { // Long enough high edge
|
||||
pulse_state.latched_ = true;
|
||||
state.last_detected_edge_us_ = pulse_state.last_intr_;
|
||||
state.count_ += 1;
|
||||
state.count_++; // NOLINT(clang-diagnostic-deprecated-volatile)
|
||||
}
|
||||
|
||||
// Due to order of operations this includes
|
||||
|
||||
@@ -8,10 +8,9 @@ breaking changes policy. Use at your own risk.
|
||||
Once the API is considered stable, this warning will be removed.
|
||||
"""
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_ON_CONTROL
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import queue_entity_register, setup_entity
|
||||
from esphome.coroutine import CoroPriority
|
||||
@@ -43,7 +42,6 @@ def radio_frequency_schema(class_: type[cg.MockObjClass]) -> cv.Schema:
|
||||
return entity_schema.extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(class_),
|
||||
cv.Optional(CONF_ON_CONTROL): automation.validate_automation({}),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -61,11 +59,6 @@ async def register_radio_frequency(var: cg.Pvariable, config: ConfigType) -> Non
|
||||
await setup_radio_frequency_core_(var, config)
|
||||
CORE.register_platform_component("radio_frequency", var)
|
||||
|
||||
for conf in config.get(CONF_ON_CONTROL, []):
|
||||
await automation.build_callback_automation(
|
||||
var, "add_on_control_callback", [(RadioFrequencyCall, "x")], conf
|
||||
)
|
||||
|
||||
|
||||
async def new_radio_frequency(config: ConfigType, *args) -> cg.Pvariable:
|
||||
"""Create a new RadioFrequency instance.
|
||||
|
||||
@@ -54,10 +54,6 @@ RadioFrequencyCall &RadioFrequencyCall::set_repeat_count(uint32_t count) {
|
||||
|
||||
void RadioFrequencyCall::perform() {
|
||||
if (this->parent_ != nullptr) {
|
||||
// Fire any on_control hooks (user-wired automations) before handing off to
|
||||
// the platform-specific control() — gives users a chance to react to call
|
||||
// parameters (e.g. retune an external RF front-end based on call.get_frequency()).
|
||||
this->parent_->control_callback_.call(*this);
|
||||
this->parent_->control(*this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,15 +170,6 @@ class RadioFrequency : public Component, public EntityBase, public remote_base::
|
||||
this->receive_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
/// Add a callback to invoke when a transmit call is made on this entity.
|
||||
/// Fires before the platform-specific control() runs, with the call object
|
||||
/// (containing frequency, modulation, repeat count, etc.). Used by the
|
||||
/// `on_control` YAML trigger so users can wire any RF front-end driver
|
||||
/// (CC1101, RFM69, custom) to react to per-call parameters.
|
||||
template<typename F> void add_on_control_callback(F &&callback) {
|
||||
this->control_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
protected:
|
||||
friend class RadioFrequencyCall;
|
||||
|
||||
@@ -191,8 +182,6 @@ class RadioFrequency : public Component, public EntityBase, public remote_base::
|
||||
|
||||
// Callback manager for receive events (lazy: saves memory when no callbacks registered)
|
||||
LazyCallbackManager<void(remote_base::RemoteReceiveData)> receive_callback_;
|
||||
// Callback manager for on_control trigger (lazy: same memory savings)
|
||||
LazyCallbackManager<void(const RadioFrequencyCall &)> control_callback_;
|
||||
};
|
||||
|
||||
} // namespace esphome::radio_frequency
|
||||
|
||||
@@ -78,10 +78,10 @@ void RemoteReceiverComponent::setup() {
|
||||
void RemoteReceiverComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Remote Receiver:\n"
|
||||
" Buffer Size: %" PRIu32 "\n"
|
||||
" Tolerance: %" PRIu32 "%s\n"
|
||||
" Filter out pulses shorter than: %" PRIu32 " us\n"
|
||||
" Signal is done after %" PRIu32 " us of no changes",
|
||||
" Buffer Size: %u\n"
|
||||
" Tolerance: %u%s\n"
|
||||
" Filter out pulses shorter than: %u us\n"
|
||||
" Signal is done after %u us of no changes",
|
||||
this->buffer_size_, this->tolerance_,
|
||||
(this->tolerance_mode_ == remote_base::TOLERANCE_MODE_TIME) ? " us" : "%", this->filter_us_,
|
||||
this->idle_us_);
|
||||
|
||||
@@ -139,7 +139,7 @@ def _parse_platform_version(value):
|
||||
# The default/recommended arduino framework version
|
||||
# - https://github.com/earlephilhower/arduino-pico/releases
|
||||
# - https://api.registry.platformio.org/v3/packages/earlephilhower/tool/framework-arduinopico
|
||||
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 6, 0)
|
||||
RECOMMENDED_ARDUINO_FRAMEWORK_VERSION = cv.Version(5, 5, 1)
|
||||
|
||||
# The raspberrypi platform version to use for arduino frameworks
|
||||
# - https://github.com/maxgerhardt/platform-raspberrypi/tags
|
||||
@@ -149,8 +149,8 @@ RECOMMENDED_ARDUINO_PLATFORM_VERSION = "v1.4.0-gcc14-arduinopico460"
|
||||
def _arduino_check_versions(value):
|
||||
value = value.copy()
|
||||
lookups = {
|
||||
"dev": (cv.Version(5, 6, 0), "https://github.com/earlephilhower/arduino-pico"),
|
||||
"latest": (cv.Version(5, 6, 0), None),
|
||||
"dev": (cv.Version(5, 5, 1), "https://github.com/earlephilhower/arduino-pico"),
|
||||
"latest": (cv.Version(5, 5, 1), None),
|
||||
"recommended": (RECOMMENDED_ARDUINO_FRAMEWORK_VERSION, None),
|
||||
}
|
||||
|
||||
|
||||
@@ -457,19 +457,6 @@ RP2040_BOARD_PINS = {
|
||||
"SS": 17,
|
||||
"TX": 12,
|
||||
},
|
||||
"challenger_2350_nbiot": {
|
||||
"LED": 15,
|
||||
"MISO": 16,
|
||||
"MOSI": 19,
|
||||
"RX": 13,
|
||||
"SCK": 18,
|
||||
"SCL": 21,
|
||||
"SCL1": 31,
|
||||
"SDA": 20,
|
||||
"SDA1": 31,
|
||||
"SS": 17,
|
||||
"TX": 12,
|
||||
},
|
||||
"challenger_2350_wifi6_ble5": {
|
||||
"LED": 7,
|
||||
"MISO": 16,
|
||||
@@ -1724,11 +1711,6 @@ BOARDS = {
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
},
|
||||
"challenger_2350_nbiot": {
|
||||
"name": "iLabs Challenger 2350 NB-IoT",
|
||||
"mcu": "rp2350",
|
||||
"max_pin": 47,
|
||||
},
|
||||
"challenger_2350_wifi6_ble5": {
|
||||
"name": "iLabs Challenger 2350 WiFi/BLE",
|
||||
"mcu": "rp2350",
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
#elif defined(USE_ESP32)
|
||||
#include <esp_ota_ops.h>
|
||||
#include <esp_system.h>
|
||||
#include <esp_image_format.h>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -23,37 +22,6 @@ namespace esphome::safe_mode {
|
||||
|
||||
static const char *const TAG = "safe_mode";
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS)
|
||||
// Find a non-running app partition. If verify is true, only returns a partition
|
||||
// whose image passes verification (expensive: reads flash). Returns nullptr if none found.
|
||||
static const esp_partition_t *find_alternate_app_partition(bool verify) {
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
const esp_partition_t *result = nullptr;
|
||||
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr);
|
||||
while (it != nullptr) {
|
||||
const esp_partition_t *p = esp_partition_get(it);
|
||||
if (p->address != running->address) {
|
||||
if (!verify) {
|
||||
result = p;
|
||||
break;
|
||||
}
|
||||
esp_image_metadata_t data = {};
|
||||
const esp_partition_pos_t part_pos = {
|
||||
.offset = p->address,
|
||||
.size = p->size,
|
||||
};
|
||||
if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &part_pos, &data) == ESP_OK) {
|
||||
result = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
it = esp_partition_next(it);
|
||||
}
|
||||
esp_partition_iterator_release(it);
|
||||
return result;
|
||||
}
|
||||
#endif
|
||||
|
||||
void SafeModeComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Safe Mode:\n"
|
||||
@@ -66,11 +34,7 @@ void SafeModeComponent::dump_config() {
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
|
||||
const char *state_str;
|
||||
if (this->ota_state_ == ESP_OTA_IMG_NEW) {
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
state_str = "support unknown";
|
||||
#else
|
||||
state_str = "not supported";
|
||||
#endif
|
||||
} else if (this->ota_state_ == ESP_OTA_IMG_PENDING_VERIFY) {
|
||||
state_str = "supported";
|
||||
} else {
|
||||
@@ -100,18 +64,6 @@ void SafeModeComponent::dump_config() {
|
||||
" See https://esphome.io/guides/faq.html#brownout-detector-was-triggered");
|
||||
}
|
||||
}
|
||||
if (!this->app_ota_possible_) {
|
||||
ESP_LOGW(TAG, "OTA updates are impossible.");
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
ESP_LOGW(TAG, " OTA partition table update or serial flashing is required.");
|
||||
#else
|
||||
if (find_alternate_app_partition(false) != nullptr) {
|
||||
ESP_LOGW(TAG, " Activate safe mode to reboot to the recovery partition.");
|
||||
} else {
|
||||
ESP_LOGE(TAG, " No recovery partition available; serial flashing is required.");
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -172,10 +124,8 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
|
||||
// Check partition state to detect if bootloader supports rollback
|
||||
const esp_partition_t *running_part = esp_ota_get_running_partition();
|
||||
esp_ota_get_state_partition(running_part, &this->ota_state_);
|
||||
const esp_partition_t *next_part = esp_ota_get_next_update_partition(nullptr);
|
||||
this->app_ota_possible_ = (next_part != nullptr && next_part != running_part);
|
||||
const esp_partition_t *running = esp_ota_get_running_partition();
|
||||
esp_ota_get_state_partition(running, &this->ota_state_);
|
||||
#endif
|
||||
|
||||
uint32_t rtc_val = this->read_rtc_();
|
||||
@@ -201,28 +151,6 @@ bool SafeModeComponent::should_enter_safe_mode(uint8_t num_attempts, uint32_t en
|
||||
ESP_LOGE(TAG, "Boot loop detected");
|
||||
}
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK) && !defined(USE_OTA_PARTITIONS)
|
||||
// Allow recovery of soft-bricked devices
|
||||
// Instead of starting safe_mode, reboot to the other app partition if all conditions are met:
|
||||
// - app OTA is impossible (for example because the other app partition has type 'factory')
|
||||
// - the other app partition contains a valid app (for example Tasmota safeboot image or ESPHome)
|
||||
// - allow_partition_access is not configured making recovery via partition table update impossible
|
||||
// Image verification is deferred until here so the cost is only paid when entering safe mode,
|
||||
// not on every boot.
|
||||
if (!this->app_ota_possible_) {
|
||||
const esp_partition_t *rollback_part = find_alternate_app_partition(true);
|
||||
if (rollback_part != nullptr) {
|
||||
esp_err_t err = esp_ota_set_boot_partition(rollback_part);
|
||||
if (err == ESP_OK) {
|
||||
ESP_LOGW(TAG, "OTA updates are impossible. Rebooting to recovery app.");
|
||||
App.reboot();
|
||||
} else {
|
||||
ESP_LOGE(TAG, "Failed to set recovery boot partition: %s", esp_err_to_name(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
this->status_set_error();
|
||||
this->set_timeout(enable_time, []() {
|
||||
ESP_LOGW(TAG, "Timeout, restarting");
|
||||
|
||||
@@ -48,14 +48,11 @@ class SafeModeComponent final : public Component {
|
||||
uint32_t safe_mode_enable_time_{60000}; ///< The time safe mode should remain active for
|
||||
uint32_t safe_mode_rtc_value_{0};
|
||||
uint32_t safe_mode_start_time_{0}; ///< stores when safe mode was enabled
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
|
||||
esp_ota_img_states_t ota_state_{ESP_OTA_IMG_UNDEFINED}; // 4-byte enum
|
||||
#endif
|
||||
// Group 1-byte members together to minimize padding
|
||||
bool boot_successful_{false}; ///< set to true after boot is considered successful
|
||||
uint8_t safe_mode_num_attempts_{0};
|
||||
#if defined(USE_ESP32) && defined(USE_OTA_ROLLBACK)
|
||||
bool app_ota_possible_{true};
|
||||
esp_ota_img_states_t ota_state_{ESP_OTA_IMG_UNDEFINED};
|
||||
#endif
|
||||
// Larger objects at the end
|
||||
ESPPreferenceObject rtc_;
|
||||
|
||||
@@ -169,8 +169,6 @@ async def script_execute_action_to_code(config, action_id, template_arg, args):
|
||||
return value
|
||||
if type == "bool":
|
||||
return cg.RawExpression(str(value).lower())
|
||||
if isinstance(value, (list, tuple)):
|
||||
return cg.ArrayInitializer(*value)
|
||||
return cg.RawExpression(str(value))
|
||||
|
||||
return converter
|
||||
|
||||
@@ -25,6 +25,7 @@ from esphome.const import (
|
||||
CONF_TEMPERATURE_COMPENSATION,
|
||||
CONF_TIME_CONSTANT,
|
||||
CONF_VOC,
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_PM1,
|
||||
DEVICE_CLASS_PM10,
|
||||
@@ -76,6 +77,7 @@ def _gas_sensor(
|
||||
return sensor.sensor_schema(
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
).extend(
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ from esphome.const import (
|
||||
CONF_TEMPERATURE,
|
||||
CONF_TYPE,
|
||||
CONF_VOC,
|
||||
DEVICE_CLASS_AQI,
|
||||
DEVICE_CLASS_CARBON_DIOXIDE,
|
||||
DEVICE_CLASS_HUMIDITY,
|
||||
DEVICE_CLASS_PM1,
|
||||
@@ -92,11 +93,13 @@ CONFIG_SCHEMA = (
|
||||
cv.Optional(CONF_VOC): sensor.sensor_schema(
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_NOX): sensor.sensor_schema(
|
||||
icon=ICON_RADIATOR,
|
||||
accuracy_decimals=0,
|
||||
device_class=DEVICE_CLASS_AQI,
|
||||
state_class=STATE_CLASS_MEASUREMENT,
|
||||
),
|
||||
cv.Optional(CONF_CO2): sensor.sensor_schema(
|
||||
|
||||
@@ -206,15 +206,12 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
# sendspin-cpp library
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.6.1")
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.4.0")
|
||||
|
||||
cg.add_define("USE_SENDSPIN", True) # for MDNS
|
||||
|
||||
data = _get_data()
|
||||
|
||||
# The color role is not yet wired up in ESPHome; disable it in the library for now.
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_COLOR", False)
|
||||
|
||||
# Configure Sendspin roles based on requested features (ESPHome internally via USE_SENDSPIN_*)
|
||||
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
|
||||
if data.artwork_support:
|
||||
@@ -267,7 +264,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
# Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not
|
||||
# starved by the HTTP server during the initial encoded-audio burst at stream start),
|
||||
# decode buffer location PREFER_EXTERNAL.
|
||||
# interpolation/decode buffer locations PREFER_EXTERNAL.
|
||||
player_struct_fields = [
|
||||
("audio_formats", audio_format_structs),
|
||||
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
|
||||
|
||||
@@ -188,6 +188,14 @@ void SendspinMediaSource::on_stream_end() {
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener lifecycle callback)
|
||||
void SendspinMediaSource::on_stream_clear() {
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
// Only set to IDLE if we were previously in a non-IDLE state, to avoid duplicate state changes
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (PlayerRoleListener callback)
|
||||
void SendspinMediaSource::on_volume_changed(uint8_t volume) { this->request_volume_(volume / 100.0f); }
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user