mirror of
https://github.com/esphome/esphome.git
synced 2026-09-11 15:27:33 +00:00
Merge branch 'dev' into store-yaml-firmware
This commit is contained in:
@@ -47,7 +47,7 @@ runs:
|
||||
|
||||
- name: Build and push to ghcr by digest
|
||||
id: build-ghcr
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
@@ -73,7 +73,7 @@ runs:
|
||||
|
||||
- name: Build and push to dockerhub by digest
|
||||
id: build-dockerhub
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
env:
|
||||
DOCKER_BUILD_SUMMARY: false
|
||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||
|
||||
@@ -35,6 +35,10 @@ runs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true' && runner.os != 'Windows'
|
||||
shell: bash
|
||||
|
||||
@@ -5,6 +5,7 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: daily
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
# Hypotehsis is only used for testing and is updated quite often
|
||||
- dependency-name: hypothesis
|
||||
|
||||
@@ -32,6 +32,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
|
||||
- name: Install apt dependencies
|
||||
run: |
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Set TAG
|
||||
run: |
|
||||
|
||||
+21
-10
@@ -6,14 +6,6 @@ on:
|
||||
branches: [dev, beta, release]
|
||||
|
||||
pull_request:
|
||||
paths:
|
||||
- "**"
|
||||
- "!.github/workflows/*.yml"
|
||||
- "!.github/actions/build-image/*"
|
||||
- ".github/workflows/ci.yml"
|
||||
- "!.yamllint"
|
||||
- "!.github/dependabot.yml"
|
||||
- "!docker/**"
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
@@ -60,6 +52,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -97,6 +93,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -175,6 +173,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Install device-builder + esphome from PR
|
||||
# Install device-builder with its esphome + test extras
|
||||
# first so its pinned versions of pytest/etc. land, then
|
||||
@@ -215,6 +217,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -237,7 +241,7 @@ jobs:
|
||||
. venv/bin/activate
|
||||
pytest -vv --cov-report=xml --tb=native --durations=30 -n auto tests --ignore=tests/integration/
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
- name: Save Python virtual environment cache
|
||||
@@ -253,6 +257,7 @@ jobs:
|
||||
needs:
|
||||
- common
|
||||
outputs:
|
||||
core-ci: ${{ steps.determine.outputs.core-ci }}
|
||||
integration-tests: ${{ steps.determine.outputs.integration-tests }}
|
||||
integration-test-buckets: ${{ steps.determine.outputs.integration-test-buckets }}
|
||||
clang-tidy: ${{ steps.determine.outputs.clang-tidy }}
|
||||
@@ -306,6 +311,7 @@ jobs:
|
||||
echo "$output" | jq
|
||||
|
||||
# Extract individual fields
|
||||
echo "core-ci=$(echo "$output" | jq -r '.core_ci')" >> $GITHUB_OUTPUT
|
||||
echo "integration-tests=$(echo "$output" | jq -r '.integration_tests')" >> $GITHUB_OUTPUT
|
||||
echo "integration-test-buckets=$(echo "$output" | jq -c '.integration_test_buckets')" >> $GITHUB_OUTPUT
|
||||
echo "clang-tidy=$(echo "$output" | jq -r '.clang_tidy')" >> $GITHUB_OUTPUT
|
||||
@@ -365,6 +371,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
- name: Create Python virtual environment
|
||||
if: steps.cache-venv.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
@@ -957,7 +967,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- common
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release')
|
||||
- determine-jobs
|
||||
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
|
||||
steps:
|
||||
- name: Check out code from GitHub
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -99,15 +99,15 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -178,17 +178,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to docker hub
|
||||
if: matrix.registry == 'dockerhub'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: matrix.registry == 'ghcr'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -212,74 +212,6 @@ jobs:
|
||||
docker buildx imagetools create $(jq -Rcnr 'inputs | . / "," | map("-t " + .) | join(" ")' <<< "${{ steps.tags.outputs.tags}}") \
|
||||
$(printf '${{ steps.tags.outputs.image }}@sha256:%s ' *)
|
||||
|
||||
deploy-ha-addon-repo:
|
||||
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- init
|
||||
- deploy-manifest
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: home-assistant-addon
|
||||
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
let description = "ESPHome";
|
||||
if (context.eventName == "release") {
|
||||
description = ${{ toJSON(github.event.release.body) }};
|
||||
}
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: "esphome",
|
||||
repo: "home-assistant-addon",
|
||||
workflow_id: "bump-version.yml",
|
||||
ref: "main",
|
||||
inputs: {
|
||||
version: "${{ needs.init.outputs.tag }}",
|
||||
content: description
|
||||
}
|
||||
})
|
||||
|
||||
deploy-esphome-schema:
|
||||
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [init]
|
||||
environment: ${{ needs.init.outputs.deploy_env }}
|
||||
steps:
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: esphome-schema
|
||||
permission-actions: write # actions.createWorkflowDispatch on the target repo (only API call made with this token)
|
||||
|
||||
- name: Trigger Workflow
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
owner: "esphome",
|
||||
repo: "esphome-schema",
|
||||
workflow_id: "generate-schemas.yml",
|
||||
ref: "main",
|
||||
inputs: {
|
||||
version: "${{ needs.init.outputs.tag }}",
|
||||
}
|
||||
})
|
||||
|
||||
version-notifier:
|
||||
if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -302,7 +234,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
script: |
|
||||
github.rest.actions.createWorkflowDispatch({
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
owner: "esphome",
|
||||
repo: "version-notifier",
|
||||
workflow_id: "notify.yml",
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Stale
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch
|
||||
remove-stale-when-updated: true
|
||||
|
||||
@@ -50,6 +50,10 @@ jobs:
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
# Pin uv version so the action does not have to fetch the
|
||||
# manifest from raw.githubusercontent.com on every cache
|
||||
# miss; that fetch flakes on Windows runners.
|
||||
version: "0.11.15"
|
||||
|
||||
- name: Install Home Assistant
|
||||
run: |
|
||||
|
||||
@@ -11,7 +11,7 @@ ci:
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
# Ruff version.
|
||||
rev: v0.15.12
|
||||
rev: v0.15.14
|
||||
hooks:
|
||||
# Run the linter.
|
||||
- id: ruff
|
||||
|
||||
@@ -417,6 +417,7 @@ esphome/components/restart/* @esphome/core
|
||||
esphome/components/rf_bridge/* @jesserockz
|
||||
esphome/components/rgbct/* @jesserockz
|
||||
esphome/components/ring_buffer/* @kahrendt
|
||||
esphome/components/router/speaker/* @kahrendt
|
||||
esphome/components/rp2040/* @jesserockz
|
||||
esphome/components/rp2040_ble/* @bdraco
|
||||
esphome/components/rp2040_pio_led_strip/* @Papa-DMan
|
||||
|
||||
+27
-6
@@ -50,6 +50,7 @@ from esphome.const import (
|
||||
CONF_TOPIC,
|
||||
CONF_USERNAME,
|
||||
CONF_WEB_SERVER,
|
||||
CONF_WIFI,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_CORE,
|
||||
KEY_TARGET_PLATFORM,
|
||||
@@ -607,7 +608,7 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
process_stacktrace = getattr(module, "process_stacktrace")
|
||||
process_stacktrace = module.process_stacktrace
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
@@ -733,6 +734,13 @@ def write_cpp_file() -> int:
|
||||
|
||||
|
||||
def compile_program(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
# Keep this gate here, NOT in config validation: device-builder needs
|
||||
# `esphome config` to keep succeeding with placeholders so onboarding can run.
|
||||
if CONF_WIFI in config:
|
||||
from esphome.components.wifi import check_placeholder_credentials
|
||||
|
||||
check_placeholder_credentials(config)
|
||||
|
||||
# NOTE: "Build path:" format is parsed by script/ci_memory_impact_extract.py
|
||||
# If you change this format, update the regex in that script as well
|
||||
_LOGGER.info("Compiling app... Build path: %s", CORE.build_path)
|
||||
@@ -786,7 +794,7 @@ def _check_and_emit_build_info() -> None:
|
||||
|
||||
# Read build_info from JSON
|
||||
try:
|
||||
with open(build_info_json_path, encoding="utf-8") as f:
|
||||
with build_info_json_path.open(encoding="utf-8") as f:
|
||||
build_info = json.load(f)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
_LOGGER.debug("Failed to read build_info: %s", e)
|
||||
@@ -1048,7 +1056,7 @@ def _wait_for_serial_port(
|
||||
def _port_found() -> bool:
|
||||
if port is not None:
|
||||
if os.name == "posix":
|
||||
return os.path.exists(port)
|
||||
return Path(port).exists()
|
||||
return any(p.path == port for p in get_serial_ports())
|
||||
ports = get_serial_ports()
|
||||
if known_ports is not None:
|
||||
@@ -1093,7 +1101,7 @@ def upload_program(
|
||||
host = devices[0]
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if getattr(module, "upload_program")(config, args, host):
|
||||
if module.upload_program(config, args, host):
|
||||
return 0, host
|
||||
except AttributeError:
|
||||
pass
|
||||
@@ -1345,7 +1353,7 @@ def _validate_bootloader_binary(binary: Path) -> None:
|
||||
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
if getattr(module, "show_logs")(config, args, devices):
|
||||
if module.show_logs(config, args, devices):
|
||||
return 0
|
||||
except AttributeError:
|
||||
pass
|
||||
@@ -2441,7 +2449,10 @@ def run_esphome(argv):
|
||||
# Skipped when -s overrides are passed, since the cache was written
|
||||
# against the previous substitution set.
|
||||
config: ConfigType | None = None
|
||||
if args.command in ("upload", "logs") and not command_line_substitutions:
|
||||
cache_eligible = (
|
||||
args.command in ("upload", "logs") and not command_line_substitutions
|
||||
)
|
||||
if cache_eligible:
|
||||
from esphome.compiled_config import load_compiled_config
|
||||
|
||||
config = load_compiled_config(conf_path)
|
||||
@@ -2456,6 +2467,16 @@ def run_esphome(argv):
|
||||
command_line_substitutions,
|
||||
skip_external_update=skip_external,
|
||||
)
|
||||
# Refresh the cache so the next upload/logs hits the fast path
|
||||
# instead of re-running read_config. Skip when the storage
|
||||
# sidecar is absent (no compile has run): the cache would
|
||||
# never be loaded back, so writing secrets to disk is wasted.
|
||||
if cache_eligible and config is not None:
|
||||
from esphome.compiled_config import save_compiled_config
|
||||
from esphome.storage_json import ext_storage_path
|
||||
|
||||
if ext_storage_path(conf_path.name).exists():
|
||||
save_compiled_config(config)
|
||||
if config is None:
|
||||
return 2
|
||||
CORE.config = config
|
||||
|
||||
@@ -6,6 +6,7 @@ from collections import defaultdict
|
||||
from collections.abc import Callable
|
||||
import heapq
|
||||
from operator import itemgetter
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -509,7 +510,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
lines.append(
|
||||
f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):"
|
||||
)
|
||||
for i, (symbol, demangled, size) in enumerate(large_core_symbols):
|
||||
for i, (_symbol, demangled, size) in enumerate(large_core_symbols):
|
||||
# Core symbols only track (symbol, demangled, size) without section info,
|
||||
# so we don't show section labels here
|
||||
lines.append(
|
||||
@@ -601,7 +602,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
lines.append(
|
||||
f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B & storage ({len(large_symbols)} symbols):"
|
||||
)
|
||||
for i, (symbol, demangled, size, section) in enumerate(large_symbols):
|
||||
for i, (_symbol, demangled, size, section) in enumerate(large_symbols):
|
||||
lines.append(
|
||||
f"{i + 1}. {self._format_symbol_with_section(demangled, size, section)}"
|
||||
)
|
||||
@@ -640,7 +641,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
lines.append(
|
||||
f" Symbols > {self.RAM_SYMBOL_SIZE_THRESHOLD} B ({len(large_ram_syms)}):"
|
||||
)
|
||||
for symbol, demangled, size, section in large_ram_syms[:10]:
|
||||
for _symbol, demangled, size, section in large_ram_syms[:10]:
|
||||
# Format section label consistently by stripping leading dot
|
||||
section_label = section.lstrip(".") if section else ""
|
||||
display_name = _format_pstorage_name(demangled)
|
||||
@@ -699,7 +700,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer):
|
||||
content = "\n".join(lines)
|
||||
|
||||
if output_file:
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
with Path(output_file).open("w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
else:
|
||||
print(content)
|
||||
@@ -737,7 +738,6 @@ def main():
|
||||
|
||||
# Load build directory
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from esphome.platformio.toolchain import IDEData
|
||||
|
||||
@@ -785,7 +785,7 @@ def main():
|
||||
if not idedata_path.exists():
|
||||
continue
|
||||
try:
|
||||
with open(idedata_path, encoding="utf-8") as f:
|
||||
with idedata_path.open(encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
idedata = IDEData(raw_data)
|
||||
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
|
||||
|
||||
@@ -154,7 +154,7 @@ def batch_demangle(
|
||||
failed_count = 0
|
||||
|
||||
for original, stripped, prefix, demangled in zip(
|
||||
symbols, symbols_stripped, symbols_prefixes, demangled_lines
|
||||
symbols, symbols_stripped, symbols_prefixes, demangled_lines, strict=True
|
||||
):
|
||||
# Add back any prefix that was removed
|
||||
demangled = _restore_symbol_prefix(prefix, stripped, demangled)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
@@ -37,7 +36,7 @@ def _find_in_platformio_packages(tool_name: str) -> str | None:
|
||||
Full path to the tool or None if not found
|
||||
"""
|
||||
# Get PlatformIO packages directory
|
||||
platformio_home = Path(os.path.expanduser("~/.platformio/packages"))
|
||||
platformio_home = Path("~/.platformio/packages").expanduser()
|
||||
if not platformio_home.exists():
|
||||
return None
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ def get_available_components() -> list[str] | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(project_desc, encoding="utf-8") as f:
|
||||
with project_desc.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
component_info = data.get("build_component_info", {})
|
||||
|
||||
+1
-1
@@ -412,7 +412,7 @@ class ConfigBundleCreator:
|
||||
@staticmethod
|
||||
def _add_to_tar(tar: tarfile.TarFile, bf: BundleFile) -> None:
|
||||
"""Add a BundleFile to the tar archive with deterministic metadata."""
|
||||
with open(bf.source, "rb") as f:
|
||||
with bf.source.open("rb") as f:
|
||||
_add_bytes_to_tar(tar, bf.path, f.read())
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#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
|
||||
@@ -1242,7 +1243,7 @@ void APIConnection::try_send_store_yaml_() {
|
||||
void APIConnection::on_get_time_response(const GetTimeResponse &value) {
|
||||
if (homeassistant::global_homeassistant_time != nullptr) {
|
||||
homeassistant::global_homeassistant_time->set_epoch_time(value.epoch_seconds);
|
||||
#ifdef USE_TIME_TIMEZONE
|
||||
#if defined(USE_HOMEASSISTANT_TIMEZONE) && defined(USE_TIME_TIMEZONE)
|
||||
if (!value.timezone.empty()) {
|
||||
// Check if the sender provided pre-parsed timezone data.
|
||||
// If std_offset is non-zero or DST rules are present, the parsed data was populated.
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
#endif
|
||||
#include "api_pb2.h"
|
||||
#include "api_pb2_service.h"
|
||||
#include "api_server.h"
|
||||
#include "list_entities.h"
|
||||
#include "subscribe_state.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/component.h"
|
||||
#ifdef USE_ESP32_CRASH_HANDLER
|
||||
@@ -36,6 +37,9 @@ 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
|
||||
@@ -420,44 +424,10 @@ 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. 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;
|
||||
}
|
||||
// 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);
|
||||
|
||||
// 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.
|
||||
@@ -801,7 +771,8 @@ class APIConnection final : public APIServerConnectionBase {
|
||||
// Read by process_batch_multi_ to pass into MessageInfo.
|
||||
uint8_t batch_header_size_{0};
|
||||
|
||||
uint32_t get_batch_delay_ms_() const { return this->parent_->get_batch_delay(); }
|
||||
// Defined in api_connection_buffer.h (needs APIServer complete).
|
||||
uint32_t get_batch_delay_ms_() const;
|
||||
// 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
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
#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
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "api_server.h"
|
||||
#ifdef USE_API
|
||||
#include <cerrno>
|
||||
#include <cinttypes>
|
||||
#include "api_connection.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/application.h"
|
||||
@@ -30,11 +31,6 @@ 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_();
|
||||
@@ -682,7 +678,7 @@ uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConn
|
||||
// Schedule automatic cleanup after timeout (client will have given up by then)
|
||||
// Uses numeric ID overload to avoid heap allocation from str_sprintf
|
||||
this->set_timeout(action_call_id, USE_API_ACTION_CALL_TIMEOUT_MS, [this, action_call_id]() {
|
||||
ESP_LOGD(TAG, "Action call %u timed out", action_call_id);
|
||||
ESP_LOGD(TAG, "Action call %" PRIu32 " timed out", action_call_id);
|
||||
this->unregister_active_action_call(action_call_id);
|
||||
});
|
||||
|
||||
@@ -726,7 +722,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id);
|
||||
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id);
|
||||
}
|
||||
#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
void APIServer::send_action_response(uint32_t action_call_id, bool success, StringRef error_message,
|
||||
@@ -738,7 +734,7 @@ void APIServer::send_action_response(uint32_t action_call_id, bool success, Stri
|
||||
return;
|
||||
}
|
||||
}
|
||||
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id);
|
||||
ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %" PRIu32, action_call_id);
|
||||
}
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON
|
||||
#endif // USE_API_USER_DEFINED_ACTION_RESPONSES
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
#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"
|
||||
@@ -12,8 +14,6 @@
|
||||
#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,15 +191,9 @@ 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.
|
||||
// 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>;
|
||||
using APIConnectionPtr = std::unique_ptr<APIConnection>;
|
||||
class ActiveClientsView {
|
||||
const APIConnectionPtr *begin_;
|
||||
const APIConnectionPtr *end_;
|
||||
|
||||
@@ -101,13 +101,14 @@ async def async_run_logs(
|
||||
client_info=f"ESPHome Logs {__version__}",
|
||||
noise_psk=noise_psk,
|
||||
addresses=addresses, # Pass all addresses for automatic retry
|
||||
provide_time=False,
|
||||
)
|
||||
|
||||
# Try platform-specific stacktrace handler first, fall back to generic
|
||||
platform_process_stacktrace = None
|
||||
try:
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
platform_process_stacktrace = getattr(module, "process_stacktrace")
|
||||
platform_process_stacktrace = module.process_stacktrace
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
|
||||
@@ -100,7 +100,7 @@ def position(min=-MAX_POSITION, max=MAX_POSITION):
|
||||
if isinstance(value, str) and value.endswith("%"):
|
||||
value = percent_to_position(value)
|
||||
|
||||
if isinstance(value, str) and (value.endswith("°") or value.endswith("deg")):
|
||||
if isinstance(value, str) and value.endswith(("°", "deg")):
|
||||
return angle_to_position(
|
||||
value,
|
||||
min=round(min * POSITION_TO_ANGLE),
|
||||
|
||||
@@ -335,7 +335,7 @@ async def to_code(config):
|
||||
|
||||
add_idf_component(
|
||||
name="esphome/esp-audio-libs",
|
||||
ref="3.0.0",
|
||||
ref="3.1.0",
|
||||
)
|
||||
|
||||
data = _get_data()
|
||||
|
||||
@@ -9,9 +9,12 @@ namespace esphome::audio {
|
||||
|
||||
static const char *const TAG = "audio.decoder";
|
||||
|
||||
static const uint32_t DECODING_TIMEOUT_MS = 50; // The decode function will yield after this duration
|
||||
static const uint32_t READ_WRITE_TIMEOUT_MS = 20; // Timeout for transferring audio data
|
||||
|
||||
// Max consecutive decode iterations that consume input but produce no output; e.g., skipping a large metadata block,
|
||||
// before yielding and returning.
|
||||
static const uint8_t MAX_NO_OUTPUT_ITERATIONS = 32;
|
||||
|
||||
static const uint32_t MAX_POTENTIALLY_FAILED_COUNT = 10;
|
||||
|
||||
AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
|
||||
@@ -20,11 +23,13 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
|
||||
}
|
||||
|
||||
esp_err_t AudioDecoder::add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer) {
|
||||
auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_);
|
||||
// Zero-copy source reading directly from the ring buffer's internal storage. Raw file data is byte
|
||||
// aligned, so no frame alignment is required.
|
||||
auto source = RingBufferAudioSource::create(input_ring_buffer.lock(), this->input_buffer_size_);
|
||||
if (source == nullptr) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
// create() only returns nullptr for invalid arguments (expired ring buffer or zero buffer size)
|
||||
return ESP_ERR_INVALID_ARG;
|
||||
}
|
||||
source->set_source(input_ring_buffer);
|
||||
this->input_buffer_ = std::move(source);
|
||||
return ESP_OK;
|
||||
}
|
||||
@@ -141,13 +146,7 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
}
|
||||
|
||||
FileDecoderState state = FileDecoderState::MORE_TO_PROCESS;
|
||||
|
||||
uint32_t decoding_start = millis();
|
||||
|
||||
bool first_loop_iteration = true;
|
||||
|
||||
size_t bytes_processed = 0;
|
||||
size_t bytes_available_before_processing = 0;
|
||||
uint8_t no_output_iterations = 0;
|
||||
|
||||
while (state == FileDecoderState::MORE_TO_PROCESS) {
|
||||
// Transfer decoded out
|
||||
@@ -161,45 +160,39 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
this->playback_ms_ +=
|
||||
this->audio_stream_info_.value().frames_to_milliseconds_with_remainder(&this->accumulated_frames_written_);
|
||||
}
|
||||
|
||||
if ((bytes_written > 0) && (this->output_transfer_buffer_->available() == 0)) {
|
||||
// All decoded audio has been flushed to the sink; return so the caller can react to stop/pause before
|
||||
// decoding the next batch
|
||||
return AudioDecoderState::DECODING;
|
||||
}
|
||||
} else {
|
||||
// If paused, block to avoid wasting CPU resources
|
||||
delay(READ_WRITE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
// Verify there is enough space to store more decoded audio and that the function hasn't been running too long
|
||||
if ((this->output_transfer_buffer_->free() < this->free_buffer_required_) ||
|
||||
(millis() - decoding_start > DECODING_TIMEOUT_MS)) {
|
||||
if (this->output_transfer_buffer_->available() > 0) {
|
||||
// Output transfer buffer indicates backpressure, return so caller can handle other events;
|
||||
// e.g., stop/pause, before trying again
|
||||
return AudioDecoderState::DECODING;
|
||||
}
|
||||
|
||||
// Decode more audio
|
||||
|
||||
// Never shift the input buffer; every decoder buffers internally and consumes only what it processed.
|
||||
size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
|
||||
|
||||
if (!first_loop_iteration && (this->input_buffer_->available() < bytes_processed)) {
|
||||
// Less data is available than what was processed in last iteration, so don't attempt to decode.
|
||||
// This attempts to avoid the decoder from consistently trying to decode an incomplete frame. The transfer buffer
|
||||
// will shift the remaining data to the start and copy more from the source the next time the decode function is
|
||||
// called
|
||||
break;
|
||||
// Reaching here means no decoded output is pending (any would have returned above). Bounds long no-output
|
||||
// stretches; e.g., skipping a large metadata block, so a source that keeps the ring buffer full can't spin this
|
||||
// loop without yielding and trip the watchdog. The delay yields allowing other tasks to feed the watchdog and
|
||||
// the return keeps stop/pause responsive.
|
||||
if (++no_output_iterations >= MAX_NO_OUTPUT_ITERATIONS) {
|
||||
delay(1);
|
||||
return AudioDecoderState::DECODING;
|
||||
}
|
||||
|
||||
bytes_available_before_processing = this->input_buffer_->available();
|
||||
// Expose the next chunk of file data. Every decoder buffers internally and consumes only what it
|
||||
// processed, so the source does not need to accumulate or stitch chunks across fill() calls.
|
||||
this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
|
||||
|
||||
if ((this->potentially_failed_count_ > 0) && (bytes_read == 0)) {
|
||||
// Failed to decode in last attempt and there is no new data
|
||||
const size_t available_before_decode = this->input_buffer_->available();
|
||||
|
||||
if ((this->input_buffer_->free() == 0) && first_loop_iteration) {
|
||||
// The input buffer is full (or read-only, e.g. const flash source). Since it previously failed on the exact
|
||||
// same data, we can never recover. For const sources this is correct: the entire file is already available, so
|
||||
// a decode failure is genuine, not a transient out-of-data condition.
|
||||
state = FileDecoderState::FAILED;
|
||||
} else {
|
||||
// Attempt to get more data next time
|
||||
state = FileDecoderState::IDLE;
|
||||
}
|
||||
} else if (this->input_buffer_->available() == 0) {
|
||||
if (available_before_decode == 0) {
|
||||
// No data to decode, attempt to get more data next time
|
||||
state = FileDecoderState::IDLE;
|
||||
} else {
|
||||
@@ -231,9 +224,6 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
}
|
||||
}
|
||||
|
||||
first_loop_iteration = false;
|
||||
bytes_processed = bytes_available_before_processing - this->input_buffer_->available();
|
||||
|
||||
if (state == FileDecoderState::POTENTIALLY_FAILED) {
|
||||
++this->potentially_failed_count_;
|
||||
} else if (state == FileDecoderState::END_OF_FILE) {
|
||||
@@ -241,7 +231,16 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
} else if (state == FileDecoderState::FAILED) {
|
||||
return AudioDecoderState::FAILED;
|
||||
} else if (state == FileDecoderState::MORE_TO_PROCESS) {
|
||||
this->potentially_failed_count_ = 0;
|
||||
// Reset the failsafe only when the iteration made forward progress: input was consumed or output was
|
||||
// produced (output_transfer_buffer_ is drained empty above, so any available bytes are new). A
|
||||
// MORE_TO_PROCESS that neither consumes input nor produces output means the decoder is stalled; count it
|
||||
// toward the failsafe so a stuck stream eventually surfaces as FAILED instead of looping forever.
|
||||
if ((this->input_buffer_->available() < available_before_decode) ||
|
||||
(this->output_transfer_buffer_->available() > 0)) {
|
||||
this->potentially_failed_count_ = 0;
|
||||
} else {
|
||||
++this->potentially_failed_count_;
|
||||
}
|
||||
}
|
||||
}
|
||||
return AudioDecoderState::DECODING;
|
||||
|
||||
@@ -61,15 +61,16 @@ class AudioDecoder {
|
||||
*/
|
||||
public:
|
||||
/// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source()
|
||||
/// @param input_buffer_size Size of the input transfer buffer in bytes.
|
||||
/// @param input_buffer_size Soft cap on the bytes a ring buffer source exposes per fill, in bytes.
|
||||
/// @param output_buffer_size Size of the output transfer buffer in bytes.
|
||||
AudioDecoder(size_t input_buffer_size, size_t output_buffer_size);
|
||||
|
||||
~AudioDecoder() = default;
|
||||
|
||||
/// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr.
|
||||
/// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
|
||||
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
/// @brief Adds a source ring buffer for raw file data. Shares ownership of the ring buffer via a shared_ptr.
|
||||
/// The decoder reads directly from the ring buffer's internal storage with a zero-copy RingBufferAudioSource.
|
||||
/// @param input_ring_buffer weak_ptr of the source ring buffer to read from
|
||||
/// @return ESP_OK if successful, ESP_ERR_INVALID_ARG if the ring buffer is expired or the buffer size is zero
|
||||
esp_err_t add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer);
|
||||
|
||||
/// @brief Adds a sink ring buffer for decoded audio. Takes ownership of the ring buffer in a shared_ptr.
|
||||
|
||||
@@ -12,16 +12,17 @@ static const uint32_t READ_WRITE_TIMEOUT_MS = 20;
|
||||
|
||||
AudioResampler::AudioResampler(size_t input_buffer_size, size_t output_buffer_size)
|
||||
: input_buffer_size_(input_buffer_size), output_buffer_size_(output_buffer_size) {
|
||||
this->input_transfer_buffer_ = AudioSourceTransferBuffer::create(input_buffer_size);
|
||||
this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size);
|
||||
}
|
||||
|
||||
esp_err_t AudioResampler::add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer) {
|
||||
if (this->input_transfer_buffer_ != nullptr) {
|
||||
this->input_transfer_buffer_->set_source(input_ring_buffer);
|
||||
return ESP_OK;
|
||||
// The zero-copy RingBufferAudioSource is created lazily on the first resample() call, once both the ring
|
||||
// buffer (stored here) and the input stream info (set by start()) are available, in either order.
|
||||
this->source_ring_buffer_ = input_ring_buffer.lock();
|
||||
if (this->source_ring_buffer_ == nullptr) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
return ESP_ERR_NO_MEM;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t AudioResampler::add_sink(std::weak_ptr<ring_buffer::RingBuffer> &output_ring_buffer) {
|
||||
@@ -47,7 +48,7 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
|
||||
this->input_stream_info_ = input_stream_info;
|
||||
this->output_stream_info_ = output_stream_info;
|
||||
|
||||
if ((this->input_transfer_buffer_ == nullptr) || (this->output_transfer_buffer_ == nullptr)) {
|
||||
if (this->output_transfer_buffer_ == nullptr) {
|
||||
return ESP_ERR_NO_MEM;
|
||||
}
|
||||
|
||||
@@ -56,6 +57,13 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
// Reject frame sizes that can't be used as the zero-copy source's alignment up front, where the caller checks
|
||||
// the return code. The lazy create() in resample() keeps its own guard since it runs before the uint8_t cast.
|
||||
const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1);
|
||||
if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) {
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
if ((input_stream_info.get_sample_rate() != output_stream_info.get_sample_rate()) ||
|
||||
(input_stream_info.get_bits_per_sample() != output_stream_info.get_bits_per_sample())) {
|
||||
this->resampler_ = make_unique<esp_audio_libs::resampler::Resampler>(
|
||||
@@ -87,8 +95,27 @@ esp_err_t AudioResampler::start(AudioStreamInfo &input_stream_info, AudioStreamI
|
||||
}
|
||||
|
||||
AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_differential) {
|
||||
if (this->audio_source_ == nullptr) {
|
||||
// Lazily create the zero-copy source on first use. Frame-aligned reads ensure multi-channel frames are
|
||||
// never split across the ring buffer's wrap boundary.
|
||||
const size_t bytes_per_frame = this->input_stream_info_.frames_to_bytes(1);
|
||||
if ((bytes_per_frame == 0) || (bytes_per_frame > RingBufferAudioSource::MAX_ALIGNMENT_BYTES)) {
|
||||
// Stream info is unset or the frame is too large to use as an alignment; the uint8_t cast below would
|
||||
// truncate it and could yield a source that tears frames.
|
||||
return AudioResamplerState::FAILED;
|
||||
}
|
||||
// Pass the shared_ptr by copy so a failed create() leaves source_ring_buffer_ intact; release our
|
||||
// reference only after the source has taken ownership.
|
||||
this->audio_source_ = RingBufferAudioSource::create(this->source_ring_buffer_, this->input_buffer_size_,
|
||||
static_cast<uint8_t>(bytes_per_frame));
|
||||
if (this->audio_source_ == nullptr) {
|
||||
return AudioResamplerState::FAILED;
|
||||
}
|
||||
this->source_ring_buffer_.reset();
|
||||
}
|
||||
|
||||
if (stop_gracefully) {
|
||||
if (!this->input_transfer_buffer_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
|
||||
if (!this->audio_source_->has_buffered_data() && (this->output_transfer_buffer_->available() == 0)) {
|
||||
return AudioResamplerState::FINISHED;
|
||||
}
|
||||
}
|
||||
@@ -102,9 +129,11 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
|
||||
delay(READ_WRITE_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
this->input_transfer_buffer_->transfer_data_from_source(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS));
|
||||
// Expose a chunk of the ring buffer's internal storage. pre_shift is ignored by RingBufferAudioSource
|
||||
// (there is no intermediate transfer buffer to compact).
|
||||
this->audio_source_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
|
||||
|
||||
if (this->input_transfer_buffer_->available() == 0) {
|
||||
if (this->audio_source_->available() == 0) {
|
||||
// No samples available to process
|
||||
return AudioResamplerState::RESAMPLING;
|
||||
}
|
||||
@@ -112,17 +141,17 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
|
||||
const size_t bytes_free = this->output_transfer_buffer_->free();
|
||||
const uint32_t frames_free = this->output_stream_info_.bytes_to_frames(bytes_free);
|
||||
|
||||
const size_t bytes_available = this->input_transfer_buffer_->available();
|
||||
const size_t bytes_available = this->audio_source_->available();
|
||||
const uint32_t frames_available = this->input_stream_info_.bytes_to_frames(bytes_available);
|
||||
|
||||
if ((this->input_stream_info_.get_sample_rate() != this->output_stream_info_.get_sample_rate()) ||
|
||||
(this->input_stream_info_.get_bits_per_sample() != this->output_stream_info_.get_bits_per_sample())) {
|
||||
// Adjust gain by -3 dB to avoid clipping due to the resampling process
|
||||
esp_audio_libs::resampler::ResamplerResults results =
|
||||
this->resampler_->resample(this->input_transfer_buffer_->get_buffer_start(),
|
||||
this->output_transfer_buffer_->get_buffer_end(), frames_available, frames_free, -3);
|
||||
this->resampler_->resample(this->audio_source_->data(), this->output_transfer_buffer_->get_buffer_end(),
|
||||
frames_available, frames_free, -3);
|
||||
|
||||
this->input_transfer_buffer_->decrease_buffer_length(this->input_stream_info_.frames_to_bytes(results.frames_used));
|
||||
this->audio_source_->consume(this->input_stream_info_.frames_to_bytes(results.frames_used));
|
||||
this->output_transfer_buffer_->increase_buffer_length(
|
||||
this->output_stream_info_.frames_to_bytes(results.frames_generated));
|
||||
|
||||
@@ -146,10 +175,10 @@ AudioResamplerState AudioResampler::resample(bool stop_gracefully, int32_t *ms_d
|
||||
const size_t bytes_to_transfer = std::min(this->output_stream_info_.frames_to_bytes(frames_free),
|
||||
this->input_stream_info_.frames_to_bytes(frames_available));
|
||||
|
||||
std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(),
|
||||
(void *) this->input_transfer_buffer_->get_buffer_start(), bytes_to_transfer);
|
||||
std::memcpy((void *) this->output_transfer_buffer_->get_buffer_end(), (const void *) this->audio_source_->data(),
|
||||
bytes_to_transfer);
|
||||
|
||||
this->input_transfer_buffer_->decrease_buffer_length(bytes_to_transfer);
|
||||
this->audio_source_->consume(bytes_to_transfer);
|
||||
this->output_transfer_buffer_->increase_buffer_length(bytes_to_transfer);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ namespace esphome::audio {
|
||||
enum class AudioResamplerState : uint8_t {
|
||||
RESAMPLING, // More data is available to resample
|
||||
FINISHED, // All file data has been resampled and transferred
|
||||
FAILED, // Unused state included for consistency among Audio classes
|
||||
FAILED, // Failed to allocate the audio source
|
||||
};
|
||||
|
||||
class AudioResampler {
|
||||
@@ -32,14 +32,16 @@ class AudioResampler {
|
||||
* component). Also supports converting bits per sample.
|
||||
*/
|
||||
public:
|
||||
/// @brief Allocates the input and output transfer buffers
|
||||
/// @param input_buffer_size Size of the input transfer buffer in bytes.
|
||||
/// @brief Allocates the output transfer buffer. The input source is created later in resample().
|
||||
/// @param input_buffer_size Max bytes exposed per fill() call on the zero-copy input source.
|
||||
/// @param output_buffer_size Size of the output transfer buffer in bytes.
|
||||
AudioResampler(size_t input_buffer_size, size_t output_buffer_size);
|
||||
|
||||
/// @brief Adds a source ring buffer for audio data. Takes ownership of the ring buffer in a shared_ptr.
|
||||
/// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
|
||||
/// @return ESP_OK if successsful, ESP_ERR_NO_MEM if the transfer buffer wasn't allocated
|
||||
/// @brief Sets the ring buffer the audio is read from and takes shared ownership of it. The zero-copy
|
||||
/// RingBufferAudioSource that reads directly from its internal storage is created lazily on the first
|
||||
/// resample() call, so add_source() and start() may be called in any order.
|
||||
/// @param input_ring_buffer weak_ptr of a shared_ptr of the source ring buffer to transfer ownership
|
||||
/// @return ESP_OK if successful, ESP_ERR_INVALID_STATE if the ring buffer is no longer alive
|
||||
esp_err_t add_source(std::weak_ptr<ring_buffer::RingBuffer> &input_ring_buffer);
|
||||
|
||||
/// @brief Adds a sink ring buffer for resampled audio. Takes ownership of the ring buffer in a shared_ptr.
|
||||
@@ -78,7 +80,8 @@ class AudioResampler {
|
||||
void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; }
|
||||
|
||||
protected:
|
||||
std::unique_ptr<AudioSourceTransferBuffer> input_transfer_buffer_;
|
||||
std::shared_ptr<ring_buffer::RingBuffer> source_ring_buffer_;
|
||||
std::unique_ptr<RingBufferAudioSource> audio_source_;
|
||||
std::unique_ptr<AudioSinkTransferBuffer> output_transfer_buffer_;
|
||||
|
||||
size_t input_buffer_size_;
|
||||
|
||||
@@ -252,6 +252,22 @@ void RingBufferAudioSource::consume(size_t bytes) {
|
||||
}
|
||||
}
|
||||
|
||||
void RingBufferAudioSource::clear_buffered_data() {
|
||||
// Release the held item before reset() so the source no longer references memory the reset will reclaim.
|
||||
if (this->acquired_item_ != nullptr) {
|
||||
this->ring_buffer_->receive_release(this->acquired_item_);
|
||||
this->acquired_item_ = nullptr;
|
||||
}
|
||||
this->current_data_ = nullptr;
|
||||
this->current_available_ = 0;
|
||||
this->queued_data_ = nullptr;
|
||||
this->queued_length_ = 0;
|
||||
this->item_trailing_ptr_ = nullptr;
|
||||
this->item_trailing_length_ = 0;
|
||||
this->splice_length_ = 0;
|
||||
this->ring_buffer_->reset();
|
||||
}
|
||||
|
||||
bool RingBufferAudioSource::has_buffered_data() const {
|
||||
// splice_length_ is deliberately not considered here. It holds an incomplete frame whose completion
|
||||
// bytes must still arrive through the ring buffer, which ring_buffer_->available() already reports.
|
||||
|
||||
@@ -250,6 +250,10 @@ class RingBufferAudioSource : public AudioReadableBuffer {
|
||||
/// exposure stays in place and fill() returns 0 until it is fully consumed.
|
||||
size_t fill(TickType_t ticks_to_wait, bool pre_shift) override;
|
||||
|
||||
/// @brief Discards all buffered audio: releases any held ring buffer item, clears the source's in-flight
|
||||
/// state, and resets the underlying ring buffer. Must be invoked from the ring buffer's consumer thread.
|
||||
void clear_buffered_data();
|
||||
|
||||
/// @brief Returns a mutable pointer to the currently exposed audio data.
|
||||
/// The pointer may reference the ring buffer's internal storage or, when exposing a stitched frame
|
||||
/// across a wrap boundary, an internal splice buffer. In either case mutations are safe but data
|
||||
|
||||
@@ -72,7 +72,7 @@ def _file_schema(value: ConfigType | str) -> ConfigType:
|
||||
|
||||
def _validate_file_shorthand(value: str) -> ConfigType:
|
||||
value = cv.string_strict(value)
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
if value.startswith(("http://", "https://")):
|
||||
return _file_schema(
|
||||
{
|
||||
CONF_TYPE: TYPE_WEB,
|
||||
@@ -98,7 +98,7 @@ def read_audio_file_and_type(file_config: ConfigType) -> tuple[bytes, MockObj]:
|
||||
else:
|
||||
raise cv.Invalid("Unsupported file source")
|
||||
|
||||
with open(path, "rb") as f:
|
||||
with path.open("rb") as f:
|
||||
data = f.read()
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, esp32, media_source, psram
|
||||
from esphome.components import audio, media_source, psram
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM
|
||||
from esphome.types import ConfigType
|
||||
@@ -21,19 +19,13 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _validate_task_stack_in_psram(value: Any) -> bool:
|
||||
if value := cv.boolean(value):
|
||||
return cv.requires_component(psram.DOMAIN)(value)
|
||||
return value
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
media_source.media_source_schema(
|
||||
AudioFileMediaSource,
|
||||
)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
@@ -49,6 +41,4 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
psram.request_external_task_stack()
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, esp32, media_source, psram
|
||||
from esphome.components import audio, media_source, psram
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BUFFER_SIZE, CONF_ID, CONF_TASK_STACK_IN_PSRAM
|
||||
from esphome.types import ConfigType
|
||||
@@ -20,14 +18,6 @@ def _request_micro_decoder(config: ConfigType) -> ConfigType:
|
||||
return config
|
||||
|
||||
|
||||
def _validate_task_stack_in_psram(value: Any) -> bool:
|
||||
# Only require the psram component when actually enabling PSRAM stacks; validating
|
||||
# the boolean first means `false` doesn't trigger the requires_component check.
|
||||
if value := cv.boolean(value):
|
||||
return cv.requires_component(psram.DOMAIN)(value)
|
||||
return value
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
media_source.media_source_schema(
|
||||
AudioHTTPMediaSource,
|
||||
@@ -37,7 +27,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=50000): cv.int_range(
|
||||
min=5000, max=1000000
|
||||
),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
@@ -53,7 +43,5 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
psram.request_external_task_stack()
|
||||
cg.add(var.set_buffer_size(config[CONF_BUFFER_SIZE]))
|
||||
|
||||
@@ -135,12 +135,26 @@ 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)
|
||||
if (this->state() != espbt::ClientState::INIT && (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE ||
|
||||
this->send_service_ == DONE_SENDING_SERVICES)) {
|
||||
// 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)) {
|
||||
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);
|
||||
@@ -372,14 +386,6 @@ 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,6 +33,8 @@ 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,5 +1,6 @@
|
||||
#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"
|
||||
|
||||
@@ -169,7 +169,7 @@ async def to_code_base(config):
|
||||
path = _compute_local_file_path(_compute_url(config))
|
||||
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
bsec2_iaq_config = f.read()
|
||||
except Exception as e:
|
||||
raise core.EsphomeError(
|
||||
|
||||
@@ -46,7 +46,7 @@ from esphome.const import (
|
||||
Toolchain,
|
||||
__version__,
|
||||
)
|
||||
from esphome.core import CORE, HexInt, Library
|
||||
from esphome.core import CORE, EsphomeError, 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,6 +113,7 @@ 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",
|
||||
@@ -792,19 +793,15 @@ PLATFORM_VERSION_LOOKUP = {
|
||||
}
|
||||
|
||||
|
||||
def _check_pio_versions(config):
|
||||
config = config.copy()
|
||||
value = config[CONF_FRAMEWORK]
|
||||
def _resolve_framework_version(value: ConfigType) -> cv.Version:
|
||||
"""Resolve a named or raw framework version and validate the minimum.
|
||||
|
||||
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:
|
||||
@@ -817,7 +814,38 @@ def _check_pio_versions(config):
|
||||
if value[CONF_TYPE] == FRAMEWORK_ARDUINO:
|
||||
if version < cv.Version(3, 0, 0):
|
||||
raise cv.Invalid("Only Arduino 3.0+ is supported.")
|
||||
recommended_version = ARDUINO_FRAMEWORK_VERSION_LOOKUP["recommended"]
|
||||
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:
|
||||
platform_lookup = ARDUINO_PLATFORM_VERSION_LOOKUP.get(version)
|
||||
value[CONF_SOURCE] = value.get(
|
||||
CONF_SOURCE, _format_framework_arduino_version(version)
|
||||
@@ -825,9 +853,6 @@ def _check_pio_versions(config):
|
||||
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,
|
||||
@@ -843,12 +868,6 @@ def _check_pio_versions(config):
|
||||
)
|
||||
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"])
|
||||
):
|
||||
@@ -860,19 +879,26 @@ def _check_pio_versions(config):
|
||||
return config
|
||||
|
||||
|
||||
def _check_esp_idf_versions(config):
|
||||
config = _check_pio_versions(config)
|
||||
def _check_esp_idf_versions(config: ConfigType) -> ConfigType:
|
||||
config = config.copy()
|
||||
value = config[CONF_FRAMEWORK]
|
||||
|
||||
# Remove unwanted keys if present
|
||||
for key in (CONF_SOURCE, CONF_PLATFORM_VERSION):
|
||||
value.pop(key, None)
|
||||
# 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)
|
||||
|
||||
# Official ESP-IDF frameworks don't use extra
|
||||
version = cv.Version.parse(value[CONF_VERSION])
|
||||
version = cv.Version(version.major, version.minor, version.patch)
|
||||
version = _resolve_framework_version(value)
|
||||
|
||||
value[CONF_VERSION] = str(version)
|
||||
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))
|
||||
|
||||
return config
|
||||
|
||||
@@ -1718,6 +1744,31 @@ 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]
|
||||
@@ -1765,11 +1816,12 @@ async def to_code(config):
|
||||
Path(__file__).parent / "iram_fix.py.script",
|
||||
)
|
||||
else:
|
||||
cg.add_build_flag("-Wno-error=format")
|
||||
cg.add_build_flag("-Wno-error=maybe-uninitialized")
|
||||
cg.add_build_flag("-Wno-error=overloaded-virtual")
|
||||
cg.add_build_flag("-Wno-error=reorder")
|
||||
cg.add_build_flag("-Wno-error=volatile")
|
||||
# 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")
|
||||
|
||||
@@ -1777,11 +1829,8 @@ async def to_code(config):
|
||||
cg.add_build_flag("-DUSE_ESP32")
|
||||
cg.add_define("USE_NATIVE_64BIT_TIME")
|
||||
cg.add_build_flag("-Wl,-z,noexecstack")
|
||||
# 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")
|
||||
# Deferred so KEY_COMPONENTS is fully populated -- see the coroutine.
|
||||
CORE.add_job(_finalize_arduino_aware_flags)
|
||||
cg.add_define("ESPHOME_BOARD", config[CONF_BOARD])
|
||||
variant = config[CONF_VARIANT]
|
||||
cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}")
|
||||
@@ -1962,7 +2011,7 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_HEAP_PLACE_FUNCTION_INTO_FLASH", True)
|
||||
|
||||
# Setup watchdog
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_INIT", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0", False)
|
||||
add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1", False)
|
||||
@@ -2004,7 +2053,8 @@ async def to_code(config):
|
||||
if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]:
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False)
|
||||
if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]:
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0)
|
||||
# Kconfig range is [1,63]; 0 gets clamped to the default.
|
||||
add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 1)
|
||||
|
||||
_configure_lwip_max_sockets(conf)
|
||||
|
||||
@@ -2096,7 +2146,6 @@ async def to_code(config):
|
||||
for key, flag in ASSERTION_LEVELS.items():
|
||||
add_idf_sdkconfig_option(flag, assertion_level == key)
|
||||
|
||||
add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False)
|
||||
compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION]
|
||||
for key, flag in COMPILER_OPTIMIZATIONS.items():
|
||||
add_idf_sdkconfig_option(flag, compiler_optimization == key)
|
||||
@@ -2251,7 +2300,8 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 2)
|
||||
elif advanced[CONF_DISABLE_FATFS]:
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_LFN_NONE", True)
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 0)
|
||||
# Kconfig range is [1,10]; 0 gets clamped to the default.
|
||||
add_idf_sdkconfig_option("CONFIG_FATFS_VOLUME_COUNT", 1)
|
||||
|
||||
for name, value in conf[CONF_SDKCONFIG_OPTIONS].items():
|
||||
add_idf_sdkconfig_option(name, RawSdkconfigValue(value))
|
||||
@@ -2488,9 +2538,8 @@ 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
|
||||
|
||||
|
||||
@@ -2542,7 +2591,7 @@ def _write_idf_component_yml():
|
||||
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
add_idf_component(
|
||||
name="espressif/arduino-esp32",
|
||||
name=ARDUINO_ESP32_COMPONENT_NAME,
|
||||
ref=str(CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION]),
|
||||
)
|
||||
|
||||
@@ -2609,13 +2658,29 @@ def copy_files():
|
||||
|
||||
|
||||
def _decode_pc(config, addr):
|
||||
from esphome.platformio import toolchain
|
||||
# _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
|
||||
|
||||
idedata = toolchain.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
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:
|
||||
_LOGGER.debug("decode_pc no addr2line")
|
||||
return
|
||||
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||
command = [str(addr2line_path), "-pfiaC", "-e", str(firmware_elf_path), addr]
|
||||
try:
|
||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
|
||||
@@ -72,6 +72,7 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +419,7 @@ 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,6 +140,12 @@ 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);
|
||||
@@ -149,6 +155,10 @@ class BLEClientBase : public espbt::ESPBTClient, public Component {
|
||||
void set_disconnecting_() {
|
||||
this->disconnecting_started_ = millis();
|
||||
this->set_state(espbt::ClientState::DISCONNECTING);
|
||||
// BluetoothConnection::loop() disables the component loop after service discovery
|
||||
// completes, so the DISCONNECTING timeout check in loop() would never run if CLOSE_EVT
|
||||
// gets lost. Re-enable the loop so the 10s safety timeout can force IDLE.
|
||||
this->enable_loop();
|
||||
}
|
||||
// Compact error logging helpers to reduce flash usage
|
||||
void log_error_(const char *message);
|
||||
|
||||
@@ -196,42 +196,35 @@ void BLECharacteristic::gatts_event_handler(esp_gatts_cb_event_t event, esp_gatt
|
||||
(*this->on_read_callback_)(param->read.conn_id);
|
||||
}
|
||||
|
||||
uint16_t max_offset = 22;
|
||||
|
||||
// Use the client-supplied offset for long reads; short reads always start at 0.
|
||||
// The Bluedroid stack truncates ATT_READ_RSP / ATT_READ_BLOB_RSP to MTU-1, so we
|
||||
// just provide as much data as we have from the requested offset and let the stack
|
||||
// handle framing. The client issues subsequent blob reads with increasing offsets
|
||||
// until it has received the whole value.
|
||||
const uint16_t offset = param->read.is_long ? param->read.offset : 0;
|
||||
esp_gatt_status_t status = ESP_GATT_OK;
|
||||
esp_gatt_rsp_t response;
|
||||
if (param->read.is_long) {
|
||||
if (this->value_read_offset_ >= this->value_.size()) {
|
||||
response.attr_value.len = 0;
|
||||
response.attr_value.offset = this->value_read_offset_;
|
||||
this->value_read_offset_ = 0;
|
||||
} else if (this->value_.size() - this->value_read_offset_ < max_offset) {
|
||||
// Last message in the chain
|
||||
response.attr_value.len = this->value_.size() - this->value_read_offset_;
|
||||
response.attr_value.offset = this->value_read_offset_;
|
||||
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
|
||||
this->value_read_offset_ = 0;
|
||||
} else {
|
||||
response.attr_value.len = max_offset;
|
||||
response.attr_value.offset = this->value_read_offset_;
|
||||
memcpy(response.attr_value.value, this->value_.data() + response.attr_value.offset, response.attr_value.len);
|
||||
this->value_read_offset_ += max_offset;
|
||||
}
|
||||
response.attr_value.offset = offset;
|
||||
|
||||
if (offset > this->value_.size()) {
|
||||
status = ESP_GATT_INVALID_OFFSET;
|
||||
response.attr_value.len = 0;
|
||||
} else {
|
||||
response.attr_value.offset = 0;
|
||||
if (this->value_.size() + 1 > max_offset) {
|
||||
response.attr_value.len = max_offset;
|
||||
this->value_read_offset_ = max_offset;
|
||||
} else {
|
||||
response.attr_value.len = this->value_.size();
|
||||
size_t remaining = this->value_.size() - offset;
|
||||
if (remaining > ESP_GATT_MAX_ATTR_LEN) {
|
||||
ESP_LOGW(TAG, "Characteristic length %u exceeds buffer size of %u, truncating",
|
||||
static_cast<unsigned>(remaining), ESP_GATT_MAX_ATTR_LEN);
|
||||
remaining = ESP_GATT_MAX_ATTR_LEN;
|
||||
}
|
||||
memcpy(response.attr_value.value, this->value_.data(), response.attr_value.len);
|
||||
response.attr_value.len = remaining;
|
||||
memcpy(response.attr_value.value, this->value_.data() + offset, remaining);
|
||||
}
|
||||
|
||||
response.attr_value.handle = this->handle_;
|
||||
response.attr_value.auth_req = ESP_GATT_AUTH_REQ_NONE;
|
||||
|
||||
esp_err_t err =
|
||||
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, ESP_GATT_OK, &response);
|
||||
esp_ble_gatts_send_response(gatts_if, param->read.conn_id, param->read.trans_id, status, &response);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ble_gatts_send_response failed: %d", err);
|
||||
}
|
||||
|
||||
@@ -79,7 +79,6 @@ class BLECharacteristic {
|
||||
esp_gatt_char_prop_t properties_;
|
||||
uint16_t handle_{0xFFFF};
|
||||
|
||||
uint16_t value_read_offset_{0};
|
||||
std::vector<uint8_t> value_;
|
||||
std::vector<BLEDescriptor *> descriptors_;
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
|
||||
from esphome import pins
|
||||
from esphome.components import esp32
|
||||
from esphome.components.const import CONF_USE_PSRAM
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_CLK_PIN,
|
||||
@@ -39,6 +40,7 @@ BASE_SCHEMA = cv.Schema(
|
||||
cv.Required(CONF_VARIANT): cv.one_of(*esp32.VARIANTS, upper=True),
|
||||
cv.Required(CONF_ACTIVE_HIGH): cv.boolean,
|
||||
cv.Required(CONF_RESET_PIN): pins.internal_gpio_output_pin_number,
|
||||
cv.Optional(CONF_USE_PSRAM, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -242,6 +244,12 @@ async def to_code(config):
|
||||
else:
|
||||
_configure_spi(config)
|
||||
|
||||
# Place the transport mempool in PSRAM. Required on memory-tight host
|
||||
# configurations (e.g. P4 with a large LVGL UI) where the internal-RAM
|
||||
# mempool allocation fails at boot with `sdio_mempool_create` assert.
|
||||
if config[CONF_USE_PSRAM]:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_ESP_HOSTED_MEMPOOL_PREFER_SPIRAM", True)
|
||||
|
||||
# Library versions
|
||||
idf_ver = esp32.idf_version()
|
||||
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
|
||||
@@ -249,7 +257,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.8")
|
||||
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")
|
||||
|
||||
@@ -75,7 +75,7 @@ def _validate_firmware(config: dict[str, Any]) -> None:
|
||||
return
|
||||
|
||||
path = CORE.relative_config_path(config[CONF_PATH])
|
||||
with open(path, "rb") as f:
|
||||
with path.open("rb") as f:
|
||||
firmware_data = f.read()
|
||||
calculated = hashlib.sha256(firmware_data).hexdigest()
|
||||
expected = config[CONF_SHA256].lower()
|
||||
@@ -93,7 +93,7 @@ async def to_code(config: dict[str, Any]) -> None:
|
||||
|
||||
if config[CONF_TYPE] == TYPE_EMBEDDED:
|
||||
path = config[CONF_PATH]
|
||||
with open(CORE.relative_config_path(path), "rb") as f:
|
||||
with CORE.relative_config_path(path).open("rb") as f:
|
||||
firmware_data = f.read()
|
||||
rhs = [HexInt(x) for x in firmware_data]
|
||||
arr_id = ID(f"{config[CONF_ID]}_data", is_declaration=True, type=cg.uint8)
|
||||
|
||||
@@ -121,7 +121,7 @@ void Esp32HostedUpdate::setup() {
|
||||
}
|
||||
} else {
|
||||
ESP_LOGW(TAG, "Invalid app description magic word: 0x%08" PRIx32 " (expected 0x%08" PRIx32 ")",
|
||||
app_desc->magic_word, ESP_APP_DESC_MAGIC_WORD);
|
||||
app_desc->magic_word, static_cast<uint32_t>(ESP_APP_DESC_MAGIC_WORD));
|
||||
this->state_ = update::UPDATE_STATE_NO_UPDATE;
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <core_esp8266_features.h>
|
||||
#include <coredecls.h>
|
||||
|
||||
extern "C" {
|
||||
#include <user_interface.h>
|
||||
@@ -71,23 +72,22 @@ uint32_t IRAM_ATTR HOT millis() {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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).
|
||||
void HOT delay(uint32_t ms) {
|
||||
if (ms == 0) {
|
||||
optimistic_yield(1000);
|
||||
return;
|
||||
}
|
||||
uint32_t start = millis();
|
||||
while (millis() - start < ms) {
|
||||
optimistic_yield(1000);
|
||||
}
|
||||
esp_delay(ms);
|
||||
}
|
||||
|
||||
void arch_restart() {
|
||||
|
||||
@@ -58,6 +58,12 @@ __attribute__((always_inline)) inline const char *progmem_read_ptr(const char *c
|
||||
__attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_t *addr) {
|
||||
return pgm_read_word(addr); // NOLINT
|
||||
}
|
||||
// Bulk PROGMEM copy: routes to the SDK's aligned-flash `memcpy_P` so callers
|
||||
// don't have to drop to a byte-by-byte `progmem_read_byte` loop, which on
|
||||
// ESP8266 is ~4x as many flash accesses as the bulk path.
|
||||
__attribute__((always_inline)) inline void progmem_memcpy(void *dst, const void *src, size_t len) {
|
||||
memcpy_P(dst, src, len); // NOLINT
|
||||
}
|
||||
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
|
||||
|
||||
@@ -17,7 +17,7 @@ from esphome.core import HexInt
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@jesserockz"]
|
||||
|
||||
AUTO_LOAD = ["network"]
|
||||
|
||||
byte_vector = cg.std_vector.template(cg.uint8)
|
||||
peer_address_t = cg.std_ns.class_("array").template(cg.uint8, 6)
|
||||
|
||||
@@ -149,12 +149,6 @@ bool ESPNowComponent::is_wifi_enabled() {
|
||||
}
|
||||
|
||||
void ESPNowComponent::setup() {
|
||||
#ifndef USE_WIFI
|
||||
// Initialize LwIP stack for wake_loop_threadsafe() socket support
|
||||
// When WiFi component is present, it handles esp_netif_init()
|
||||
ESP_ERROR_CHECK(esp_netif_init());
|
||||
#endif
|
||||
|
||||
if (this->enable_on_boot_) {
|
||||
this->enable_();
|
||||
} else {
|
||||
@@ -174,8 +168,6 @@ void ESPNowComponent::enable() {
|
||||
|
||||
void ESPNowComponent::enable_() {
|
||||
if (!this->is_wifi_enabled()) {
|
||||
esp_event_loop_create_default();
|
||||
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
|
||||
ESP_ERROR_CHECK(esp_wifi_init(&cfg));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "w5500_custom_spi.h"
|
||||
|
||||
#include <lwip/dns.h>
|
||||
#include <cinttypes>
|
||||
@@ -163,11 +164,7 @@ void EthernetComponent::setup() {
|
||||
err = spi_bus_initialize(host, &buscfg, SPI_DMA_CH_AUTO);
|
||||
ESPHL_ERROR_CHECK(err, "SPI bus initialize error");
|
||||
#endif
|
||||
|
||||
err = esp_netif_init();
|
||||
ESPHL_ERROR_CHECK(err, "ETH netif init error");
|
||||
err = esp_event_loop_create_default();
|
||||
ESPHL_ERROR_CHECK(err, "ETH event loop error");
|
||||
// Network interface setup handled by network component
|
||||
|
||||
esp_netif_config_t cfg = ESP_NETIF_DEFAULT_ETH();
|
||||
this->eth_netif_ = esp_netif_new(&cfg);
|
||||
@@ -207,6 +204,10 @@ void EthernetComponent::setup() {
|
||||
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
|
||||
w5500_config.poll_period_ms = this->polling_interval_;
|
||||
#endif
|
||||
// Install the custom SPI driver that offloads the bulk RX/TX frame transfers off the busy-wait
|
||||
// path. w5500_config (and the devcfg it references) outlives esp_eth_mac_new_w5500() below, which
|
||||
// runs the driver's init().
|
||||
install_w5500_async_spi(w5500_config);
|
||||
#elif defined(USE_ETHERNET_DM9051)
|
||||
dm9051_config.int_gpio_num = this->interrupt_pin_;
|
||||
#ifdef USE_ETHERNET_SPI_POLLING_SUPPORT
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
#include "w5500_custom_spi.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
|
||||
|
||||
#include <driver/spi_master.h>
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/semphr.h>
|
||||
#include <cstring>
|
||||
#include <new>
|
||||
|
||||
namespace esphome::ethernet {
|
||||
|
||||
namespace {
|
||||
|
||||
// Per-device context returned by init() and handed back to read/write/deinit.
|
||||
struct W5500CustomSpiContext {
|
||||
spi_device_handle_t handle;
|
||||
SemaphoreHandle_t lock;
|
||||
};
|
||||
|
||||
// Transfers up to the ESP32 SPI hardware FIFO size (64 bytes) stay on the polling path; larger
|
||||
// transfers (the frame payloads) use the blocking, DMA-backed transmit.
|
||||
constexpr uint32_t W5500_SPI_BULK_THRESHOLD = 64;
|
||||
constexpr uint32_t W5500_SPI_LOCK_TIMEOUT_MS = 50;
|
||||
|
||||
void *w5500_custom_spi_init(const void *spi_config) {
|
||||
const auto *config = static_cast<const eth_w5500_config_t *>(spi_config);
|
||||
auto *ctx = new (std::nothrow) W5500CustomSpiContext{};
|
||||
if (ctx == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
// The W5500 SPI frame carries the 16-bit address in the command phase and the 8-bit control
|
||||
// byte in the address phase; mirror what the stock driver configures.
|
||||
spi_device_interface_config_t devcfg = *config->spi_devcfg;
|
||||
devcfg.command_bits = 16;
|
||||
devcfg.address_bits = 8;
|
||||
if (spi_bus_add_device(config->spi_host_id, &devcfg, &ctx->handle) != ESP_OK) {
|
||||
delete ctx;
|
||||
return nullptr;
|
||||
}
|
||||
ctx->lock = xSemaphoreCreateMutex();
|
||||
if (ctx->lock == nullptr) {
|
||||
spi_bus_remove_device(ctx->handle);
|
||||
delete ctx;
|
||||
return nullptr;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
esp_err_t w5500_custom_spi_deinit(void *spi_ctx) {
|
||||
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
|
||||
spi_bus_remove_device(ctx->handle);
|
||||
vSemaphoreDelete(ctx->lock);
|
||||
delete ctx;
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
// Runs one transaction under the device lock, choosing the polling vs blocking transmit by size.
|
||||
// Bulk payloads (> FIFO size) block so the calling task sleeps while DMA runs; small register
|
||||
// accesses stay on the cheaper polling path. Used by both read and write.
|
||||
esp_err_t w5500_custom_spi_transfer(W5500CustomSpiContext *ctx, spi_transaction_t *trans, uint32_t len) {
|
||||
if (xSemaphoreTake(ctx->lock, pdMS_TO_TICKS(W5500_SPI_LOCK_TIMEOUT_MS)) != pdTRUE) {
|
||||
return ESP_ERR_TIMEOUT;
|
||||
}
|
||||
esp_err_t ret;
|
||||
if (len > W5500_SPI_BULK_THRESHOLD) {
|
||||
ret = spi_device_transmit(ctx->handle, trans);
|
||||
} else {
|
||||
ret = spi_device_polling_transmit(ctx->handle, trans);
|
||||
}
|
||||
xSemaphoreGive(ctx->lock);
|
||||
return ret;
|
||||
}
|
||||
|
||||
esp_err_t w5500_custom_spi_write(void *spi_ctx, uint32_t cmd, uint32_t addr, const void *data, uint32_t len) {
|
||||
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
|
||||
spi_transaction_t trans = {};
|
||||
trans.cmd = static_cast<uint16_t>(cmd);
|
||||
trans.addr = addr;
|
||||
trans.length = 8 * len;
|
||||
trans.tx_buffer = data;
|
||||
return w5500_custom_spi_transfer(ctx, &trans, len);
|
||||
}
|
||||
|
||||
esp_err_t w5500_custom_spi_read(void *spi_ctx, uint32_t cmd, uint32_t addr, void *data, uint32_t len) {
|
||||
auto *ctx = static_cast<W5500CustomSpiContext *>(spi_ctx);
|
||||
spi_transaction_t trans = {};
|
||||
// Reads of <= 4 bytes use the transaction's inline RX buffer to avoid 4-byte boundary
|
||||
// overwrites of adjacent registers (same guard the stock driver uses).
|
||||
const bool use_rxdata = len <= 4;
|
||||
trans.flags = use_rxdata ? SPI_TRANS_USE_RXDATA : 0;
|
||||
trans.cmd = static_cast<uint16_t>(cmd);
|
||||
trans.addr = addr;
|
||||
trans.length = 8 * len;
|
||||
trans.rx_buffer = data;
|
||||
esp_err_t ret = w5500_custom_spi_transfer(ctx, &trans, len);
|
||||
if (use_rxdata && (ret == ESP_OK)) {
|
||||
memcpy(data, trans.rx_data, len);
|
||||
}
|
||||
return ret;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void install_w5500_async_spi(eth_w5500_config_t &config) {
|
||||
// Point the custom driver's config at the W5500 config itself; init() reads spi_host_id and
|
||||
// spi_devcfg back out of it. The self-reference is valid because both the config and the
|
||||
// spi_devcfg it points at outlive the esp_eth_mac_new_w5500() call that runs init().
|
||||
config.custom_spi_driver.config = &config;
|
||||
config.custom_spi_driver.init = w5500_custom_spi_init;
|
||||
config.custom_spi_driver.deinit = w5500_custom_spi_deinit;
|
||||
config.custom_spi_driver.read = w5500_custom_spi_read;
|
||||
config.custom_spi_driver.write = w5500_custom_spi_write;
|
||||
}
|
||||
|
||||
} // namespace esphome::ethernet
|
||||
|
||||
#endif // USE_ESP32 && USE_ETHERNET_W5500
|
||||
@@ -0,0 +1,35 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_ETHERNET_W5500)
|
||||
|
||||
#include <esp_idf_version.h>
|
||||
// IDF 6.0 moved the per-chip SPI MAC drivers to the Espressif Component Registry; eth_w5500_config_t
|
||||
// is no longer reachable through esp_eth.h and needs the explicit header.
|
||||
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
|
||||
#include <esp_eth_mac_w5500.h>
|
||||
#else
|
||||
#include <esp_eth.h>
|
||||
#endif
|
||||
|
||||
namespace esphome::ethernet {
|
||||
|
||||
// Installs a custom W5500 SPI driver that offloads the bulk frame transfers off the busy-wait path.
|
||||
//
|
||||
// The stock W5500 driver runs every SPI transfer through spi_device_polling_transmit(), which
|
||||
// busy-waits the CPU for the whole transfer. The frame payload (one large read per received frame,
|
||||
// one large write per transmitted frame) is by far the biggest transfer, so the RX task and the TX
|
||||
// caller each spin for hundreds of microseconds per frame. This driver sends payload transfers
|
||||
// through the blocking, interrupt-driven spi_device_transmit() instead, so the calling task sleeps
|
||||
// while DMA moves the bytes. Small register accesses stay on the polling path, where the busy-wait
|
||||
// is cheaper than an interrupt round-trip.
|
||||
//
|
||||
// Must be called before esp_eth_mac_new_w5500(). The driver reads spi_host_id and spi_devcfg back
|
||||
// out of `config` in its init() callback, so `config` (and the spi_devcfg it points at) must stay
|
||||
// alive until esp_eth_mac_new_w5500() returns.
|
||||
void install_w5500_async_spi(eth_w5500_config_t &config);
|
||||
|
||||
} // namespace esphome::ethernet
|
||||
|
||||
#endif // USE_ESP32 && USE_ETHERNET_W5500
|
||||
@@ -81,7 +81,7 @@ def _process_single_config(config: dict[str, Any]) -> None:
|
||||
elif conf[CONF_TYPE] == TYPE_LOCAL:
|
||||
components_dir = Path(CORE.relative_config_path(conf[CONF_PATH]))
|
||||
else:
|
||||
raise NotImplementedError()
|
||||
raise NotImplementedError
|
||||
|
||||
if config[CONF_COMPONENTS] == "all":
|
||||
num_components = len(list(components_dir.glob("*/__init__.py")))
|
||||
|
||||
@@ -206,6 +206,7 @@ uint8_t FingerprintGrowComponent::save_fingerprint_() {
|
||||
break;
|
||||
case ENROLL_MISMATCH:
|
||||
ESP_LOGE(TAG, "Scans do not match");
|
||||
[[fallthrough]];
|
||||
default:
|
||||
return this->data_[0];
|
||||
}
|
||||
|
||||
@@ -401,7 +401,7 @@ def validate_file_shorthand(value):
|
||||
data[CONF_WEIGHT] = weight[1:]
|
||||
return font_file_schema(data)
|
||||
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
if value.startswith(("http://", "https://")):
|
||||
return font_file_schema(
|
||||
{
|
||||
CONF_TYPE: TYPE_WEB,
|
||||
@@ -563,13 +563,13 @@ async def to_code(config):
|
||||
point_set.update(flatten(config[CONF_GLYPHS]))
|
||||
# Create the codepoint to font file map
|
||||
base_font = FONT_CACHE[config[CONF_FILE]]
|
||||
point_font_map: dict[str, Face] = {c: base_font for c in point_set}
|
||||
point_font_map: dict[str, Face] = dict.fromkeys(point_set, base_font)
|
||||
# process extras, updating the map and extending the codepoint list
|
||||
for extra in config[CONF_EXTRAS]:
|
||||
extra_points = flatten(extra[CONF_GLYPHS])
|
||||
point_set.update(extra_points)
|
||||
extra_font = FONT_CACHE[extra[CONF_FILE]]
|
||||
point_font_map.update({c: extra_font for c in extra_points})
|
||||
point_font_map.update(dict.fromkeys(extra_points, extra_font))
|
||||
|
||||
codepoints = list(point_set)
|
||||
codepoints.sort(key=functools.cmp_to_key(glyph_comparator))
|
||||
@@ -594,7 +594,9 @@ async def to_code(config):
|
||||
x.height,
|
||||
]
|
||||
for (x, y) in zip(
|
||||
glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args]))
|
||||
glyph_args,
|
||||
list(accumulate([len(x.bitmap_data) for x in glyph_args])),
|
||||
strict=True,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -15,6 +15,16 @@ void FT5x06Touchscreen::setup() {
|
||||
this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE);
|
||||
}
|
||||
|
||||
// reading the chip registers to get max x/y does not seem to work.
|
||||
if (this->display_ != nullptr) {
|
||||
if (this->x_raw_max_ == this->x_raw_min_) {
|
||||
this->x_raw_max_ = this->display_->get_native_width();
|
||||
}
|
||||
if (this->y_raw_max_ == this->y_raw_min_) {
|
||||
this->y_raw_max_ = this->display_->get_native_height();
|
||||
}
|
||||
}
|
||||
|
||||
// wait 200ms after reset.
|
||||
this->set_timeout(200, [this] { this->continue_setup_(); });
|
||||
}
|
||||
@@ -39,15 +49,6 @@ void FT5x06Touchscreen::continue_setup_() {
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
// reading the chip registers to get max x/y does not seem to work.
|
||||
if (this->display_ != nullptr) {
|
||||
if (this->x_raw_max_ == this->x_raw_min_) {
|
||||
this->x_raw_max_ = this->display_->get_native_width();
|
||||
}
|
||||
if (this->y_raw_max_ == this->y_raw_min_) {
|
||||
this->y_raw_max_ = this->display_->get_native_height();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void FT5x06Touchscreen::update_touches() {
|
||||
@@ -71,7 +72,7 @@ void FT5x06Touchscreen::update_touches() {
|
||||
uint16_t x = encode_uint16(data[i][0] & 0x0F, data[i][1]);
|
||||
uint16_t y = encode_uint16(data[i][2] & 0xF, data[i][3]);
|
||||
|
||||
ESP_LOGD(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
|
||||
ESP_LOGV(TAG, "Read %X status, id: %d, pos %d/%d", status, id, x, y);
|
||||
if (status == 0 || status == 2) {
|
||||
this->add_raw_touch_position_(id, x, y);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,9 @@ void HomeassistantSensor::setup() {
|
||||
}
|
||||
|
||||
if (this->attribute_ != nullptr) {
|
||||
ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
|
||||
ESP_LOGV(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val);
|
||||
} else {
|
||||
ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
|
||||
ESP_LOGV(TAG, "'%s': Got state %.2f", this->entity_id_, *val);
|
||||
}
|
||||
this->publish_state(*val);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import time as time_
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID
|
||||
from esphome.const import CONF_ID, CONF_TIMEZONE
|
||||
|
||||
from .. import homeassistant_ns
|
||||
|
||||
@@ -21,3 +21,5 @@ async def to_code(config):
|
||||
await time_.register_time(var, config)
|
||||
await cg.register_component(var, config)
|
||||
cg.add_define("USE_HOMEASSISTANT_TIME")
|
||||
if CONF_TIMEZONE not in config:
|
||||
cg.add_define("USE_HOMEASSISTANT_TIMEZONE")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from pathlib import Path
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32
|
||||
@@ -63,7 +65,7 @@ CONF_JSON = "json"
|
||||
|
||||
def validate_url(value):
|
||||
value = cv.url(value)
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
if value.startswith(("http://", "https://")):
|
||||
return value
|
||||
raise cv.Invalid("URL must start with 'http://' or 'https://'")
|
||||
|
||||
@@ -174,7 +176,7 @@ async def to_code(config):
|
||||
|
||||
if config.get(CONF_VERIFY_SSL):
|
||||
if ca_cert_path := config.get(CONF_CA_CERTIFICATE_PATH):
|
||||
with open(ca_cert_path, encoding="utf-8") as f:
|
||||
with Path(ca_cert_path).open(encoding="utf-8") as f:
|
||||
ca_cert_content = f.read()
|
||||
cg.add(var.set_ca_certificate(ca_cert_content))
|
||||
else:
|
||||
|
||||
@@ -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: fixed to 16-bit stereo at configured sample rate
|
||||
# SPDIF mode: 16/24/32-bit audio and stereo at configured sample rate
|
||||
audio.set_stream_limits(
|
||||
min_bits_per_sample=16,
|
||||
max_bits_per_sample=16,
|
||||
max_bits_per_sample=32,
|
||||
min_channels=2,
|
||||
max_channels=2,
|
||||
min_sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
@@ -213,9 +213,6 @@ def _final_validate(config):
|
||||
)
|
||||
if config[CONF_CHANNEL] != CONF_STEREO:
|
||||
raise cv.Invalid("SPDIF mode only supports stereo channel configuration")
|
||||
# bits_per_sample is converted to float by the schema
|
||||
if config[CONF_BITS_PER_SAMPLE] != 16:
|
||||
raise cv.Invalid("SPDIF mode only supports 16 bits per sample")
|
||||
if not config[CONF_USE_APLL]:
|
||||
raise cv.Invalid(
|
||||
"SPDIF mode requires 'use_apll: true' for accurate clock generation"
|
||||
|
||||
@@ -138,21 +138,21 @@ void I2SAudioSpeakerSPDIF::run_speaker_task() {
|
||||
// Reset lockstep records queue so it starts paired with the (also-reset) i2s_event_queue_.
|
||||
xQueueReset(this->write_records_queue_);
|
||||
|
||||
const uint32_t dma_buffers_duration_ms = DMA_BUFFER_DURATION_MS * SPDIF_DMA_BUFFERS_COUNT;
|
||||
// Ensure ring buffer duration is at least the duration of all DMA buffers
|
||||
const uint32_t ring_buffer_duration = std::max(dma_buffers_duration_ms, this->buffer_duration_ms_);
|
||||
|
||||
// The DMA buffers may have more bits per sample, so calculate buffer sizes based on the input audio stream info
|
||||
const size_t bytes_per_frame = this->current_stream_info_.frames_to_bytes(1);
|
||||
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
|
||||
// avoids unnecessary single-frame splices.
|
||||
const size_t ring_buffer_size =
|
||||
(this->current_stream_info_.ms_to_bytes(ring_buffer_duration) / bytes_per_frame) * bytes_per_frame;
|
||||
|
||||
// For SPDIF mode, one DMA buffer = one SPDIF block = 192 PCM frames
|
||||
// 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.
|
||||
const uint32_t frames_to_fill_single_dma_buffer = SPDIF_BLOCK_SAMPLES;
|
||||
const size_t bytes_to_fill_single_dma_buffer =
|
||||
this->current_stream_info_.frames_to_bytes(frames_to_fill_single_dma_buffer);
|
||||
const size_t dma_buffers_floor_bytes = bytes_to_fill_single_dma_buffer * SPDIF_DMA_BUFFERS_COUNT;
|
||||
|
||||
// Round the ring buffer size down to a multiple of bytes_per_frame so the wrap boundary stays frame-aligned and
|
||||
// avoids unnecessary single-frame splices. Ensure it is at least large enough to cover all DMA buffers.
|
||||
const size_t requested_ring_buffer_bytes =
|
||||
(this->current_stream_info_.ms_to_bytes(this->buffer_duration_ms_) / bytes_per_frame) * bytes_per_frame;
|
||||
const size_t ring_buffer_size = std::max(dma_buffers_floor_bytes, requested_ring_buffer_bytes);
|
||||
|
||||
bool successful_setup = false;
|
||||
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
|
||||
@@ -177,7 +177,8 @@ 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++) {
|
||||
esp_err_t preload_err = this->spdif_encoder_->flush_with_silence(pdMS_TO_TICKS(DMA_BUFFER_DURATION_MS));
|
||||
// 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);
|
||||
if (preload_err != ESP_OK) {
|
||||
break; // DMA preload buffer full or error
|
||||
}
|
||||
@@ -410,8 +411,9 @@ 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;
|
||||
}
|
||||
if (audio_stream_info.get_bits_per_sample() != 16) {
|
||||
ESP_LOGE(TAG, "Only supports 16 bits per sample");
|
||||
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);
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
if (audio_stream_info.get_channels() != 2) {
|
||||
@@ -419,11 +421,8 @@ esp_err_t I2SAudioSpeakerSPDIF::start_i2s_driver(audio::AudioStreamInfo &audio_s
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
// 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->parent_->try_lock()) {
|
||||
ESP_LOGE(TAG, "Parent bus is busy");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include <driver/gpio.h>
|
||||
#include <driver/i2s_std.h>
|
||||
|
||||
#include "esphome/components/audio/audio.h"
|
||||
@@ -299,6 +300,15 @@ void I2SAudioSpeakerBase::stop_i2s_driver_() {
|
||||
i2s_channel_disable(this->tx_handle_);
|
||||
i2s_del_channel(this->tx_handle_);
|
||||
this->tx_handle_ = nullptr;
|
||||
|
||||
// i2s_del_channel() leaves dout wired to this port's data-out signal in the GPIO matrix: it only
|
||||
// clears an internal reservation mask, never the esp_rom_gpio_connect_out_signal() routing that
|
||||
// setup installed. If another speaker reuses this port (shared bus), its audio still reaches our
|
||||
// dout. Detach the pin and drive it low so a stale output stops driving downstream hardware: a
|
||||
// SPDIF optical transmitter would otherwise stay lit, and an analog DAC would emit noise.
|
||||
gpio_reset_pin(this->dout_pin_);
|
||||
gpio_set_direction(this->dout_pin_, GPIO_MODE_OUTPUT);
|
||||
gpio_set_level(this->dout_pin_, 0);
|
||||
}
|
||||
this->parent_->unlock();
|
||||
}
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
namespace esphome::i2s_audio {
|
||||
|
||||
// Shared constants used by both standard and SPDIF speaker implementations
|
||||
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
|
||||
static constexpr size_t TASK_STACK_SIZE = 4096;
|
||||
static constexpr ssize_t TASK_PRIORITY = 19;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace esphome::i2s_audio {
|
||||
|
||||
static const char *const TAG = "i2s_audio.speaker.std";
|
||||
|
||||
static constexpr uint32_t DMA_BUFFER_DURATION_MS = 15;
|
||||
static constexpr size_t DMA_BUFFERS_COUNT = 4;
|
||||
// Sized to comfortably absorb scheduling jitter: at most DMA_BUFFERS_COUNT events can be in flight,
|
||||
// doubled so that a transient backlog never overruns the queue (which would desync the lockstep
|
||||
|
||||
@@ -17,7 +17,7 @@ static constexpr uint8_t PREAMBLE_M = 0x1d; // Left channel (not block start)
|
||||
static constexpr uint8_t PREAMBLE_W = 0x1b; // Right channel
|
||||
|
||||
// BMC encoding of 4 zero bits starting at phase HIGH: 00_11_00_11 = 0x33
|
||||
// Since both aux nibbles (bits 4-7, 8-11) are zero for 16-bit audio and phase is preserved, both are 0x33.
|
||||
// Used as a constant in the 16-bit subframe path, where bits 4-11 are always zero.
|
||||
static constexpr uint32_t BMC_ZERO_NIBBLE = 0x33;
|
||||
|
||||
// Constexpr BMC encoder for compile-time LUT generation.
|
||||
@@ -36,21 +36,43 @@ static constexpr uint16_t bmc_lut_encode(uint32_t data, uint8_t num_bits) {
|
||||
return bmc;
|
||||
}
|
||||
|
||||
// 4-bit BMC lookup table: 16 entries (16 bytes in flash)
|
||||
// Index: 4-bit data value (0-15), always phase=true start
|
||||
// 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)
|
||||
static constexpr auto BMC_LUT_4 = [] {
|
||||
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));
|
||||
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;
|
||||
}
|
||||
return t;
|
||||
}();
|
||||
|
||||
// 8-bit BMC lookup table: 256 entries (512 bytes in flash)
|
||||
// Index: 8-bit data value (0-255), always phase=true start
|
||||
// 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)
|
||||
static constexpr auto BMC_LUT_8 = [] {
|
||||
std::array<uint16_t, 256> t{};
|
||||
for (uint32_t i = 0; i < 256; i++)
|
||||
t[i] = bmc_lut_encode(i, 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;
|
||||
}
|
||||
return t;
|
||||
}();
|
||||
|
||||
@@ -63,7 +85,7 @@ bool SPDIFEncoder::setup() {
|
||||
}
|
||||
ESP_LOGV(TAG, "Buffer allocated (%zu bytes)", SPDIF_BLOCK_SIZE_BYTES);
|
||||
|
||||
// Build initial channel status block with default sample rate
|
||||
// Build initial channel status block with default sample rate and width
|
||||
this->build_channel_status_();
|
||||
|
||||
this->reset();
|
||||
@@ -73,7 +95,7 @@ bool SPDIFEncoder::setup() {
|
||||
void SPDIFEncoder::reset() {
|
||||
this->spdif_block_ptr_ = this->spdif_block_buf_.get();
|
||||
this->frame_in_block_ = 0;
|
||||
this->is_left_channel_ = true;
|
||||
this->block_buf_is_silence_block_ = false;
|
||||
}
|
||||
|
||||
void SPDIFEncoder::set_sample_rate(uint32_t sample_rate) {
|
||||
@@ -84,31 +106,27 @@ 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
|
||||
//
|
||||
// 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)
|
||||
// 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;
|
||||
|
||||
// Clear all bytes first
|
||||
this->channel_status_.fill(0);
|
||||
|
||||
// Byte 0: Consumer, PCM audio, no copyright, no pre-emphasis, Mode 0
|
||||
@@ -140,132 +158,148 @@ 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
|
||||
|
||||
// Bytes 4-23 remain zero (word length not specified, no original sample freq, etc.)
|
||||
// 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;
|
||||
}
|
||||
this->channel_status_[4] = word_length_code;
|
||||
}
|
||||
|
||||
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
|
||||
// ============================================================================
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
// ============================================================================
|
||||
// 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
|
||||
// ============================================================================
|
||||
|
||||
// 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);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
|
||||
// ============================================================================
|
||||
// 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;
|
||||
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);
|
||||
} else {
|
||||
preamble = PREAMBLE_W;
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
this->is_left_channel_ = !this->is_left_channel_;
|
||||
}
|
||||
|
||||
esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
|
||||
@@ -295,79 +329,162 @@ esp_err_t SPDIFEncoder::send_block_(TickType_t ticks_to_wait) {
|
||||
return err;
|
||||
}
|
||||
|
||||
size_t SPDIFEncoder::get_pending_pcm_bytes() const {
|
||||
if (this->spdif_block_ptr_ == nullptr || this->spdif_block_buf_ == nullptr) {
|
||||
return 0;
|
||||
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;
|
||||
}
|
||||
// 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
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const uint8_t *pcm_data = src;
|
||||
const uint8_t *pcm_end = src + size;
|
||||
uint32_t block_count = 0;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
// Encode one 16-bit sample
|
||||
this->encode_sample_(pcm_data);
|
||||
pcm_data += 2;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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>();
|
||||
}
|
||||
++block_count;
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (blocks_sent != nullptr) {
|
||||
*blocks_sent = block_count;
|
||||
}
|
||||
if (bytes_consumed != nullptr) {
|
||||
*bytes_consumed = size;
|
||||
}
|
||||
return ESP_OK;
|
||||
return this->send_block_(ticks_to_wait);
|
||||
}
|
||||
|
||||
esp_err_t SPDIFEncoder::flush_with_silence(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]) {
|
||||
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);
|
||||
}
|
||||
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;
|
||||
}
|
||||
return this->send_block_(ticks_to_wait);
|
||||
}
|
||||
|
||||
} // namespace esphome::i2s_audio
|
||||
|
||||
@@ -24,8 +24,6 @@ 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
|
||||
@@ -64,8 +62,16 @@ 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 (16-bit stereo)
|
||||
/// @param src Source PCM audio data (stereo, width matches set_bytes_per_sample)
|
||||
/// @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
|
||||
@@ -74,17 +80,6 @@ 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
|
||||
@@ -95,7 +90,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, 96000)
|
||||
/// @param sample_rate Sample rate in Hz (e.g., 44100, 48000)
|
||||
/// Call this before writing audio data to ensure correct channel status.
|
||||
void set_sample_rate(uint32_t sample_rate);
|
||||
|
||||
@@ -103,8 +98,19 @@ class SPDIFEncoder {
|
||||
uint32_t get_sample_rate() const { return this->sample_rate_; }
|
||||
|
||||
protected:
|
||||
/// @brief Encode a single 16-bit PCM sample into the current block position
|
||||
HOT void encode_sample_(const uint8_t *pcm_sample);
|
||||
/// @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 Send the completed block via the appropriate callback
|
||||
esp_err_t send_block_(TickType_t ticks_to_wait);
|
||||
@@ -112,15 +118,6 @@ 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)
|
||||
@@ -133,9 +130,13 @@ 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 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
|
||||
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};
|
||||
|
||||
// Channel Status Block (192 bits = 24 bytes, transmitted over 192 frames)
|
||||
// Placed last since std::array<uint8_t> has 1-byte alignment
|
||||
|
||||
@@ -395,7 +395,7 @@ def download_image(value):
|
||||
def is_svg_file(file):
|
||||
if not file:
|
||||
return False
|
||||
with open(file, "rb") as f:
|
||||
with Path(file).open("rb") as f:
|
||||
return "<svg" in str(f.read(1024))
|
||||
|
||||
|
||||
@@ -408,7 +408,7 @@ def validate_file_shorthand(value):
|
||||
raise cv.Invalid(f"Could not parse mdi icon name from '{value}'.")
|
||||
return download_gh_svg(parts[1], parts[0])
|
||||
|
||||
if value.startswith("http://") or value.startswith("https://"):
|
||||
if value.startswith(("http://", "https://")):
|
||||
return download_image(value)
|
||||
|
||||
value = cv.file_(value)
|
||||
|
||||
@@ -513,13 +513,13 @@ async def component_to_code(config):
|
||||
|
||||
# apply LibreTiny options from framework: block
|
||||
# setup LT logger to work nicely with ESPHome logger
|
||||
lt_options = dict(
|
||||
LT_LOGLEVEL="LT_LEVEL_" + framework[CONF_LOGLEVEL],
|
||||
LT_LOGGER_CALLER=0,
|
||||
LT_LOGGER_TASK=0,
|
||||
LT_LOGGER_COLOR=1,
|
||||
LT_USE_TIME=1,
|
||||
)
|
||||
lt_options = {
|
||||
"LT_LOGLEVEL": "LT_LEVEL_" + framework[CONF_LOGLEVEL],
|
||||
"LT_LOGGER_CALLER": 0,
|
||||
"LT_LOGGER_TASK": 0,
|
||||
"LT_LOGGER_COLOR": 1,
|
||||
"LT_USE_TIME": 1,
|
||||
}
|
||||
# enable/disable per-module debugging
|
||||
for module in framework[CONF_DEBUG]:
|
||||
if module == "NONE":
|
||||
|
||||
@@ -11,11 +11,19 @@
|
||||
#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).
|
||||
// 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.
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
@@ -26,13 +34,7 @@
|
||||
// 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,14 +6,22 @@ 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).
|
||||
# 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.
|
||||
#
|
||||
# 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:
|
||||
# - RTL8710B: hal.h uses section(".image2.ram.text"); stock linker consumes it.
|
||||
# - RTL8720C: hal.h uses section(".sram.text"); stock linker consumes it.
|
||||
# - 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.
|
||||
# - LN882H: stock linker has no glob for ".sram.text", so we inject
|
||||
# KEEP(*(.sram.text*)) into ".flash_copysection" (> RAM0 AT> FLASH).
|
||||
# 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.
|
||||
#
|
||||
# All families also get a post-link summary showing where IRAM_ATTR landed.
|
||||
|
||||
@@ -27,7 +35,25 @@ _KEEP_LINE = (
|
||||
"__esphome_sram_text_end = .; "
|
||||
+ _MARKER + "\n"
|
||||
)
|
||||
_LN_COPY = re.compile(r"(\.flash_copysection\s*:\s*\{\s*\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)")
|
||||
|
||||
|
||||
def _detect(env):
|
||||
@@ -56,7 +82,7 @@ KNOWN_VARIANTS = frozenset({
|
||||
|
||||
|
||||
def _inject_keep(host_section):
|
||||
"""Return a patcher that injects _KEEP_LINE at the top of `host_section`."""
|
||||
"""Return a patcher that injects _KEEP_LINE after `host_section` match."""
|
||||
def patch(content):
|
||||
if _MARKER in content:
|
||||
return content
|
||||
@@ -65,12 +91,11 @@ def _inject_keep(host_section):
|
||||
|
||||
|
||||
# Variants not listed here intentionally have no .ld patcher:
|
||||
# - 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*).
|
||||
# - RTL8720C: stock linker already consumes *(.sram.text*) into .ram.code_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),),
|
||||
}
|
||||
|
||||
|
||||
@@ -81,13 +106,14 @@ 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 = 0
|
||||
patched = []
|
||||
unpatched = []
|
||||
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 += 1
|
||||
patched.append(name)
|
||||
continue
|
||||
content = original
|
||||
for fn in _patchers:
|
||||
@@ -96,7 +122,9 @@ 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 += 1
|
||||
patched.append(name)
|
||||
else:
|
||||
unpatched.append(name)
|
||||
if not patched:
|
||||
raise RuntimeError(
|
||||
"ESPHome: no .ld in {} was patched for IRAM_ATTR. Update the "
|
||||
@@ -104,6 +132,20 @@ def _pre_link(target, source, env):
|
||||
build_dir
|
||||
)
|
||||
)
|
||||
# Every .ld in the build must be patched. RTL8710B generates one .ld per
|
||||
# OTA slot (xip1, xip2); if only one matches, the unpatched slot would
|
||||
# ship with IRAM_ATTR code dropped to zeros and brick the device on the
|
||||
# boot after an OTA into that slot.
|
||||
if unpatched:
|
||||
raise RuntimeError(
|
||||
"ESPHome: {} of {} .ld file(s) in {} were not patched for "
|
||||
"IRAM_ATTR: {}. The regex in patch_linker.py.script "
|
||||
"(_PATCHERS_BY_VARIANT[{!r}]) matched the others but not "
|
||||
"these. Update the regex to cover all linker scripts.".format(
|
||||
len(unpatched), len(ld_files), build_dir,
|
||||
", ".join(unpatched), _variant,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Substrings matched against demangled names as a fallback on RTL8720C,
|
||||
|
||||
@@ -86,10 +86,22 @@ class EffectRef:
|
||||
component_path: list[str | int] # path_context when the action was validated
|
||||
|
||||
|
||||
@dataclass
|
||||
class EffectCycleRef:
|
||||
"""A pending light.effect.next/previous action to validate.
|
||||
|
||||
Records that the referenced light needs at least one effect configured.
|
||||
"""
|
||||
|
||||
light_id: ID
|
||||
component_path: list[str | int]
|
||||
|
||||
|
||||
@dataclass
|
||||
class LightData:
|
||||
gamma_tables: dict = field(default_factory=dict) # gamma_value -> fwd_arr
|
||||
effect_refs: list[EffectRef] = field(default_factory=list)
|
||||
effect_cycle_refs: list[EffectCycleRef] = field(default_factory=list)
|
||||
|
||||
|
||||
def _get_data() -> LightData:
|
||||
@@ -160,13 +172,15 @@ def _final_validate(config: ConfigType) -> ConfigType:
|
||||
this never runs — but the ID validator will catch the missing light ID separately.
|
||||
"""
|
||||
data = _get_data()
|
||||
if not data.effect_refs:
|
||||
if not data.effect_refs and not data.effect_cycle_refs:
|
||||
return config
|
||||
|
||||
# Drain the list so we only validate once even though
|
||||
# Drain the lists so we only validate once even though
|
||||
# FINAL_VALIDATE_SCHEMA runs for each light platform instance.
|
||||
refs = data.effect_refs
|
||||
data.effect_refs = []
|
||||
cycle_refs = data.effect_cycle_refs
|
||||
data.effect_cycle_refs = []
|
||||
|
||||
fconf = fv.full_config.get()
|
||||
|
||||
@@ -188,6 +202,21 @@ def _final_validate(config: ConfigType) -> ConfigType:
|
||||
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
|
||||
)
|
||||
|
||||
for ref in cycle_refs:
|
||||
try:
|
||||
light_path = fconf.get_path_for_id(ref.light_id)[:-1]
|
||||
light_config = fconf.get_config_for_path(light_path)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
if not light_config.get(CONF_EFFECTS):
|
||||
raise cv.FinalExternalInvalid(
|
||||
f"Light '{ref.light_id}' has no effects configured, but a "
|
||||
f"'light.effect.next' or 'light.effect.previous' action "
|
||||
f"references it. Add at least one effect to the light.",
|
||||
path=[cv.ROOT_CONFIG_PATH] + ref.component_path,
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
|
||||
@@ -104,6 +104,47 @@ template<bool HasTransitionLength, typename... Ts> class DimRelativeAction : pub
|
||||
transition_length_{};
|
||||
};
|
||||
|
||||
// Cycle through the light's configured effects. `Forward` selects direction
|
||||
// at compile time so the chosen branch is the only one that gets instantiated
|
||||
// per action site. `include_none` is runtime so a single set of templates
|
||||
// covers both the "wrap through None" and "skip None" variants.
|
||||
template<bool Forward, typename... Ts> class LightEffectCycleAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit LightEffectCycleAction(LightState *parent) : parent_(parent) {}
|
||||
|
||||
void set_include_none(bool include_none) { this->include_none_ = include_none; }
|
||||
|
||||
void play(const Ts &...) override {
|
||||
size_t count = this->parent_->get_effect_count();
|
||||
if (count == 0) {
|
||||
return;
|
||||
}
|
||||
uint32_t current = this->parent_->get_current_effect_index();
|
||||
uint32_t next;
|
||||
if (this->include_none_) {
|
||||
uint32_t total = static_cast<uint32_t>(count) + 1;
|
||||
if constexpr (Forward) {
|
||||
next = (current + 1) % total;
|
||||
} else {
|
||||
next = (current + total - 1) % total;
|
||||
}
|
||||
} else {
|
||||
if constexpr (Forward) {
|
||||
next = (current % static_cast<uint32_t>(count)) + 1;
|
||||
} else {
|
||||
next = (current <= 1) ? static_cast<uint32_t>(count) : current - 1;
|
||||
}
|
||||
}
|
||||
auto call = this->parent_->turn_on();
|
||||
call.set_effect(next);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
protected:
|
||||
LightState *parent_;
|
||||
bool include_none_{false};
|
||||
};
|
||||
|
||||
template<typename... Ts> class LightIsOnCondition : public Condition<Ts...> {
|
||||
public:
|
||||
explicit LightIsOnCondition(LightState *state) : state_(state) {}
|
||||
|
||||
@@ -26,8 +26,8 @@ from esphome.const import (
|
||||
CONF_WARM_WHITE,
|
||||
CONF_WHITE,
|
||||
)
|
||||
from esphome.core import CORE, EsphomeError, Lambda
|
||||
from esphome.cpp_generator import LambdaExpression
|
||||
from esphome.core import CORE, ID, EsphomeError, Lambda
|
||||
from esphome.cpp_generator import LambdaExpression, MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .types import (
|
||||
@@ -39,12 +39,15 @@ from .types import (
|
||||
DimRelativeAction,
|
||||
LightCall,
|
||||
LightControlAction,
|
||||
LightEffectCycleAction,
|
||||
LightIsOffCondition,
|
||||
LightIsOnCondition,
|
||||
LightState,
|
||||
ToggleAction,
|
||||
)
|
||||
|
||||
CONF_INCLUDE_NONE = "include_none"
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.toggle",
|
||||
@@ -253,6 +256,75 @@ async def light_control_to_code(config, action_id, template_arg, args):
|
||||
return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda)
|
||||
|
||||
|
||||
def _record_effect_cycle_ref(config: ConfigType) -> ConfigType:
|
||||
"""Record a cycle-action reference for later validation against the target light."""
|
||||
from . import EffectCycleRef, _get_data
|
||||
|
||||
_get_data().effect_cycle_refs.append(
|
||||
EffectCycleRef(
|
||||
light_id=config[CONF_ID],
|
||||
component_path=path_context.get(),
|
||||
)
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_ID): cv.use_id(LightState),
|
||||
cv.Optional(CONF_INCLUDE_NONE, default=False): cv.boolean,
|
||||
}
|
||||
)
|
||||
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA.add_extra(_record_effect_cycle_ref)
|
||||
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA = automation.maybe_simple_id(
|
||||
LIGHT_EFFECT_CYCLE_ACTION_BASE_SCHEMA
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.effect.next",
|
||||
LightEffectCycleAction,
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def light_effect_next_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
return await _light_effect_cycle_to_code(config, action_id, template_arg, True)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"light.effect.previous",
|
||||
LightEffectCycleAction,
|
||||
LIGHT_EFFECT_CYCLE_ACTION_SCHEMA,
|
||||
synchronous=True,
|
||||
)
|
||||
async def light_effect_previous_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
return await _light_effect_cycle_to_code(config, action_id, template_arg, False)
|
||||
|
||||
|
||||
async def _light_effect_cycle_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
forward: bool,
|
||||
) -> MockObj:
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
cycle_template_arg = cg.TemplateArguments(forward, *template_arg)
|
||||
var = cg.new_Pvariable(action_id, cycle_template_arg, paren)
|
||||
cg.add(var.set_include_none(config[CONF_INCLUDE_NONE]))
|
||||
return var
|
||||
|
||||
|
||||
CONF_RELATIVE_BRIGHTNESS = "relative_brightness"
|
||||
LIGHT_DIM_RELATIVE_ACTION_SCHEMA = cv.Schema(
|
||||
{
|
||||
|
||||
@@ -39,6 +39,7 @@ LIMIT_MODES = {
|
||||
# Actions
|
||||
ToggleAction = light_ns.class_("ToggleAction", automation.Action)
|
||||
LightControlAction = light_ns.class_("LightControlAction", automation.Action)
|
||||
LightEffectCycleAction = light_ns.class_("LightEffectCycleAction", automation.Action)
|
||||
DimRelativeAction = light_ns.class_("DimRelativeAction", automation.Action)
|
||||
AddressableSet = light_ns.class_("AddressableSet", automation.Action)
|
||||
LightIsOnCondition = light_ns.class_("LightIsOnCondition", automation.Condition)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import functools
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import pkgutil
|
||||
@@ -55,6 +56,7 @@ from .automation import layers_to_code, lvgl_update
|
||||
from .defines import (
|
||||
CONF_ALIGN_TO_LAMBDA_ID,
|
||||
LOGGER,
|
||||
add_lv_use,
|
||||
get_focused_widgets,
|
||||
get_lv_images_used,
|
||||
get_refreshed_widgets,
|
||||
@@ -71,13 +73,14 @@ 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,
|
||||
WIDGET_TYPES,
|
||||
any_widget_schema,
|
||||
container_schema,
|
||||
obj_schema,
|
||||
obj_dict,
|
||||
)
|
||||
from .styles import styles_to_code, theme_to_code
|
||||
from .touchscreens import touchscreen_schema, touchscreens_to_code
|
||||
@@ -100,6 +103,7 @@ from .widgets import (
|
||||
get_screen_active,
|
||||
set_obj_properties,
|
||||
)
|
||||
from .widgets.img import CONF_IMAGE
|
||||
|
||||
# Import only what we actually use directly in this file
|
||||
from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code
|
||||
@@ -170,7 +174,7 @@ def generate_lv_conf_h():
|
||||
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)),
|
||||
sorted(clashes),
|
||||
)
|
||||
unused_defines = all_defines - lv_defines.keys() - defines_from_flags
|
||||
|
||||
@@ -433,6 +437,8 @@ async def to_code(configs):
|
||||
|
||||
# This must be done after all widgets are created
|
||||
styles_used = df.get_styles_used()
|
||||
if any(BASE_PROPS.get(x) is lvalid.lv_image for x in styles_used):
|
||||
add_lv_use(CONF_IMAGE)
|
||||
for use in df.get_lv_uses():
|
||||
df.add_define(f"LV_USE_{use.upper()}")
|
||||
cg.add_define(f"USE_LVGL_{use.upper()}")
|
||||
@@ -513,16 +519,32 @@ def add_hello_world(config):
|
||||
return config
|
||||
|
||||
|
||||
def _theme_schema(value):
|
||||
@functools.cache
|
||||
def _build_theme_schema(
|
||||
widget_types: tuple[tuple[str, widgets.WidgetType], ...],
|
||||
) -> cv.Schema:
|
||||
# The theme schema is value-independent: it depends only on the set of
|
||||
# registered widget types. Key the cache on a snapshot of WIDGET_TYPES so
|
||||
# that an external component registering a new widget after the first
|
||||
# validation (legal per any_widget_schema's lazy-evaluation contract)
|
||||
# produces a fresh tuple, a cache miss, and a rebuilt schema -- the cache
|
||||
# self-heals instead of stale-rejecting valid themes. See obj_dict() in
|
||||
# schemas.py for why chained .extend() is avoided here.
|
||||
return cv.Schema(
|
||||
{
|
||||
cv.Optional(df.CONF_DARK_MODE, default=False): cv.boolean,
|
||||
**{
|
||||
cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA)
|
||||
for name, w in WIDGET_TYPES.items()
|
||||
cv.Optional(name): cv.Schema(
|
||||
{**obj_dict(w), **FULL_STYLE_SCHEMA.schema}
|
||||
)
|
||||
for name, w in widget_types
|
||||
},
|
||||
}
|
||||
)(value)
|
||||
)
|
||||
|
||||
|
||||
def _theme_schema(value: dict) -> dict:
|
||||
return _build_theme_schema(tuple(WIDGET_TYPES.items()))(value)
|
||||
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validation
|
||||
|
||||
@@ -335,7 +335,7 @@ TYPE_NONE = "none"
|
||||
|
||||
DIRECTIONS = LvConstant("LV_DIR_", "LEFT", "RIGHT", "BOTTOM", "TOP")
|
||||
|
||||
LV_FONTS = list(f"montserrat_{s}" for s in range(8, 50, 2)) + [
|
||||
LV_FONTS = [f"montserrat_{s}" for s in range(8, 50, 2)] + [
|
||||
"dejavu_16_persian_hebrew",
|
||||
"simsun_16_cjk",
|
||||
"unscii_8",
|
||||
|
||||
@@ -239,7 +239,7 @@ def color_retmapper(value):
|
||||
else:
|
||||
r, g, b, _ = from_rgbw(cval)
|
||||
return literal(f"lv_color_make({r}, {g}, {b})")
|
||||
assert False
|
||||
raise AssertionError(f"Unhandled lv_color value: {value!r}")
|
||||
|
||||
|
||||
def option_string(value):
|
||||
|
||||
@@ -74,11 +74,11 @@ inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) {
|
||||
lv_style_set_text_font(style, font->get_lv_font());
|
||||
}
|
||||
#endif
|
||||
#if defined(USE_LVGL_IMAGE) && defined(USE_IMAGE)
|
||||
#if LV_USE_IMAGE
|
||||
|
||||
#ifdef USE_IMAGE
|
||||
#ifdef USE_LVGL_IMAGE
|
||||
// Shortcut / overload, so that the source of an image widget can easily be updated from within a lambda.
|
||||
inline void lv_image_set_src(lv_obj_t *obj, image::Image *image) { ::lv_image_set_src(obj, image->get_lv_image_dsc()); }
|
||||
#endif // LV_USE_IMAGE
|
||||
|
||||
inline void lv_obj_set_style_bitmap_mask_src(lv_obj_t *obj, image::Image *image, lv_style_selector_t selector) {
|
||||
::lv_obj_set_style_bitmap_mask_src(obj, image->get_lv_image_dsc(), selector);
|
||||
@@ -93,7 +93,8 @@ inline void lv_style_set_bg_image_src(lv_style_t *style, image::Image *image) {
|
||||
inline void lv_style_set_bitmap_mask_src(lv_style_t *style, image::Image *image) {
|
||||
::lv_style_set_bitmap_mask_src(style, image->get_lv_image_dsc());
|
||||
}
|
||||
#endif // USE_LVGL_IMAGE
|
||||
#endif
|
||||
|
||||
#ifdef USE_LVGL_ANIMIMG
|
||||
inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images) {
|
||||
auto *dsc = static_cast<std::vector<lv_image_dsc_t *> *>(lv_obj_get_user_data(img));
|
||||
@@ -109,6 +110,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
|
||||
lv_animimg_set_src(img, (const void **) dsc->data(), dsc->size());
|
||||
}
|
||||
#endif // USE_LVGL_ANIMIMG
|
||||
#endif // USE_IMAGE
|
||||
|
||||
#ifdef USE_LVGL_METER
|
||||
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.automation import Trigger, validate_automation
|
||||
@@ -21,6 +22,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import TimePeriod
|
||||
from esphome.core.config import StartupTrigger
|
||||
from esphome.schema_extractors import EnableSchemaExtraction
|
||||
|
||||
from . import defines as df, lv_validation as lvalid
|
||||
from .defines import (
|
||||
@@ -377,18 +379,63 @@ TRIGGER_EVENT_MAP = {
|
||||
}
|
||||
|
||||
|
||||
def part_schema(parts):
|
||||
def part_dict(parts: tuple[str, ...] | list[str]) -> dict[Any, Any]:
|
||||
"""
|
||||
Return the raw mapping used by part_schema, so callers can merge it into a
|
||||
larger dict and avoid chained .extend() calls (each .extend() recompiles the
|
||||
whole mapping, turning the build into O(N^2)).
|
||||
|
||||
Invariant: the source schemas spread here (STATE_SCHEMA, FLAG_SCHEMA, the
|
||||
nested STATE_SCHEMA values) must use the default extra=PREVENT_EXTRA and
|
||||
required=False and must not register any add_extra/prepend_extra
|
||||
validators. Reaching into .schema and rebuilding via cv.Schema(...) keeps
|
||||
only the mapping; non-default extra/required and any _extra_schemas would
|
||||
be silently dropped.
|
||||
"""
|
||||
return {
|
||||
**STATE_SCHEMA.schema,
|
||||
**FLAG_SCHEMA.schema,
|
||||
**{cv.Optional(part): STATE_SCHEMA for part in parts},
|
||||
}
|
||||
|
||||
|
||||
def part_schema(parts: tuple[str, ...] | list[str]) -> cv.Schema:
|
||||
"""
|
||||
Generate a schema for the various parts (e.g. main:, indicator:) of a widget type
|
||||
:param parts: The parts to include
|
||||
:return: The schema
|
||||
"""
|
||||
return STATE_SCHEMA.extend(FLAG_SCHEMA).extend(
|
||||
{cv.Optional(part): STATE_SCHEMA for part in parts}
|
||||
)
|
||||
return cv.Schema(part_dict(parts))
|
||||
|
||||
|
||||
def automation_schema(typ: LvType):
|
||||
def _lazy_validate_automation(extra_schema: dict) -> Callable[[Any], Any]:
|
||||
"""Return a validator that defers building the validate_automation schema.
|
||||
|
||||
validate_automation() runs AUTOMATION_SCHEMA.extend(extra_schema), which
|
||||
voluptuous compiles eagerly. automation_schema() builds ~60 of these per
|
||||
widget type, and the vast majority of slots are never invoked by a given
|
||||
user config. Deferring the build to first use removes that work from
|
||||
schema-construction time.
|
||||
|
||||
When EnableSchemaExtraction is set (build_language_schema.py), fall back
|
||||
to eager construction so the @schema_extractor("automation") decoration
|
||||
inside validate_automation is registered.
|
||||
"""
|
||||
if EnableSchemaExtraction:
|
||||
return validate_automation(extra_schema)
|
||||
|
||||
cached: Callable[[Any], Any] | None = None
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
nonlocal cached
|
||||
if cached is None:
|
||||
cached = validate_automation(extra_schema)
|
||||
return cached(value)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def automation_schema(typ: LvType) -> dict[Any, Any]:
|
||||
events = df.LV_EVENT_TRIGGERS + df.SWIPE_TRIGGERS
|
||||
if typ.has_on_value:
|
||||
events = events + (CONF_ON_VALUE, CONF_ON_UPDATE)
|
||||
@@ -403,7 +450,7 @@ def automation_schema(typ: LvType):
|
||||
|
||||
return {
|
||||
**{
|
||||
cv.Optional(event): validate_automation(
|
||||
cv.Optional(event): _lazy_validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
Trigger.template(*get_trigger_args(event))
|
||||
@@ -412,7 +459,7 @@ def automation_schema(typ: LvType):
|
||||
)
|
||||
for event in events
|
||||
},
|
||||
cv.Optional(CONF_ON_BOOT): validate_automation(
|
||||
cv.Optional(CONF_ON_BOOT): _lazy_validate_automation(
|
||||
{cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger)}
|
||||
),
|
||||
}
|
||||
@@ -461,23 +508,62 @@ def base_update_schema(widget_type: WidgetType | LvType, parts):
|
||||
return schema
|
||||
|
||||
|
||||
def obj_schema(widget_type: WidgetType):
|
||||
# Memoize obj_dict() the same way _OBJ_SCHEMA_CACHE memoizes obj_schema().
|
||||
# automation_schema(w.w_type) builds fresh Trigger.template(...) objects on
|
||||
# every call, so without this cache _theme_schema pays that cost per widget
|
||||
# per validation. Callers must treat the returned dict as immutable. The
|
||||
# _theme_schema caller spreads it into a fresh dict, which is safe; the
|
||||
# obj_schema caller passes it directly to cv.Schema(...) -- voluptuous stores
|
||||
# the mapping by reference but never mutates it (.extend() copies first), so
|
||||
# the alias is also safe today. Adding in-place mutation of obj_schema(w).schema
|
||||
# would corrupt this cache.
|
||||
_OBJ_DICT_CACHE: dict[int, tuple[WidgetType, dict[Any, Any]]] = {}
|
||||
|
||||
|
||||
def obj_dict(widget_type: WidgetType) -> dict[Any, Any]:
|
||||
"""
|
||||
Return the raw mapping used by obj_schema, so callers can merge it into a
|
||||
larger dict and avoid chained .extend() calls.
|
||||
|
||||
Inherits the same source-schema invariant documented on part_dict: any
|
||||
schema spread into this mapping must use the default extra=PREVENT_EXTRA
|
||||
and required=False and must carry no add_extra/prepend_extra validators.
|
||||
|
||||
The returned mapping is cached and must be treated as immutable by callers.
|
||||
"""
|
||||
cached = _OBJ_DICT_CACHE.get(id(widget_type))
|
||||
if cached is not None and cached[0] is widget_type:
|
||||
return cached[1]
|
||||
built = {
|
||||
**part_dict(widget_type.parts),
|
||||
**ALIGN_TO_SCHEMA,
|
||||
**automation_schema(widget_type.w_type),
|
||||
cv.Optional(CONF_STATE): SET_STATE_SCHEMA,
|
||||
cv.Optional(CONF_GROUP): cv.use_id(lv_group_t),
|
||||
}
|
||||
_OBJ_DICT_CACHE[id(widget_type)] = (widget_type, built)
|
||||
return built
|
||||
|
||||
|
||||
# Widget types are module-level singletons populated at import time, so we
|
||||
# can cache compiled obj_schemas by widget_type identity for the lifetime of
|
||||
# the process. The strong reference in the value keeps the key (an id()
|
||||
# target) from being recycled.
|
||||
_OBJ_SCHEMA_CACHE: dict[int, tuple[WidgetType, cv.Schema]] = {}
|
||||
|
||||
|
||||
def obj_schema(widget_type: WidgetType) -> cv.Schema:
|
||||
"""
|
||||
Create a schema for a widget type itself i.e. no allowance for children
|
||||
:param widget_type:
|
||||
:return:
|
||||
"""
|
||||
return (
|
||||
part_schema(widget_type.parts)
|
||||
.extend(ALIGN_TO_SCHEMA)
|
||||
.extend(automation_schema(widget_type.w_type))
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_STATE): SET_STATE_SCHEMA,
|
||||
cv.Optional(CONF_GROUP): cv.use_id(lv_group_t),
|
||||
}
|
||||
)
|
||||
)
|
||||
cached = _OBJ_SCHEMA_CACHE.get(id(widget_type))
|
||||
if cached is not None and cached[0] is widget_type:
|
||||
return cached[1]
|
||||
schema = cv.Schema(obj_dict(widget_type))
|
||||
_OBJ_SCHEMA_CACHE[id(widget_type)] = (widget_type, schema)
|
||||
return schema
|
||||
|
||||
|
||||
ALIGN_TO_SCHEMA = {
|
||||
@@ -534,7 +620,16 @@ def strip_defaults(schema: cv.Schema):
|
||||
return cv.Schema({cv.Optional(k): v for k, v in schema.schema.items()})
|
||||
|
||||
|
||||
def container_schema(widget_type: WidgetType, extras=None):
|
||||
# Keyed by (id(widget_type), id(extras)); strong refs in the value keep both
|
||||
# alive so id() can't be recycled.
|
||||
_CONTAINER_SCHEMA_CACHE: dict[
|
||||
tuple[int, int], tuple[Any, Any, Callable[[Any], Any]]
|
||||
] = {}
|
||||
|
||||
|
||||
def container_schema(
|
||||
widget_type: WidgetType, extras: Any = None
|
||||
) -> Callable[[Any], Any]:
|
||||
"""
|
||||
Create a schema for a container widget of a given type. All obj properties are available, plus
|
||||
the extras passed in, plus any defined for the specific widget being specified.
|
||||
@@ -542,19 +637,31 @@ def container_schema(widget_type: WidgetType, extras=None):
|
||||
:param extras: Additional options to be made available, e.g. layout properties for children
|
||||
:return: The schema for this type of widget.
|
||||
"""
|
||||
schema = obj_schema(widget_type).extend(
|
||||
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
|
||||
)
|
||||
if extras:
|
||||
schema = schema.extend(extras)
|
||||
# Delayed evaluation for recursion
|
||||
cache_key = (id(widget_type), id(extras))
|
||||
cached = _CONTAINER_SCHEMA_CACHE.get(cache_key)
|
||||
if cached is not None:
|
||||
cached_widget_type, cached_extras, cached_validator = cached
|
||||
if cached_widget_type is widget_type and cached_extras is extras:
|
||||
return cached_validator
|
||||
|
||||
schema = schema.extend(widget_type.schema)
|
||||
cached_schema: cv.Schema | None = None
|
||||
|
||||
def validator(value):
|
||||
def get_schema() -> cv.Schema:
|
||||
nonlocal cached_schema
|
||||
if cached_schema is None:
|
||||
schema = obj_schema(widget_type).extend(
|
||||
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
|
||||
)
|
||||
if extras:
|
||||
schema = schema.extend(extras)
|
||||
cached_schema = schema.extend(widget_type.schema)
|
||||
return cached_schema
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
value = value or {}
|
||||
return append_layout_schema(schema, value)(value)
|
||||
return append_layout_schema(get_schema(), value)(value)
|
||||
|
||||
_CONTAINER_SCHEMA_CACHE[cache_key] = (widget_type, extras, validator)
|
||||
return validator
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from .defines import (
|
||||
CONF_THEME,
|
||||
LValidator,
|
||||
add_lv_use,
|
||||
get_styles_used,
|
||||
get_theme_widget_map,
|
||||
literal,
|
||||
)
|
||||
@@ -25,6 +26,7 @@ def has_style_props(config) -> bool:
|
||||
async def style_set(svar, style):
|
||||
for prop, validator in ALL_STYLES.items():
|
||||
if (value := style.get(prop)) is not None:
|
||||
get_styles_used().add(prop)
|
||||
if isinstance(validator, LValidator):
|
||||
value = await validator.process(value)
|
||||
if isinstance(value, list):
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from collections.abc import Callable
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from esphome import codegen as cg, config_validation as cv
|
||||
from esphome.automation import register_action
|
||||
@@ -15,6 +17,7 @@ from esphome.const import (
|
||||
from esphome.core import ID, EsphomeError, TimePeriod
|
||||
from esphome.coroutine import FakeAwaitable
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.schema_extractors import EnableSchemaExtraction
|
||||
from esphome.types import Expression
|
||||
|
||||
from ..defines import (
|
||||
@@ -73,6 +76,34 @@ from ..types import (
|
||||
EVENT_LAMB = "event_lamb__"
|
||||
|
||||
|
||||
def _build_update_schema(widget_type: "WidgetType") -> Schema:
|
||||
# Local import: ..schemas imports WidgetType from this module.
|
||||
from ..schemas import base_update_schema
|
||||
|
||||
return base_update_schema(widget_type, widget_type.parts).extend(
|
||||
widget_type.modify_schema
|
||||
)
|
||||
|
||||
|
||||
def _update_action_schema(
|
||||
widget_type: "WidgetType",
|
||||
) -> Schema | Callable[[Any], Any]:
|
||||
# Eager when extracting so build_language_schema.py sees the mapping;
|
||||
# lazy otherwise to skip ~200 ms of import-time voluptuous work.
|
||||
if EnableSchemaExtraction:
|
||||
return _build_update_schema(widget_type)
|
||||
|
||||
cached: Schema | None = None
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
nonlocal cached
|
||||
if cached is None:
|
||||
cached = _build_update_schema(widget_type)
|
||||
return cached(value)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
class WidgetType:
|
||||
"""
|
||||
Describes a type of Widget, e.g. "bar" or "line"
|
||||
@@ -113,18 +144,17 @@ class WidgetType:
|
||||
|
||||
# Local import to avoid circular import
|
||||
from ..automation import update_to_code
|
||||
from ..schemas import WIDGET_TYPES, base_update_schema
|
||||
from ..schemas import WIDGET_TYPES
|
||||
|
||||
if not is_mock:
|
||||
if self.name in WIDGET_TYPES:
|
||||
raise EsphomeError(f"Duplicate definition of widget type '{self.name}'")
|
||||
WIDGET_TYPES[self.name] = self
|
||||
|
||||
# Register the update action automatically, adding widget-specific properties
|
||||
register_action(
|
||||
f"lvgl.{self.name}.update",
|
||||
ObjUpdateAction,
|
||||
base_update_schema(self, self.parts).extend(self.modify_schema),
|
||||
_update_action_schema(self),
|
||||
synchronous=True,
|
||||
)(update_to_code)
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ class TabviewType(WidgetType):
|
||||
tab_bar = Widget(bar_obj, obj_spec)
|
||||
await set_obj_properties(tab_bar, tab_style)
|
||||
if tab_items_style:
|
||||
for index, tab_conf in enumerate(config[CONF_TABS]):
|
||||
for index, _tab_conf in enumerate(config[CONF_TABS]):
|
||||
await set_obj_properties(
|
||||
Widget(lv_obj.get_child(bar_obj, index), button_spec),
|
||||
tab_items_style,
|
||||
|
||||
@@ -7,7 +7,7 @@ from urllib.parse import urljoin
|
||||
from esphome import automation, external_files, git
|
||||
from esphome.automation import register_action, register_condition
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import esp32, microphone, ota
|
||||
from esphome.components import esp32, microphone, ota, psram
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_FILE,
|
||||
@@ -20,6 +20,7 @@ from esphome.const import (
|
||||
CONF_RAW_DATA_ID,
|
||||
CONF_REF,
|
||||
CONF_REFRESH,
|
||||
CONF_TASK_STACK_IN_PSRAM,
|
||||
CONF_TYPE,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
@@ -358,6 +359,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
),
|
||||
cv.Optional(CONF_VAD): _maybe_empty_vad_schema,
|
||||
cv.Optional(CONF_STOP_AFTER_DETECTION, default=True): cv.boolean,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
cv.Optional(CONF_MODEL): cv.invalid(
|
||||
f"The {CONF_MODEL} parameter has moved to be a list element under the {CONF_MODELS} parameter."
|
||||
),
|
||||
@@ -374,14 +376,14 @@ CONFIG_SCHEMA = cv.All(
|
||||
|
||||
|
||||
def _load_model_data(manifest_path: Path):
|
||||
with open(manifest_path, encoding="utf-8") as f:
|
||||
with manifest_path.open(encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
_validate_manifest_version(manifest)
|
||||
|
||||
model_path = manifest_path.parent / manifest[CONF_MODEL]
|
||||
|
||||
with open(model_path, "rb") as f:
|
||||
with model_path.open("rb") as f:
|
||||
model = f.read()
|
||||
|
||||
if manifest.get(KEY_VERSION) == 1:
|
||||
@@ -451,6 +453,10 @@ async def to_code(config):
|
||||
cg.add_define("USE_MICRO_WAKE_WORD")
|
||||
ota.request_ota_state_listeners()
|
||||
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
psram.request_external_task_stack()
|
||||
|
||||
esp32.add_idf_component(name="espressif/esp-tflite-micro", ref="1.3.3~1")
|
||||
# Pin esp-nn for stable future builds (esp-tflite-micro depends on esp-nn)
|
||||
esp32.add_idf_component(name="espressif/esp-nn", ref="1.1.2")
|
||||
|
||||
@@ -33,7 +33,8 @@ static const uint32_t INFERENCE_TASK_STACK_SIZE = 3072;
|
||||
static const UBaseType_t INFERENCE_TASK_PRIORITY = 3;
|
||||
|
||||
enum EventGroupBits : uint32_t {
|
||||
COMMAND_STOP = (1 << 0), // Signals the inference task should stop
|
||||
COMMAND_STOP = (1 << 0), // Signals the inference task should stop
|
||||
COMMAND_RESET_RING_BUFFER = (1 << 1), // Signals the inference task to discard buffered audio
|
||||
|
||||
TASK_STARTING = (1 << 3),
|
||||
TASK_RUNNING = (1 << 4),
|
||||
@@ -114,13 +115,13 @@ void MicroWakeWord::setup() {
|
||||
}
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = this->ring_buffer_.lock();
|
||||
if (this->ring_buffer_.use_count() > 1) {
|
||||
size_t bytes_free = temp_ring_buffer->free();
|
||||
|
||||
if (bytes_free < data.size()) {
|
||||
xEventGroupSetBits(this->event_group_, EventGroupBits::WARNING_FULL_RING_BUFFER);
|
||||
temp_ring_buffer->reset();
|
||||
// Producer-only write: never touches consumer state. If the buffer is full, ask the inference task
|
||||
// to drain it - reset() is a consumer operation and must run on the inference task's thread.
|
||||
// Disable partial writes so audio chunks are either fully accepted or rejected and handled below.
|
||||
if (temp_ring_buffer->write_without_replacement(data.data(), data.size(), 0, false) == 0) {
|
||||
xEventGroupSetBits(this->event_group_,
|
||||
EventGroupBits::WARNING_FULL_RING_BUFFER | EventGroupBits::COMMAND_RESET_RING_BUFFER);
|
||||
}
|
||||
temp_ring_buffer->write((void *) data.data(), data.size());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -146,56 +147,65 @@ void MicroWakeWord::inference_task(void *params) {
|
||||
|
||||
{ // Ensures any C++ objects fall out of scope to deallocate before deleting the task
|
||||
|
||||
const size_t new_bytes_to_process =
|
||||
this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(this_mww->features_step_size_);
|
||||
std::unique_ptr<audio::AudioSourceTransferBuffer> audio_buffer;
|
||||
const auto &stream_info = this_mww->microphone_source_->get_audio_stream_info();
|
||||
const size_t bytes_per_frame = stream_info.frames_to_bytes(1);
|
||||
const size_t max_fill_bytes = stream_info.ms_to_bytes(this_mww->features_step_size_);
|
||||
std::unique_ptr<audio::RingBufferAudioSource> audio_source;
|
||||
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE];
|
||||
|
||||
if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) {
|
||||
// Allocate audio transfer buffer
|
||||
audio_buffer = audio::AudioSourceTransferBuffer::create(new_bytes_to_process);
|
||||
|
||||
if (audio_buffer == nullptr) {
|
||||
// Round ring buffer size down to a frame multiple so the wrap boundary never splits an int16 sample.
|
||||
const size_t ring_buffer_size =
|
||||
(stream_info.ms_to_bytes(RING_BUFFER_DURATION_MS) / bytes_per_frame) * bytes_per_frame;
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(ring_buffer_size);
|
||||
if (temp_ring_buffer == nullptr) {
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY);
|
||||
} else {
|
||||
audio_source = audio::RingBufferAudioSource::create(temp_ring_buffer, max_fill_bytes,
|
||||
static_cast<uint8_t>(bytes_per_frame));
|
||||
if (audio_source == nullptr) {
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY);
|
||||
} else {
|
||||
this_mww->ring_buffer_ = temp_ring_buffer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) {
|
||||
// Allocate ring buffer
|
||||
std::shared_ptr<ring_buffer::RingBuffer> temp_ring_buffer = ring_buffer::RingBuffer::create(
|
||||
this_mww->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS));
|
||||
if (temp_ring_buffer.use_count() == 0) {
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_MEMORY);
|
||||
}
|
||||
audio_buffer->set_source(temp_ring_buffer);
|
||||
this_mww->ring_buffer_ = temp_ring_buffer;
|
||||
}
|
||||
|
||||
if (!(xEventGroupGetBits(this_mww->event_group_) & ERROR_BITS)) {
|
||||
this_mww->microphone_source_->start();
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_RUNNING);
|
||||
|
||||
while (!(xEventGroupGetBits(this_mww->event_group_) & COMMAND_STOP)) {
|
||||
audio_buffer->transfer_data_from_source(pdMS_TO_TICKS(DATA_TIMEOUT_MS));
|
||||
|
||||
if (audio_buffer->available() < new_bytes_to_process) {
|
||||
// Insufficient data to generate new spectrogram features, read more next iteration
|
||||
continue;
|
||||
while (!(xEventGroupGetBits(this_mww->event_group_) & (COMMAND_STOP | ERROR_BITS))) {
|
||||
if (xEventGroupGetBits(this_mww->event_group_) & EventGroupBits::COMMAND_RESET_RING_BUFFER) {
|
||||
// Producer asked us to drain; run the consumer-side reset from this thread.
|
||||
audio_source->clear_buffered_data();
|
||||
xEventGroupClearBits(this_mww->event_group_, EventGroupBits::COMMAND_RESET_RING_BUFFER);
|
||||
}
|
||||
|
||||
// Generate new spectrogram features
|
||||
uint32_t processed_samples = this_mww->generate_features_(
|
||||
(int16_t *) audio_buffer->get_buffer_start(), audio_buffer->available() / sizeof(int16_t), features_buffer);
|
||||
audio_buffer->decrease_buffer_length(processed_samples * sizeof(int16_t));
|
||||
audio_source->fill(pdMS_TO_TICKS(DATA_TIMEOUT_MS), false);
|
||||
|
||||
// Run inference using the new spectorgram features
|
||||
if (!this_mww->update_model_probabilities_(features_buffer)) {
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE);
|
||||
break;
|
||||
// The frontend buffers samples internally and only emits a feature once it has a full window, so we can
|
||||
// hand it whatever the source exposes. The frontend consumes at least one sample per call, so available()
|
||||
// strictly decreases and this loop always terminates.
|
||||
while (audio_source->available() >= sizeof(int16_t)) {
|
||||
const size_t samples_available = audio_source->available() / sizeof(int16_t);
|
||||
const int16_t *audio_data = reinterpret_cast<const int16_t *>(audio_source->data());
|
||||
|
||||
size_t processed_samples = 0;
|
||||
const bool feature_generated =
|
||||
this_mww->generate_features_(audio_data, samples_available, features_buffer, &processed_samples);
|
||||
audio_source->consume(processed_samples * sizeof(int16_t));
|
||||
|
||||
if (feature_generated) {
|
||||
if (!this_mww->update_model_probabilities_(features_buffer)) {
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::ERROR_INFERENCE);
|
||||
break;
|
||||
}
|
||||
|
||||
// Process each model's probabilities and possibly send a Detection Event to the queue
|
||||
this_mww->process_probabilities_();
|
||||
}
|
||||
}
|
||||
|
||||
// Process each model's probabilities and possibly send a Detection Event to the queue
|
||||
this_mww->process_probabilities_();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -207,10 +217,7 @@ void MicroWakeWord::inference_task(void *params) {
|
||||
FrontendFreeStateContents(&this_mww->frontend_state_);
|
||||
|
||||
xEventGroupSetBits(this_mww->event_group_, EventGroupBits::TASK_STOPPED);
|
||||
while (true) {
|
||||
// Continuously delay until the main loop deletes the task
|
||||
delay(10);
|
||||
}
|
||||
vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it
|
||||
}
|
||||
|
||||
std::vector<WakeWordModel *> MicroWakeWord::get_wake_words() {
|
||||
@@ -233,14 +240,14 @@ void MicroWakeWord::add_vad_model(const uint8_t *model_start, uint8_t probabilit
|
||||
#endif
|
||||
|
||||
void MicroWakeWord::suspend_task_() {
|
||||
if (this->inference_task_handle_ != nullptr) {
|
||||
vTaskSuspend(this->inference_task_handle_);
|
||||
if (this->inference_task_.is_created()) {
|
||||
vTaskSuspend(this->inference_task_.get_handle());
|
||||
}
|
||||
}
|
||||
|
||||
void MicroWakeWord::resume_task_() {
|
||||
if (this->inference_task_handle_ != nullptr) {
|
||||
vTaskResume(this->inference_task_handle_);
|
||||
if (this->inference_task_.is_created()) {
|
||||
vTaskResume(this->inference_task_.get_handle());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,8 +289,7 @@ void MicroWakeWord::loop() {
|
||||
|
||||
if ((event_group_bits & EventGroupBits::TASK_STOPPED)) {
|
||||
ESP_LOGD(TAG, "Inference task is finished, freeing task resources");
|
||||
vTaskDelete(this->inference_task_handle_);
|
||||
this->inference_task_handle_ = nullptr;
|
||||
this->inference_task_.deallocate();
|
||||
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
||||
xQueueReset(this->detection_queue_);
|
||||
this->set_state_(State::STOPPED);
|
||||
@@ -301,7 +307,7 @@ void MicroWakeWord::loop() {
|
||||
|
||||
switch (this->state_) {
|
||||
case State::STARTING:
|
||||
if ((this->inference_task_handle_ == nullptr) && !this->status_has_error()) {
|
||||
if (!this->inference_task_.is_created() && !this->status_has_error()) {
|
||||
// Setup preprocesor feature generator. If done in the task, it would lock the task to its initial core, as it
|
||||
// uses floating point operations.
|
||||
if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_,
|
||||
@@ -310,10 +316,8 @@ void MicroWakeWord::loop() {
|
||||
return;
|
||||
}
|
||||
|
||||
xTaskCreate(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE, (void *) this,
|
||||
INFERENCE_TASK_PRIORITY, &this->inference_task_handle_);
|
||||
|
||||
if (this->inference_task_handle_ == nullptr) {
|
||||
if (!this->inference_task_.create(MicroWakeWord::inference_task, "mww", INFERENCE_TASK_STACK_SIZE,
|
||||
(void *) this, INFERENCE_TASK_PRIORITY, this->task_stack_in_psram_)) {
|
||||
FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state
|
||||
this->status_momentary_error("task_start", 1000);
|
||||
}
|
||||
@@ -386,11 +390,15 @@ void MicroWakeWord::set_state_(State state) {
|
||||
}
|
||||
}
|
||||
|
||||
size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_available,
|
||||
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]) {
|
||||
size_t processed_samples = 0;
|
||||
bool MicroWakeWord::generate_features_(const int16_t *audio_buffer, size_t samples_available,
|
||||
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples) {
|
||||
*processed_samples = 0;
|
||||
struct FrontendOutput frontend_output =
|
||||
FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, &processed_samples);
|
||||
FrontendProcessSamples(&this->frontend_state_, audio_buffer, samples_available, processed_samples);
|
||||
|
||||
if (frontend_output.size == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < frontend_output.size; ++i) {
|
||||
// These scaling values are set to match the TFLite audio frontend int8 output.
|
||||
@@ -415,7 +423,7 @@ size_t MicroWakeWord::generate_features_(int16_t *audio_buffer, size_t samples_a
|
||||
features_buffer[i] = static_cast<int8_t>(clamp<int32_t>(value, INT8_MIN, INT8_MAX));
|
||||
}
|
||||
|
||||
return processed_samples;
|
||||
return true;
|
||||
}
|
||||
|
||||
void MicroWakeWord::process_probabilities_() {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/static_task.h"
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
#include "esphome/components/ota/ota_backend.h"
|
||||
@@ -59,6 +60,8 @@ class MicroWakeWord : public Component
|
||||
|
||||
void set_stop_after_detection(bool stop_after_detection) { this->stop_after_detection_ = stop_after_detection; }
|
||||
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
|
||||
|
||||
Trigger<std::string> *get_wake_word_detected_trigger() { return &this->wake_word_detected_trigger_; }
|
||||
|
||||
void add_wake_word_model(WakeWordModel *model);
|
||||
@@ -93,6 +96,8 @@ class MicroWakeWord : public Component
|
||||
|
||||
bool stop_after_detection_;
|
||||
|
||||
bool task_stack_in_psram_{false};
|
||||
|
||||
uint8_t features_step_size_;
|
||||
|
||||
// Audio frontend handles generating spectrogram features
|
||||
@@ -105,8 +110,9 @@ class MicroWakeWord : public Component
|
||||
// Used to send messages about the models' states to the main loop
|
||||
QueueHandle_t detection_queue_;
|
||||
|
||||
StaticTask inference_task_;
|
||||
|
||||
static void inference_task(void *params);
|
||||
TaskHandle_t inference_task_handle_{nullptr};
|
||||
|
||||
/// @brief Suspends the inference task
|
||||
void suspend_task_();
|
||||
@@ -115,13 +121,16 @@ class MicroWakeWord : public Component
|
||||
|
||||
void set_state_(State state);
|
||||
|
||||
/// @brief Generates spectrogram features from an input buffer of audio samples
|
||||
/// @param audio_buffer (int16_t *) Buffer containing input audio samples
|
||||
/// @param samples_available (size_t) Number of samples avaiable in the input buffer
|
||||
/// @param features_buffer (int8_t *) Buffer to store generated features
|
||||
/// @return (size_t) Number of samples processed from the input buffer
|
||||
size_t generate_features_(int16_t *audio_buffer, size_t samples_available,
|
||||
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE]);
|
||||
/// @brief Generates a spectrogram feature from an input buffer of audio samples. The frontend buffers samples
|
||||
/// internally, so callers may stream arbitrary-sized chunks; a feature is only emitted once enough samples have
|
||||
/// accumulated to fill a full analysis window.
|
||||
/// @param audio_buffer (const int16_t *) Buffer containing input audio samples
|
||||
/// @param samples_available (size_t) Number of samples available in the input buffer
|
||||
/// @param features_buffer (int8_t *) Buffer to store the generated feature, valid only when the return value is true
|
||||
/// @param processed_samples (size_t *) Set to the number of samples consumed from the input buffer
|
||||
/// @return True if a new feature was generated; false if more samples are required
|
||||
bool generate_features_(const int16_t *audio_buffer, size_t samples_available,
|
||||
int8_t features_buffer[PREPROCESSOR_FEATURE_SIZE], size_t *processed_samples);
|
||||
|
||||
/// @brief Processes any new probabilities for each model. If any wake word is detected, it will send a DetectionEvent
|
||||
/// to the detection_queue_.
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import climate, uart
|
||||
from esphome.components.climate import validate_climate_swing_mode
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_TEMPERATURE, CONF_UPDATE_INTERVAL
|
||||
from esphome.const import (
|
||||
CONF_ID,
|
||||
CONF_SUPPORTED_SWING_MODES,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_UPDATE_INTERVAL,
|
||||
)
|
||||
from esphome.core import ID
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
@@ -43,6 +49,9 @@ CONFIG_SCHEMA = (
|
||||
cv.Optional(
|
||||
CONF_CURRENT_TEMPERATURE_MIN_INTERVAL, default="60s"
|
||||
): cv.update_interval,
|
||||
cv.Optional(
|
||||
CONF_SUPPORTED_SWING_MODES, default="OFF"
|
||||
): validate_climate_swing_mode,
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -63,6 +72,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
var = await climate.new_climate(config)
|
||||
await cg.register_component(var, config)
|
||||
await uart.register_uart_device(var, config)
|
||||
cg.add(var.set_supported_swing_mode(config[CONF_SUPPORTED_SWING_MODES]))
|
||||
cg.add(
|
||||
var.set_current_temperature_min_interval(
|
||||
config[CONF_CURRENT_TEMPERATURE_MIN_INTERVAL]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <cmath>
|
||||
#include <optional>
|
||||
#include "esphome/components/uart/uart.h"
|
||||
#include "esphome/core/finite_set_mask.h"
|
||||
|
||||
@@ -84,6 +84,8 @@ climate::ClimateTraits MitsubishiCN105Climate::traits() {
|
||||
traits.add_supported_fan_mode(p.second);
|
||||
}
|
||||
|
||||
traits.set_supported_swing_modes(this->supported_swing_modes_);
|
||||
|
||||
traits.set_visual_min_temperature(16.0f);
|
||||
traits.set_visual_max_temperature(31.0f);
|
||||
traits.set_visual_temperature_step(1.0f);
|
||||
@@ -114,6 +116,37 @@ void MitsubishiCN105Climate::control(const climate::ClimateCall &call) {
|
||||
this->hp_.set_fan_mode(*fan_mode);
|
||||
}
|
||||
|
||||
if (const auto swing_mode = call.get_swing_mode()) {
|
||||
auto vane = this->last_non_swing_vane_mode_;
|
||||
auto wide = this->last_non_swing_wide_vane_mode_;
|
||||
|
||||
switch (*swing_mode) {
|
||||
case climate::CLIMATE_SWING_BOTH:
|
||||
vane = MitsubishiCN105::VaneMode::SWING;
|
||||
wide = MitsubishiCN105::WideVaneMode::SWING;
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
vane = MitsubishiCN105::VaneMode::SWING;
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_HORIZONTAL:
|
||||
wide = MitsubishiCN105::WideVaneMode::SWING;
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_OFF:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
|
||||
this->hp_.set_vane_mode(vane);
|
||||
}
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
|
||||
this->hp_.set_wide_vane_mode(wide);
|
||||
}
|
||||
}
|
||||
|
||||
if (this->hp_.is_status_initialized()) {
|
||||
this->apply_values_();
|
||||
}
|
||||
@@ -143,7 +176,64 @@ void MitsubishiCN105Climate::apply_values_() {
|
||||
ESP_LOGD(TAG, "Unable to map fan mode");
|
||||
}
|
||||
|
||||
if (!this->supported_swing_modes_.empty()) {
|
||||
bool vertical_swinging = false;
|
||||
bool horizontal_swinging = false;
|
||||
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_VERTICAL)) {
|
||||
if (status.vane_mode == MitsubishiCN105::VaneMode::SWING) {
|
||||
vertical_swinging = true;
|
||||
} else if (status.vane_mode != MitsubishiCN105::VaneMode::UNKNOWN) {
|
||||
this->last_non_swing_vane_mode_ = status.vane_mode;
|
||||
}
|
||||
}
|
||||
|
||||
if (this->supported_swing_modes_.count(climate::CLIMATE_SWING_HORIZONTAL)) {
|
||||
if (status.wide_vane_mode == MitsubishiCN105::WideVaneMode::SWING) {
|
||||
horizontal_swinging = true;
|
||||
} else if (status.wide_vane_mode != MitsubishiCN105::WideVaneMode::UNKNOWN) {
|
||||
this->last_non_swing_wide_vane_mode_ = status.wide_vane_mode;
|
||||
}
|
||||
}
|
||||
|
||||
if (vertical_swinging && horizontal_swinging) {
|
||||
this->swing_mode = climate::CLIMATE_SWING_BOTH;
|
||||
} else if (vertical_swinging) {
|
||||
this->swing_mode = climate::CLIMATE_SWING_VERTICAL;
|
||||
} else if (horizontal_swinging) {
|
||||
this->swing_mode = climate::CLIMATE_SWING_HORIZONTAL;
|
||||
} else {
|
||||
this->swing_mode = climate::CLIMATE_SWING_OFF;
|
||||
}
|
||||
}
|
||||
|
||||
this->publish_state();
|
||||
}
|
||||
|
||||
void MitsubishiCN105Climate::set_supported_swing_mode(climate::ClimateSwingMode mode) {
|
||||
this->supported_swing_modes_.clear();
|
||||
switch (mode) {
|
||||
case climate::CLIMATE_SWING_VERTICAL:
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_HORIZONTAL:
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_BOTH:
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_OFF);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_VERTICAL);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_HORIZONTAL);
|
||||
this->supported_swing_modes_.insert(climate::CLIMATE_SWING_BOTH);
|
||||
break;
|
||||
|
||||
case climate::CLIMATE_SWING_OFF:
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::mitsubishi_cn105
|
||||
|
||||
@@ -25,10 +25,15 @@ class MitsubishiCN105Climate : public climate::Climate, public Component, public
|
||||
void set_remote_temperature(float temperature) { this->hp_.set_remote_temperature(temperature); }
|
||||
void clear_remote_temperature() { this->hp_.clear_remote_temperature(); }
|
||||
|
||||
void set_supported_swing_mode(climate::ClimateSwingMode mode);
|
||||
|
||||
protected:
|
||||
void apply_values_();
|
||||
|
||||
MitsubishiCN105 hp_;
|
||||
climate::ClimateSwingModeMask supported_swing_modes_{};
|
||||
MitsubishiCN105::VaneMode last_non_swing_vane_mode_{MitsubishiCN105::VaneMode::AUTO};
|
||||
MitsubishiCN105::WideVaneMode last_non_swing_wide_vane_mode_{MitsubishiCN105::WideVaneMode::CENTER};
|
||||
};
|
||||
|
||||
template<typename... Ts>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import audio, esp32, speaker
|
||||
from esphome.components import audio, psram, speaker
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BITS_PER_SAMPLE,
|
||||
@@ -44,20 +44,10 @@ SOURCE_SPEAKER_SCHEMA = speaker.SPEAKER_SCHEMA.extend(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.one_of(CONF_NEVER, lower=True),
|
||||
),
|
||||
cv.Optional(CONF_BITS_PER_SAMPLE, default=16): cv.int_range(16, 16),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _set_stream_limits(config):
|
||||
audio.set_stream_limits(
|
||||
min_bits_per_sample=16,
|
||||
max_bits_per_sample=16,
|
||||
)(config)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _validate_source_speaker(config):
|
||||
fconf = fv.full_config.get()
|
||||
|
||||
@@ -67,15 +57,25 @@ def _validate_source_speaker(config):
|
||||
output_speaker_id = fconf.get_config_for_path(path)
|
||||
config[CONF_OUTPUT_SPEAKER] = output_speaker_id
|
||||
|
||||
inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER)(config)
|
||||
inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER)(config)
|
||||
inherit_property_from(CONF_SAMPLE_RATE, CONF_OUTPUT_SPEAKER)(config)
|
||||
|
||||
audio.final_validate_audio_schema(
|
||||
"mixer",
|
||||
audio_device=CONF_OUTPUT_SPEAKER,
|
||||
sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
)(config)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _validate_output_speaker(config):
|
||||
audio.final_validate_audio_schema(
|
||||
"mixer",
|
||||
audio_device=CONF_OUTPUT_SPEAKER,
|
||||
bits_per_sample=config.get(CONF_BITS_PER_SAMPLE),
|
||||
channels=config.get(CONF_NUM_CHANNELS),
|
||||
sample_rate=config.get(CONF_SAMPLE_RATE),
|
||||
)(config)
|
||||
|
||||
return config
|
||||
@@ -89,24 +89,26 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Required(CONF_SOURCE_SPEAKERS): cv.All(
|
||||
cv.ensure_list(SOURCE_SPEAKER_SCHEMA),
|
||||
cv.Length(min=2, max=8),
|
||||
[_set_stream_limits],
|
||||
),
|
||||
cv.Optional(CONF_BITS_PER_SAMPLE): cv.one_of(8, 16, 24, 32, int=True),
|
||||
cv.Optional(CONF_NUM_CHANNELS): cv.int_range(min=1, max=2),
|
||||
cv.Optional(CONF_QUEUE_MODE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM, default=False): cv.boolean,
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): psram.validate_task_stack_in_psram,
|
||||
}
|
||||
),
|
||||
cv.only_on([PLATFORM_ESP32]),
|
||||
)
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = cv.All(
|
||||
inherit_property_from(CONF_BITS_PER_SAMPLE, CONF_OUTPUT_SPEAKER),
|
||||
inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_SOURCE_SPEAKERS): [_validate_source_speaker],
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
),
|
||||
inherit_property_from(CONF_NUM_CHANNELS, CONF_OUTPUT_SPEAKER),
|
||||
_validate_output_speaker,
|
||||
)
|
||||
|
||||
|
||||
@@ -116,16 +118,14 @@ async def to_code(config):
|
||||
|
||||
spkr = await cg.get_variable(config[CONF_OUTPUT_SPEAKER])
|
||||
|
||||
cg.add(var.set_output_bits_per_sample(config[CONF_BITS_PER_SAMPLE]))
|
||||
cg.add(var.set_output_channels(config[CONF_NUM_CHANNELS]))
|
||||
cg.add(var.set_output_speaker(spkr))
|
||||
cg.add(var.set_queue_mode(config[CONF_QUEUE_MODE]))
|
||||
|
||||
if task_stack_in_psram := config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(task_stack_in_psram))
|
||||
if task_stack_in_psram and config[CONF_TASK_STACK_IN_PSRAM]:
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
psram.request_external_task_stack()
|
||||
|
||||
# Initialize FixedVector with exact count of source speakers
|
||||
cg.add(var.init_source_speakers(len(config[CONF_SOURCE_SPEAKERS])))
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <mixer.h> // esp-audio-libs
|
||||
#include <pcm_convert.h> // esp-audio-libs
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::mixer_speaker {
|
||||
@@ -22,19 +24,8 @@ static const uint32_t MIXER_AUTO_STOP_DEBOUNCE_MS = 200;
|
||||
|
||||
static const size_t TASK_STACK_SIZE = 4096;
|
||||
|
||||
static const int16_t MAX_AUDIO_SAMPLE_VALUE = INT16_MAX;
|
||||
static const int16_t MIN_AUDIO_SAMPLE_VALUE = INT16_MIN;
|
||||
|
||||
static const char *const TAG = "speaker_mixer";
|
||||
|
||||
// Gives the Q15 fixed point scaling factor to reduce by 0 dB, 1dB, ..., 50 dB
|
||||
// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014)
|
||||
// float to Q15 fixed point formula: q15_scale_factor = floating_point_scale_factor * 2^(15)
|
||||
static const std::array<int16_t, 51> DECIBEL_REDUCTION_TABLE = {
|
||||
32767, 29201, 26022, 23189, 20665, 18415, 16410, 14624, 13032, 11613, 10349, 9222, 8218, 7324, 6527, 5816, 5183,
|
||||
4619, 4116, 3668, 3269, 2913, 2596, 2313, 2061, 1837, 1637, 1459, 1300, 1158, 1032, 920, 820, 731,
|
||||
651, 580, 517, 461, 411, 366, 326, 291, 259, 231, 206, 183, 163, 146, 130, 116, 103};
|
||||
|
||||
// Event bits for SourceSpeaker command processing
|
||||
enum SourceSpeakerEventBits : uint32_t {
|
||||
SOURCE_SPEAKER_COMMAND_START = (1 << 0),
|
||||
@@ -315,97 +306,17 @@ size_t SourceSpeaker::process_data_from_source(std::shared_ptr<audio::RingBuffer
|
||||
|
||||
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());
|
||||
|
||||
duck_samples(current_buffer, samples_to_duck, &this->current_ducking_db_reduction_,
|
||||
&this->ducking_transition_samples_remaining_, this->samples_per_ducking_step_,
|
||||
this->db_change_per_ducking_step_);
|
||||
esp_audio_libs::ducking::apply(audio_source->mutable_data(),
|
||||
static_cast<uint8_t>(this->audio_stream_info_.get_bits_per_sample() / 8),
|
||||
samples_to_duck, this->ducking_state_);
|
||||
}
|
||||
|
||||
return bytes_read;
|
||||
}
|
||||
|
||||
void SourceSpeaker::apply_ducking(uint8_t decibel_reduction, uint32_t duration) {
|
||||
if (this->target_ducking_db_reduction_ != decibel_reduction) {
|
||||
// Start transition from the previous target (which becomes the new current level)
|
||||
this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_;
|
||||
|
||||
this->target_ducking_db_reduction_ = decibel_reduction;
|
||||
|
||||
// Calculate the number of intermediate dB steps for the transition timing.
|
||||
// Subtract 1 because the first step is taken immediately after this calculation.
|
||||
uint8_t total_ducking_steps = 0;
|
||||
if (this->target_ducking_db_reduction_ > this->current_ducking_db_reduction_) {
|
||||
// The dB reduction level is increasing (which results in quieter audio)
|
||||
total_ducking_steps = this->target_ducking_db_reduction_ - this->current_ducking_db_reduction_ - 1;
|
||||
this->db_change_per_ducking_step_ = 1;
|
||||
} else {
|
||||
// The dB reduction level is decreasing (which results in louder audio)
|
||||
total_ducking_steps = this->current_ducking_db_reduction_ - this->target_ducking_db_reduction_ - 1;
|
||||
this->db_change_per_ducking_step_ = -1;
|
||||
}
|
||||
if ((duration > 0) && (total_ducking_steps > 0)) {
|
||||
this->ducking_transition_samples_remaining_ = this->audio_stream_info_.ms_to_samples(duration);
|
||||
|
||||
this->samples_per_ducking_step_ = this->ducking_transition_samples_remaining_ / total_ducking_steps;
|
||||
this->ducking_transition_samples_remaining_ =
|
||||
this->samples_per_ducking_step_ * total_ducking_steps; // adjust for integer division rounding
|
||||
|
||||
this->current_ducking_db_reduction_ += this->db_change_per_ducking_step_;
|
||||
} else {
|
||||
this->ducking_transition_samples_remaining_ = 0;
|
||||
this->current_ducking_db_reduction_ = this->target_ducking_db_reduction_;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SourceSpeaker::duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck,
|
||||
int8_t *current_ducking_db_reduction, uint32_t *ducking_transition_samples_remaining,
|
||||
uint32_t samples_per_ducking_step, int8_t db_change_per_ducking_step) {
|
||||
if (*ducking_transition_samples_remaining > 0) {
|
||||
// Ducking level is still transitioning
|
||||
|
||||
// Takes the ceiling of input_samples_to_duck/samples_per_ducking_step
|
||||
uint32_t ducking_steps_in_batch =
|
||||
input_samples_to_duck / samples_per_ducking_step + (input_samples_to_duck % samples_per_ducking_step != 0);
|
||||
|
||||
for (uint32_t i = 0; i < ducking_steps_in_batch; ++i) {
|
||||
uint32_t samples_left_in_step = *ducking_transition_samples_remaining % samples_per_ducking_step;
|
||||
|
||||
if (samples_left_in_step == 0) {
|
||||
samples_left_in_step = samples_per_ducking_step;
|
||||
}
|
||||
|
||||
uint32_t samples_to_duck = std::min(input_samples_to_duck, samples_left_in_step);
|
||||
samples_to_duck = std::min(samples_to_duck, *ducking_transition_samples_remaining);
|
||||
|
||||
// Ensure we only point to valid index in the Q15 scaling factor table
|
||||
uint8_t safe_db_reduction_index =
|
||||
clamp<uint8_t>(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1);
|
||||
int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index];
|
||||
|
||||
audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, samples_to_duck);
|
||||
|
||||
if (samples_left_in_step - samples_to_duck == 0) {
|
||||
// After scaling the current samples, we are ready to transition to the next step
|
||||
*current_ducking_db_reduction += db_change_per_ducking_step;
|
||||
}
|
||||
|
||||
input_buffer += samples_to_duck;
|
||||
*ducking_transition_samples_remaining -= samples_to_duck;
|
||||
input_samples_to_duck -= samples_to_duck;
|
||||
}
|
||||
}
|
||||
|
||||
if ((*current_ducking_db_reduction > 0) && (input_samples_to_duck > 0)) {
|
||||
// Audio is ducked, but its not in the middle of a transition step
|
||||
|
||||
uint8_t safe_db_reduction_index =
|
||||
clamp<uint8_t>(*current_ducking_db_reduction, 0, DECIBEL_REDUCTION_TABLE.size() - 1);
|
||||
int16_t q15_scale_factor = DECIBEL_REDUCTION_TABLE[safe_db_reduction_index];
|
||||
|
||||
audio::scale_audio_samples(input_buffer, input_buffer, q15_scale_factor, input_samples_to_duck);
|
||||
}
|
||||
const uint32_t transition_samples = duration > 0 ? this->audio_stream_info_.ms_to_samples(duration) : 0;
|
||||
esp_audio_libs::ducking::set_target(this->ducking_state_, decibel_reduction, transition_samples);
|
||||
}
|
||||
|
||||
void SourceSpeaker::enter_stopping_state_() {
|
||||
@@ -417,8 +328,9 @@ void SourceSpeaker::enter_stopping_state_() {
|
||||
void MixerSpeaker::dump_config() {
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Speaker Mixer:\n"
|
||||
" Number of output channels: %u",
|
||||
this->output_channels_);
|
||||
" Number of output channels: %" PRIu8 "\n"
|
||||
" Output bits per sample: %" PRIu8,
|
||||
this->output_channels_, this->output_bits_per_sample_);
|
||||
}
|
||||
|
||||
void MixerSpeaker::setup() {
|
||||
@@ -512,13 +424,8 @@ void MixerSpeaker::loop() {
|
||||
|
||||
esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) {
|
||||
if (!this->audio_stream_info_.has_value()) {
|
||||
if (stream_info.get_bits_per_sample() != 16) {
|
||||
// Audio streams that don't have 16 bits per sample are not supported
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
this->audio_stream_info_ = audio::AudioStreamInfo(stream_info.get_bits_per_sample(), this->output_channels_,
|
||||
stream_info.get_sample_rate());
|
||||
this->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(this->output_bits_per_sample_, this->output_channels_, stream_info.get_sample_rate());
|
||||
this->output_speaker_->set_audio_stream_info(this->audio_stream_info_.value());
|
||||
} else {
|
||||
if (!this->queue_mode_ && (stream_info.get_sample_rate() != this->audio_stream_info_.value().get_sample_rate())) {
|
||||
@@ -542,57 +449,6 @@ esp_err_t MixerSpeaker::start(audio::AudioStreamInfo &stream_info) {
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
void MixerSpeaker::copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info,
|
||||
int16_t *output_buffer, audio::AudioStreamInfo output_stream_info,
|
||||
uint32_t frames_to_transfer) {
|
||||
uint8_t input_channels = input_stream_info.get_channels();
|
||||
uint8_t output_channels = output_stream_info.get_channels();
|
||||
const uint8_t max_input_channel_index = input_channels - 1;
|
||||
|
||||
if (input_channels == output_channels) {
|
||||
size_t bytes_to_copy = input_stream_info.frames_to_bytes(frames_to_transfer);
|
||||
memcpy(output_buffer, input_buffer, bytes_to_copy);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for (uint32_t frame_index = 0; frame_index < frames_to_transfer; ++frame_index) {
|
||||
for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) {
|
||||
uint8_t input_channel_index = std::min(output_channel_index, max_input_channel_index);
|
||||
output_buffer[output_channels * frame_index + output_channel_index] =
|
||||
input_buffer[input_channels * frame_index + input_channel_index];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void MixerSpeaker::mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info,
|
||||
const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info,
|
||||
int16_t *output_buffer, audio::AudioStreamInfo output_stream_info,
|
||||
uint32_t frames_to_mix) {
|
||||
const uint8_t primary_channels = primary_stream_info.get_channels();
|
||||
const uint8_t secondary_channels = secondary_stream_info.get_channels();
|
||||
const uint8_t output_channels = output_stream_info.get_channels();
|
||||
|
||||
const uint8_t max_primary_channel_index = primary_channels - 1;
|
||||
const uint8_t max_secondary_channel_index = secondary_channels - 1;
|
||||
|
||||
for (uint32_t frames_index = 0; frames_index < frames_to_mix; ++frames_index) {
|
||||
for (uint8_t output_channel_index = 0; output_channel_index < output_channels; ++output_channel_index) {
|
||||
const uint32_t secondary_channel_index = std::min(output_channel_index, max_secondary_channel_index);
|
||||
const int32_t secondary_sample = secondary_buffer[frames_index * secondary_channels + secondary_channel_index];
|
||||
|
||||
const uint32_t primary_channel_index = std::min(output_channel_index, max_primary_channel_index);
|
||||
const int32_t primary_sample =
|
||||
static_cast<int32_t>(primary_buffer[frames_index * primary_channels + primary_channel_index]);
|
||||
|
||||
const int32_t added_sample = secondary_sample + primary_sample;
|
||||
|
||||
output_buffer[frames_index * output_channels + output_channel_index] =
|
||||
static_cast<int16_t>(clamp<int32_t>(added_sample, MIN_AUDIO_SAMPLE_VALUE, MAX_AUDIO_SAMPLE_VALUE));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NOLINTBEGIN(bugprone-unchecked-optional-access) -- audio_stream_info_ always set before this task is created
|
||||
void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
MixerSpeaker *this_mixer = static_cast<MixerSpeaker *>(params);
|
||||
@@ -662,6 +518,10 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
|
||||
uint32_t frames_to_mix = output_frames_free;
|
||||
|
||||
const audio::AudioStreamInfo &output_info = this_mixer->audio_stream_info_.value();
|
||||
const uint8_t output_bps = output_info.get_bits_per_sample() / 8;
|
||||
const uint8_t output_channels = output_info.get_channels();
|
||||
|
||||
if ((audio_sources_with_data.size() == 1) || this_mixer->queue_mode_) {
|
||||
// Only one speaker has audio data, just copy samples over
|
||||
|
||||
@@ -669,14 +529,15 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
|
||||
if (active_stream_info.get_sample_rate() ==
|
||||
this_mixer->output_speaker_->get_audio_stream_info().get_sample_rate()) {
|
||||
// Speaker's sample rate matches the output speaker's, copy directly
|
||||
// Speaker's sample rate matches the output speaker's, convert directly into the output buffer
|
||||
|
||||
const uint32_t frames_available_in_buffer =
|
||||
active_stream_info.bytes_to_frames(audio_sources_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()),
|
||||
this_mixer->audio_stream_info_.value(), frames_to_mix);
|
||||
esp_audio_libs::pcm_convert::copy_frames(
|
||||
audio_sources_with_data[0]->data(), output_transfer_buffer->get_buffer_end(),
|
||||
static_cast<uint8_t>(active_stream_info.get_bits_per_sample() / 8), active_stream_info.get_channels(),
|
||||
output_bps, output_channels, frames_to_mix);
|
||||
|
||||
// Set playback delay for newly contributing source
|
||||
if (!speakers_with_data[0]->has_contributed_.load(std::memory_order_acquire)) {
|
||||
@@ -690,8 +551,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
audio_sources_with_data[0]->consume(active_stream_info.frames_to_bytes(frames_to_mix));
|
||||
|
||||
// Update output transfer buffer length and pipeline frame count
|
||||
output_transfer_buffer->increase_buffer_length(
|
||||
this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix));
|
||||
output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix));
|
||||
this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release);
|
||||
} else {
|
||||
// Speaker's stream info doesn't match the output speaker's, so it's a new source speaker
|
||||
@@ -703,7 +563,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
} else {
|
||||
// Speaker has finished writing the current audio, update the stream information and restart the speaker
|
||||
this_mixer->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(active_stream_info.get_bits_per_sample(), this_mixer->output_channels_,
|
||||
audio::AudioStreamInfo(this_mixer->output_bits_per_sample_, this_mixer->output_channels_,
|
||||
active_stream_info.get_sample_rate());
|
||||
this_mixer->output_speaker_->set_audio_stream_info(this_mixer->audio_stream_info_.value());
|
||||
this_mixer->output_speaker_->start();
|
||||
@@ -719,21 +579,22 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
speakers_with_data[i]->get_audio_stream_info().bytes_to_frames(audio_sources_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());
|
||||
const uint8_t *primary_buffer = audio_sources_with_data[0]->data();
|
||||
audio::AudioStreamInfo primary_stream_info = speakers_with_data[0]->get_audio_stream_info();
|
||||
|
||||
// Mix two streams together
|
||||
// Mix two streams together at a time, accumulating into the output buffer.
|
||||
for (size_t i = 1; i < audio_sources_with_data.size(); ++i) {
|
||||
mix_audio_samples(primary_buffer, primary_stream_info,
|
||||
reinterpret_cast<const int16_t *>(audio_sources_with_data[i]->data()),
|
||||
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);
|
||||
esp_audio_libs::mixer::mix_frames(
|
||||
primary_buffer, static_cast<uint8_t>(primary_stream_info.get_bits_per_sample() / 8),
|
||||
primary_stream_info.get_channels(), audio_sources_with_data[i]->data(),
|
||||
static_cast<uint8_t>(speakers_with_data[i]->get_audio_stream_info().get_bits_per_sample() / 8),
|
||||
speakers_with_data[i]->get_audio_stream_info().get_channels(), output_transfer_buffer->get_buffer_end(),
|
||||
output_bps, output_channels, frames_to_mix);
|
||||
|
||||
if (i != audio_sources_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_stream_info = this_mixer->audio_stream_info_.value();
|
||||
primary_buffer = output_transfer_buffer->get_buffer_end();
|
||||
primary_stream_info = output_info;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,8 +615,7 @@ void MixerSpeaker::audio_mixer_task(void *params) {
|
||||
}
|
||||
|
||||
// Update output transfer buffer length and pipeline frame count (once, not per source)
|
||||
output_transfer_buffer->increase_buffer_length(
|
||||
this_mixer->audio_stream_info_.value().frames_to_bytes(frames_to_mix));
|
||||
output_transfer_buffer->increase_buffer_length(output_info.frames_to_bytes(frames_to_mix));
|
||||
this_mixer->frames_in_pipeline_.fetch_add(frames_to_mix, std::memory_order_release);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/static_task.h"
|
||||
|
||||
#include <ducking.h> // esp-audio-libs
|
||||
|
||||
#include <freertos/event_groups.h>
|
||||
|
||||
#include <atomic>
|
||||
@@ -22,7 +24,8 @@ namespace esphome::mixer_speaker {
|
||||
* - Source speaker commands are signaled via event group bits and processed in its loop function to ensure thread
|
||||
* safety
|
||||
* - Directly handles pausing at the SourceSpeaker level; pause state is not passed through to the output speaker.
|
||||
* - Audio sent to the SourceSpeaker must have 16 bits per sample.
|
||||
* - Audio sent to the SourceSpeaker can have 8, 16, 24, or 32 bits per sample. Each source is converted to the output
|
||||
* speaker's bit depth as it is mixed (or copied) into the output buffer.
|
||||
* - Audio sent to the SourceSpeaker can have any number of channels. They are duplicated or ignored as needed to match
|
||||
* the number of channels required for the output speaker.
|
||||
* - In queue mode, the audio sent to the SourceSpeakers can have different sample rates.
|
||||
@@ -93,19 +96,6 @@ class SourceSpeaker : public speaker::Speaker, public Component {
|
||||
void enter_stopping_state_();
|
||||
void send_command_(uint32_t command_bit, bool wake_loop = false);
|
||||
|
||||
/// @brief Ducks audio samples by a specified amount. When changing the ducking amount, it can transition gradually
|
||||
/// over a specified amount of samples.
|
||||
/// @param input_buffer buffer with audio samples to be ducked in place
|
||||
/// @param input_samples_to_duck number of samples to process in ``input_buffer``
|
||||
/// @param current_ducking_db_reduction pointer to the current dB reduction
|
||||
/// @param ducking_transition_samples_remaining pointer to the total number of samples left before the
|
||||
/// transition is finished
|
||||
/// @param samples_per_ducking_step total number of samples per ducking step for the transition
|
||||
/// @param db_change_per_ducking_step the change in dB reduction per step
|
||||
static void duck_samples(int16_t *input_buffer, uint32_t input_samples_to_duck, int8_t *current_ducking_db_reduction,
|
||||
uint32_t *ducking_transition_samples_remaining, uint32_t samples_per_ducking_step,
|
||||
int8_t db_change_per_ducking_step);
|
||||
|
||||
MixerSpeaker *parent_;
|
||||
|
||||
std::shared_ptr<audio::RingBufferAudioSource> audio_source_;
|
||||
@@ -118,11 +108,7 @@ class SourceSpeaker : public speaker::Speaker, public Component {
|
||||
|
||||
bool pause_state_{false};
|
||||
|
||||
int8_t target_ducking_db_reduction_{0};
|
||||
int8_t current_ducking_db_reduction_{0};
|
||||
int8_t db_change_per_ducking_step_{1};
|
||||
uint32_t ducking_transition_samples_remaining_{0};
|
||||
uint32_t samples_per_ducking_step_{0};
|
||||
esp_audio_libs::ducking::DuckingState ducking_state_{};
|
||||
|
||||
std::atomic<uint32_t> pending_playback_frames_{0};
|
||||
std::atomic<uint32_t> playback_delay_frames_{0}; // Frames in output pipeline when this source started contributing
|
||||
@@ -143,12 +129,14 @@ class MixerSpeaker : public Component {
|
||||
|
||||
/// @brief Starts the mixer task. Called by a source speaker giving the current audio stream information
|
||||
/// @param stream_info The calling source speaker's audio stream information
|
||||
/// @return ESP_ERR_NOT_SUPPORTED if the incoming stream is incompatible due to unsupported bits per sample
|
||||
/// ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream
|
||||
/// @return ESP_ERR_INVALID_ARG if the incoming stream is incompatible to be mixed with the other input audio stream
|
||||
/// ESP_OK if the incoming stream is compatible and the mixer task starts
|
||||
esp_err_t start(audio::AudioStreamInfo &stream_info);
|
||||
|
||||
void set_output_channels(uint8_t output_channels) { this->output_channels_ = output_channels; }
|
||||
void set_output_bits_per_sample(uint8_t output_bits_per_sample) {
|
||||
this->output_bits_per_sample_ = output_bits_per_sample;
|
||||
}
|
||||
void set_output_speaker(speaker::Speaker *speaker) { this->output_speaker_ = speaker; }
|
||||
void set_queue_mode(bool queue_mode) { this->queue_mode_ = queue_mode; }
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
|
||||
@@ -159,33 +147,6 @@ class MixerSpeaker : public Component {
|
||||
uint32_t get_frames_in_pipeline() const { return this->frames_in_pipeline_.load(std::memory_order_acquire); }
|
||||
|
||||
protected:
|
||||
/// @brief Copies audio frames from the input buffer to the output buffer taking into account the number of channels
|
||||
/// in each stream. If the output stream has more channels, the input samples are duplicated. If the output stream has
|
||||
/// less channels, the extra channel input samples are dropped.
|
||||
/// @param input_buffer
|
||||
/// @param input_stream_info
|
||||
/// @param output_buffer
|
||||
/// @param output_stream_info
|
||||
/// @param frames_to_transfer number of frames (consisting of a sample for each channel) to copy from the input buffer
|
||||
static void copy_frames(const int16_t *input_buffer, audio::AudioStreamInfo input_stream_info, int16_t *output_buffer,
|
||||
audio::AudioStreamInfo output_stream_info, uint32_t frames_to_transfer);
|
||||
|
||||
/// @brief Mixes the primary and secondary streams taking into account the number of channels in each stream. Primary
|
||||
/// and secondary samples are duplicated or dropped as necessary to ensure the output stream has the configured number
|
||||
/// of channels. Output samples are clamped to the corresponding int16 min or max values if the mixed sample
|
||||
/// overflows.
|
||||
/// @param primary_buffer samples buffer for the primary stream
|
||||
/// @param primary_stream_info stream info for the primary stream
|
||||
/// @param secondary_buffer samples buffer for secondary stream
|
||||
/// @param secondary_stream_info stream info for the secondary stream
|
||||
/// @param output_buffer buffer for the mixed samples
|
||||
/// @param output_stream_info stream info for the output buffer
|
||||
/// @param frames_to_mix number of frames in the primary and secondary buffers to mix together
|
||||
static void mix_audio_samples(const int16_t *primary_buffer, audio::AudioStreamInfo primary_stream_info,
|
||||
const int16_t *secondary_buffer, audio::AudioStreamInfo secondary_stream_info,
|
||||
int16_t *output_buffer, audio::AudioStreamInfo output_stream_info,
|
||||
uint32_t frames_to_mix);
|
||||
|
||||
static void audio_mixer_task(void *params);
|
||||
|
||||
EventGroupHandle_t event_group_{nullptr};
|
||||
@@ -193,6 +154,7 @@ class MixerSpeaker : public Component {
|
||||
FixedVector<SourceSpeaker *> source_speakers_;
|
||||
speaker::Speaker *output_speaker_{nullptr};
|
||||
|
||||
uint8_t output_bits_per_sample_;
|
||||
uint8_t output_channels_;
|
||||
bool queue_mode_;
|
||||
bool task_stack_in_psram_{false};
|
||||
|
||||
@@ -26,7 +26,7 @@ CONFIG_SCHEMA = MSA_SENSOR_SCHEMA.extend(
|
||||
),
|
||||
key=CONF_NAME,
|
||||
)
|
||||
for event, icon in zip(EVENT_SENSORS, ICONS)
|
||||
for event, icon in zip(EVENT_SENSORS, ICONS, strict=True)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -5,8 +5,9 @@ import esphome.codegen as cg
|
||||
from esphome.components.esp32 import add_idf_sdkconfig_option
|
||||
from esphome.components.psram import is_guaranteed as psram_is_guaranteed
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ENABLE_IPV6, CONF_MIN_IPV6_ADDR_COUNT
|
||||
from esphome.const import CONF_ENABLE_IPV6, CONF_ID, CONF_MIN_IPV6_ADDR_COUNT
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
AUTO_LOAD = ["mdns"]
|
||||
@@ -19,6 +20,7 @@ KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking"
|
||||
CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance"
|
||||
|
||||
network_ns = cg.esphome_ns.namespace("network")
|
||||
NetworkComponent = network_ns.class_("NetworkComponent", cg.Component)
|
||||
IPAddress = network_ns.class_("IPAddress")
|
||||
|
||||
|
||||
@@ -107,6 +109,7 @@ def has_high_performance_networking() -> bool:
|
||||
|
||||
CONFIG_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(NetworkComponent),
|
||||
cv.SplitDefault(
|
||||
CONF_ENABLE_IPV6,
|
||||
bk72xx=False,
|
||||
@@ -224,3 +227,15 @@ async def to_code(config):
|
||||
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_LWIP2_IPV6_LOW_MEMORY")
|
||||
if CORE.is_rp2040:
|
||||
cg.add_build_flag("-DPIO_FRAMEWORK_ARDUINO_ENABLE_IPV6")
|
||||
# Pvariable creation lives in a separate coroutine at NETWORK_SERVICES so it
|
||||
# emits after wifi/ethernet at COMMUNICATION. This keeps compile-time config
|
||||
# (above) separate from C++ object lifecycle and allows wiring in interface
|
||||
# pointers via get_variable().
|
||||
if CORE.is_esp32:
|
||||
CORE.add_job(network_component_to_code, config)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.NETWORK_SERVICES)
|
||||
async def network_component_to_code(config: ConfigType) -> None:
|
||||
var = cg.new_Pvariable(config[CONF_ID])
|
||||
await cg.register_component(var, config)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
#include "network_component.h"
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_NETWORK) && defined(USE_ESP32)
|
||||
#include "esphome/core/log.h"
|
||||
#include "esp_err.h"
|
||||
#include "esp_netif.h"
|
||||
#include "esp_event.h"
|
||||
namespace esphome::network {
|
||||
|
||||
static const char *const TAG = "network";
|
||||
|
||||
void NetworkComponent::setup() {
|
||||
// Initialize ESP-IDF network interfaces and ensure the default event loop exists
|
||||
esp_err_t err;
|
||||
err = esp_netif_init();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_netif_init failed: (%d) %s", err, esp_err_to_name(err));
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
err = esp_event_loop_create_default();
|
||||
// ESP_ERR_INVALID_STATE is returned if the default loop already exists,
|
||||
// which is fine since we just want to make sure it exists
|
||||
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
|
||||
ESP_LOGE(TAG, "esp_event_loop_create_default failed: (%d) %s", err, esp_err_to_name(err));
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::network
|
||||
#endif
|
||||
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
#include "esphome/core/defines.h"
|
||||
#if defined(USE_NETWORK) && defined(USE_ESP32)
|
||||
#include "esphome/core/component.h"
|
||||
|
||||
namespace esphome::network {
|
||||
class NetworkComponent : public Component {
|
||||
public:
|
||||
void setup() override;
|
||||
// AFTER_BLUETOOTH: BLE controller must initialize before esp_netif_init per IDF guidance.
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_BLUETOOTH; }
|
||||
};
|
||||
} // namespace esphome::network
|
||||
#endif
|
||||
@@ -139,7 +139,7 @@ async def _smpmgr_upload_connected(
|
||||
already_uploaded = True
|
||||
|
||||
if not already_uploaded:
|
||||
with open(firmware, "rb") as file:
|
||||
with firmware.open("rb") as file:
|
||||
image = file.read()
|
||||
upload_size = len(image)
|
||||
progress = ProgressBar("Uploading")
|
||||
|
||||
@@ -16,7 +16,7 @@ def define_has_component(component_type: str, keys: list[str]) -> None:
|
||||
cg.add_define(
|
||||
f"OPENTHERM_{component_type.upper()}_LIST(F, sep)",
|
||||
cg.RawExpression(
|
||||
" sep ".join(map(lambda key: f"F({key}_{component_type.lower()})", keys))
|
||||
" sep ".join(f"F({key}_{component_type.lower()})" for key in keys)
|
||||
),
|
||||
)
|
||||
for key in keys:
|
||||
@@ -30,12 +30,8 @@ def define_has_settings(keys: list[str], schemas: dict[str, SettingSchema]) -> N
|
||||
"OPENTHERM_SETTING_LIST(F, sep)",
|
||||
cg.RawExpression(
|
||||
" sep ".join(
|
||||
map(
|
||||
lambda key: (
|
||||
f"F({schemas[key].backing_type}, {key}_setting, {schemas[key].default_value})"
|
||||
),
|
||||
keys,
|
||||
)
|
||||
f"F({schemas[key].backing_type}, {key}_setting, {schemas[key].default_value})"
|
||||
for key in keys
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user