mirror of
https://github.com/esphome/esphome.git
synced 2026-09-04 03:56:04 +00:00
Compare commits
97
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70047d0948 | ||
|
|
ff0c5f575e | ||
|
|
f06ad8c436 | ||
|
|
29db5fa4bb | ||
|
|
85f33978e7 | ||
|
|
79786f1cc7 | ||
|
|
febf8815c7 | ||
|
|
6f6d991dd2 | ||
|
|
e9f7579910 | ||
|
|
a99c1b3e08 | ||
|
|
2d6af1f7e5 | ||
|
|
700676b340 | ||
|
|
bf1c339dc1 | ||
|
|
f5c1b8839d | ||
|
|
4404dd68ba | ||
|
|
67491c3194 | ||
|
|
39b2b901f7 | ||
|
|
f30ad588ea | ||
|
|
be82e8faeb | ||
|
|
57397a318a | ||
|
|
87a705b1cc | ||
|
|
edbb9f7b28 | ||
|
|
efff8fe8be | ||
|
|
f248302370 | ||
|
|
6fda5f41b2 | ||
|
|
ea2b2b3920 | ||
|
|
f33d137669 | ||
|
|
d28498ac2c | ||
|
|
556783b95b | ||
|
|
857e529803 | ||
|
|
197d4dac8e | ||
|
|
2d7f9dc48d | ||
|
|
be84e6c9f4 | ||
|
|
0418f2138a | ||
|
|
d9c22d6b56 | ||
|
|
60a94fd109 | ||
|
|
9371ec319a | ||
|
|
ce466c6b60 | ||
|
|
a460f5343c | ||
|
|
7c2a63bf82 | ||
|
|
690a197346 | ||
|
|
24d4da1021 | ||
|
|
53c4e6f386 | ||
|
|
1d63158480 | ||
|
|
013dee44eb | ||
|
|
9ddb828da3 | ||
|
|
120d1e51fb | ||
|
|
844a36f7a1 | ||
|
|
72a75f2d3f | ||
|
|
b5eb444015 | ||
|
|
15ab5422c7 | ||
|
|
33f88619da | ||
|
|
df1200629f | ||
|
|
41bd570d30 | ||
|
|
cf223674e5 | ||
|
|
af74b639cf | ||
|
|
4108b27197 | ||
|
|
7cfab58a05 | ||
|
|
85e1e4b95e | ||
|
|
0f174ee626 | ||
|
|
8046ff7e1e | ||
|
|
5e9db1c8c6 | ||
|
|
81d147ff9e | ||
|
|
58cb7effd4 | ||
|
|
3dd60c5713 | ||
|
|
f073c1cabe | ||
|
|
5cc447e0da | ||
|
|
0980630f68 | ||
|
|
b8dfffdf06 | ||
|
|
f6e39d305d | ||
|
|
08e5cb5576 | ||
|
|
faa61696e0 | ||
|
|
9999913d07 | ||
|
|
92aa98f680 | ||
|
|
3d69169141 | ||
|
|
24fdfcf1a1 | ||
|
|
550444dc34 | ||
|
|
ba7c06785a | ||
|
|
b708d1a826 | ||
|
|
148d478dec | ||
|
|
45e78e4114 | ||
|
|
3b3e003aa3 | ||
|
|
2f3e16b482 | ||
|
|
e085cb50d9 | ||
|
|
2fbfb4c385 | ||
|
|
61261b4a59 | ||
|
|
d48aad8c4d | ||
|
|
f1d3be4bda | ||
|
|
2758aa5517 | ||
|
|
a8b0133ec1 | ||
|
|
1398dcebb4 | ||
|
|
096d0c4279 | ||
|
|
e127268dac | ||
|
|
f0bffed3c0 | ||
|
|
1a871e231d | ||
|
|
47765bd2d0 | ||
|
|
8066325e0b |
@@ -27,9 +27,9 @@ jobs:
|
||||
|
||||
- name: Generate a token
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v2
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Auto Label PR
|
||||
|
||||
@@ -136,6 +136,53 @@ jobs:
|
||||
if-no-files-found: ignore
|
||||
retention-days: 14
|
||||
|
||||
device-builder:
|
||||
name: Test downstream esphome/device-builder
|
||||
runs-on: ubuntu-24.04
|
||||
needs:
|
||||
- common
|
||||
- determine-jobs
|
||||
if: needs.determine-jobs.outputs.device-builder == 'true'
|
||||
steps:
|
||||
- name: Check out esphome (this PR)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
path: esphome
|
||||
- name: Check out esphome/device-builder
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: esphome/device-builder
|
||||
ref: main
|
||||
path: device-builder
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Set up uv
|
||||
# Mirrors the install shape device-builder's own CI uses
|
||||
# (esphome/device-builder#192): uv replaces pip for the
|
||||
# install step (order-of-magnitude faster on cold boots,
|
||||
# with its own wheel cache). actions/setup-python still
|
||||
# provides the interpreter.
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
- 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
|
||||
# overlay the PR's esphome so the downstream tests run
|
||||
# against this PR's Python code. ``--system`` installs into
|
||||
# the runner's Python instead of a venv.
|
||||
run: |
|
||||
uv pip install --system -e './device-builder[esphome,test]'
|
||||
uv pip install --system -e ./esphome
|
||||
- name: Run device-builder pytest
|
||||
# ``-n auto`` runs under pytest-xdist (matches device-builder's
|
||||
# own CI). No ``--cov`` here -- this is purely a downstream
|
||||
# smoke check against this PR's esphome code.
|
||||
working-directory: device-builder
|
||||
run: pytest -q -n auto --maxfail=5 --durations=10 --no-cov --ignore=tests/benchmarks
|
||||
|
||||
pytest:
|
||||
name: Run pytest
|
||||
strategy:
|
||||
@@ -204,6 +251,7 @@ jobs:
|
||||
clang-tidy-mode: ${{ steps.determine.outputs.clang-tidy-mode }}
|
||||
python-linters: ${{ steps.determine.outputs.python-linters }}
|
||||
import-time: ${{ steps.determine.outputs.import-time }}
|
||||
device-builder: ${{ steps.determine.outputs.device-builder }}
|
||||
changed-components: ${{ steps.determine.outputs.changed-components }}
|
||||
changed-components-with-tests: ${{ steps.determine.outputs.changed-components-with-tests }}
|
||||
directly-changed-components-with-tests: ${{ steps.determine.outputs.directly-changed-components-with-tests }}
|
||||
@@ -247,6 +295,7 @@ jobs:
|
||||
echo "clang-tidy-mode=$(echo "$output" | jq -r '.clang_tidy_mode')" >> $GITHUB_OUTPUT
|
||||
echo "python-linters=$(echo "$output" | jq -r '.python_linters')" >> $GITHUB_OUTPUT
|
||||
echo "import-time=$(echo "$output" | jq -r '.import_time')" >> $GITHUB_OUTPUT
|
||||
echo "device-builder=$(echo "$output" | jq -r '.device_builder')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components=$(echo "$output" | jq -c '.changed_components')" >> $GITHUB_OUTPUT
|
||||
echo "changed-components-with-tests=$(echo "$output" | jq -c '.changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
echo "directly-changed-components-with-tests=$(echo "$output" | jq -c '.directly_changed_components_with_tests')" >> $GITHUB_OUTPUT
|
||||
@@ -1063,6 +1112,7 @@ jobs:
|
||||
- clang-tidy-nosplit
|
||||
- clang-tidy-split
|
||||
- determine-jobs
|
||||
- device-builder
|
||||
- test-build-components-split
|
||||
- pre-commit-ci-lite
|
||||
- memory-impact-target-branch
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
@@ -86,6 +86,6 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -8,4 +8,4 @@ on:
|
||||
|
||||
jobs:
|
||||
lock:
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@3c4e8446aa1029f1c346a482034b3ee1489077ca # 2026.4.0
|
||||
uses: esphome/workflows/.github/workflows/lock.yml@025a1e6255610c498ed590403b7e510b69e474df # 2026.4.1
|
||||
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: home-assistant-addon
|
||||
@@ -258,7 +258,7 @@ jobs:
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: esphome-schema
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
id: generate-token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }}
|
||||
client-id: ${{ vars.ESPHOME_GITHUB_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: esphome
|
||||
repositories: version-notifier
|
||||
|
||||
@@ -55,7 +55,7 @@ repos:
|
||||
hooks:
|
||||
- id: pylint
|
||||
name: pylint
|
||||
entry: python3 script/run-in-env.py pylint
|
||||
entry: python script/run-in-env.py pylint
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^esphome/.+\.py$
|
||||
@@ -68,5 +68,5 @@ repos:
|
||||
additional_dependencies: []
|
||||
- id: ci-custom
|
||||
name: ci-custom
|
||||
entry: python3 script/run-in-env.py script/ci-custom.py
|
||||
entry: python script/run-in-env.py script/ci-custom.py
|
||||
language: system
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
# ==============================================================================
|
||||
# Installs the latest prerelease of esphome-device-builder when the
|
||||
# `use_new_device_builder` config option is enabled.
|
||||
# This is a temporary install-on-boot step until esphome-device-builder
|
||||
# becomes a direct dependency of esphome.
|
||||
# ==============================================================================
|
||||
|
||||
if ! bashio::config.true 'use_new_device_builder'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
bashio::log.info "Installing latest prerelease of esphome-device-builder..."
|
||||
if command -v uv > /dev/null; then
|
||||
uv pip install --system --no-cache-dir --prerelease=allow --upgrade \
|
||||
esphome-device-builder ||
|
||||
bashio::exit.nok "Failed installing esphome-device-builder."
|
||||
else
|
||||
pip install --no-cache-dir --pre --upgrade esphome-device-builder ||
|
||||
bashio::exit.nok "Failed installing esphome-device-builder."
|
||||
fi
|
||||
bashio::log.info "Installed esphome-device-builder."
|
||||
@@ -49,5 +49,12 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then
|
||||
rm -rf /config/esphome/.esphome
|
||||
fi
|
||||
|
||||
if bashio::config.true 'use_new_device_builder'; then
|
||||
bashio::log.info "Starting ESPHome Device Builder..."
|
||||
exec esphome-device-builder /config/esphome \
|
||||
--ha-addon \
|
||||
--ingress-port "$(bashio::addon.ingress_port)"
|
||||
fi
|
||||
|
||||
bashio::log.info "Starting ESPHome dashboard..."
|
||||
exec esphome dashboard /config/esphome --socket /var/run/esphome.sock --ha-addon
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
# Community Hass.io Add-ons: ESPHome
|
||||
# Configures NGINX for use with ESPHome
|
||||
# ==============================================================================
|
||||
|
||||
# When the new device builder is enabled it serves HA ingress directly,
|
||||
# so nginx is not used at all -- skip configuration.
|
||||
if bashio::config.true 'use_new_device_builder'; then
|
||||
bashio::log.info "Skipping NGINX setup: new device builder serves ingress directly."
|
||||
bashio::exit.ok
|
||||
fi
|
||||
|
||||
mkdir -p /var/log/nginx
|
||||
|
||||
# Generate Ingress configuration
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
# Runs the NGINX proxy
|
||||
# ==============================================================================
|
||||
|
||||
# The new device builder handles HA ingress itself, so nginx is bypassed.
|
||||
# Block the longrun forever so s6 keeps the dependency satisfied and does
|
||||
# not respawn it.
|
||||
if bashio::config.true 'use_new_device_builder'; then
|
||||
bashio::log.info "NGINX bypassed: new device builder serves ingress directly."
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
bashio::log.info "Waiting for ESPHome dashboard to come up..."
|
||||
|
||||
while [[ ! -S /var/run/esphome.sock ]]; do
|
||||
|
||||
+251
-33
@@ -28,6 +28,7 @@ from esphome.const import (
|
||||
ALLOWED_NAME_CHARS,
|
||||
ARGUMENT_HELP_DEVICE,
|
||||
CONF_API,
|
||||
CONF_AUTH,
|
||||
CONF_BAUD_RATE,
|
||||
CONF_BROKER,
|
||||
CONF_DEASSERT_RTS_DTR,
|
||||
@@ -47,6 +48,8 @@ from esphome.const import (
|
||||
CONF_PORT,
|
||||
CONF_SUBSTITUTIONS,
|
||||
CONF_TOPIC,
|
||||
CONF_USERNAME,
|
||||
CONF_WEB_SERVER,
|
||||
ENV_NOGITIGNORE,
|
||||
KEY_CORE,
|
||||
KEY_NATIVE_IDF,
|
||||
@@ -63,6 +66,7 @@ from esphome.log import AnsiFore, color, setup_log
|
||||
from esphome.types import ConfigType
|
||||
from esphome.util import (
|
||||
PICOTOOL_PACKAGE,
|
||||
FlashImage,
|
||||
detect_rp2040_bootsel,
|
||||
get_picotool_path,
|
||||
get_serial_ports,
|
||||
@@ -348,6 +352,17 @@ def choose_upload_log_host(
|
||||
elif bootsel.permission_error:
|
||||
bootsel_permission_error = True
|
||||
|
||||
# Annotate the OTA chooser entry only in the non-default case: when the
|
||||
# config has web_server OTA but no native API OTA, the upload will fall
|
||||
# through to the HTTP path and the user benefits from seeing that
|
||||
# explicitly. The native-API path is the default and gets a plain label
|
||||
# to avoid noise on the most common scenario. For LOGGING the OTA
|
||||
# transport doesn't apply, so always leave the label plain.
|
||||
if purpose == Purpose.UPLOADING and not has_native_ota() and has_web_server_ota():
|
||||
ota_suffix = " via web_server"
|
||||
else:
|
||||
ota_suffix = ""
|
||||
|
||||
def add_ota_options() -> None:
|
||||
"""Add OTA options, using mDNS discovery if name_add_mac_suffix is enabled."""
|
||||
if (discovered := _discover_mac_suffix_devices()) is not None:
|
||||
@@ -355,11 +370,11 @@ def choose_upload_log_host(
|
||||
# intentionally skip the base-name fallback since with
|
||||
# name_add_mac_suffix on, the base name doesn't exist on the net.
|
||||
for host in discovered:
|
||||
options.append((f"Over The Air ({host})", host))
|
||||
options.append((f"Over The Air{ota_suffix} ({host})", host))
|
||||
elif has_resolvable_address():
|
||||
options.append((f"Over The Air ({CORE.address})", CORE.address))
|
||||
options.append((f"Over The Air{ota_suffix} ({CORE.address})", CORE.address))
|
||||
if has_mqtt_ip_lookup():
|
||||
options.append(("Over The Air (MQTT IP lookup)", "MQTTIP"))
|
||||
options.append((f"Over The Air{ota_suffix} (MQTT IP lookup)", "MQTTIP"))
|
||||
|
||||
if purpose == Purpose.LOGGING:
|
||||
if has_mqtt_logging():
|
||||
@@ -428,7 +443,19 @@ def has_api() -> bool:
|
||||
|
||||
|
||||
def has_ota() -> bool:
|
||||
"""Check if OTA upload is available (requires platform: esphome)."""
|
||||
"""Check if any network OTA upload is available.
|
||||
|
||||
True if the config exposes either ``platform: esphome`` (native API
|
||||
OTA) or ``platform: web_server`` (HTTP OTA). Both reach the device
|
||||
over the same network stack, so the OTA discovery path treats them
|
||||
interchangeably; ``upload_program`` picks the actual transport based
|
||||
on ``--ota-platform`` and what's configured.
|
||||
"""
|
||||
return has_native_ota() or has_web_server_ota()
|
||||
|
||||
|
||||
def has_native_ota() -> bool:
|
||||
"""Check if native API OTA upload is available (``platform: esphome``)."""
|
||||
if CONF_OTA not in CORE.config:
|
||||
return False
|
||||
return any(
|
||||
@@ -437,6 +464,16 @@ def has_ota() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def has_web_server_ota() -> bool:
|
||||
"""Check if web_server OTA upload is available (``platform: web_server``)."""
|
||||
if CONF_OTA not in CORE.config:
|
||||
return False
|
||||
return any(
|
||||
ota_item.get(CONF_PLATFORM) == CONF_WEB_SERVER
|
||||
for ota_item in CORE.config[CONF_OTA]
|
||||
)
|
||||
|
||||
|
||||
def has_mqtt_ip_lookup() -> bool:
|
||||
"""Check if MQTT is available and IP lookup is supported."""
|
||||
from esphome.components.mqtt import CONF_DISCOVER_IP
|
||||
@@ -586,8 +623,6 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
from aioesphomeapi import LogParser
|
||||
import serial
|
||||
|
||||
from esphome import platformio_api
|
||||
|
||||
if CONF_LOGGER not in config:
|
||||
_LOGGER.info("Logger is not enabled. Not starting UART logs.")
|
||||
return 1
|
||||
@@ -602,8 +637,11 @@ 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")
|
||||
except AttributeError:
|
||||
pass
|
||||
except (AttributeError, ImportError):
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
CORE.target_platform,
|
||||
)
|
||||
|
||||
backtrace_state = False
|
||||
ser = serial.Serial()
|
||||
@@ -646,14 +684,10 @@ def run_miniterm(config: ConfigType, port: str, args) -> int:
|
||||
)
|
||||
safe_print(parser.parse_line(line, time_str))
|
||||
|
||||
if process_stacktrace:
|
||||
if process_stacktrace is not None:
|
||||
backtrace_state = process_stacktrace(
|
||||
config, line, backtrace_state
|
||||
)
|
||||
else:
|
||||
backtrace_state = platformio_api.process_stacktrace(
|
||||
config, line, backtrace_state=backtrace_state
|
||||
)
|
||||
except serial.SerialException:
|
||||
_LOGGER.error("Serial port closed!")
|
||||
return 0
|
||||
@@ -843,22 +877,20 @@ def _make_crystal_freq_callback(
|
||||
def upload_using_esptool(
|
||||
config: ConfigType, port: str, file: str, speed: int
|
||||
) -> str | int:
|
||||
from esphome import platformio_api
|
||||
|
||||
first_baudrate = speed or config[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS].get(
|
||||
"upload_speed", os.getenv("ESPHOME_UPLOAD_SPEED", "460800")
|
||||
)
|
||||
|
||||
if file is not None:
|
||||
flash_images = [platformio_api.FlashImage(path=file, offset="0x0")]
|
||||
flash_images = [FlashImage(path=file, offset="0x0")]
|
||||
else:
|
||||
from esphome import platformio_api
|
||||
|
||||
idedata = platformio_api.get_idedata(config)
|
||||
|
||||
firmware_offset = "0x10000" if CORE.is_esp32 else "0x0"
|
||||
flash_images = [
|
||||
platformio_api.FlashImage(
|
||||
path=idedata.firmware_bin_path, offset=firmware_offset
|
||||
),
|
||||
FlashImage(path=idedata.firmware_bin_path, offset=firmware_offset),
|
||||
]
|
||||
for image in idedata.extra_flash_images:
|
||||
if not image.path.is_file():
|
||||
@@ -1091,6 +1123,15 @@ def upload_program(
|
||||
|
||||
port_type = get_port_type(host)
|
||||
|
||||
# MQTT and MQTTIP are also OTA paths; MQTTIP gets resolved to a real IP later by
|
||||
# _resolve_network_devices(). Only SERIAL and BOOTSEL are non-OTA upload paths.
|
||||
if port_type in (PortType.SERIAL, PortType.BOOTSEL) and getattr(
|
||||
args, "partition_table", False
|
||||
):
|
||||
raise EsphomeError(
|
||||
"The option --partition-table can only be used for Over The Air updates."
|
||||
)
|
||||
|
||||
if port_type == PortType.BOOTSEL:
|
||||
exit_code = upload_using_picotool(config)
|
||||
# Return None for device - BOOTSEL can't be used for logging,
|
||||
@@ -1110,30 +1151,179 @@ def upload_program(
|
||||
|
||||
return exit_code, host if exit_code == 0 else None
|
||||
|
||||
ota_conf = {}
|
||||
requested_platform = getattr(args, "ota_platform", None)
|
||||
chosen_platform = _choose_ota_platform(config, requested_platform)
|
||||
|
||||
# Resolve MQTT magic strings to actual IP addresses
|
||||
network_devices = _resolve_network_devices(devices, config, args)
|
||||
|
||||
if chosen_platform == CONF_WEB_SERVER:
|
||||
if getattr(args, "partition_table", False):
|
||||
raise EsphomeError(
|
||||
"--partition-table is only supported with the esphome OTA platform; "
|
||||
"the web_server OTA path can only update the firmware image."
|
||||
)
|
||||
binary = CORE.firmware_bin
|
||||
if getattr(args, "file", None) is not None:
|
||||
binary = Path(args.file)
|
||||
return _upload_via_web_server(config, network_devices, binary)
|
||||
|
||||
return _upload_via_native_api(config, network_devices, args)
|
||||
|
||||
|
||||
def _choose_ota_platform(config: ConfigType, requested: str | None) -> str:
|
||||
"""Pick the OTA platform to use, optionally honoring ``--ota-platform``.
|
||||
|
||||
Default behavior prefers ``esphome`` (native API) when it is configured.
|
||||
The native API uses challenge-response auth with MD5/SHA256 hashing of a
|
||||
server-issued nonce, so the password is never sent over the wire; the
|
||||
``web_server`` path uses HTTP Basic auth which transmits credentials in
|
||||
cleartext over the LAN. (The native path also supports gzip compression
|
||||
on ESP8266, where flash space is tight; on ESP32/RP2040/LibreTiny the
|
||||
backend reports ``supports_compression() == false`` and the firmware is
|
||||
sent uncompressed regardless of which platform is used.) Falls back to
|
||||
``web_server`` only when that is the only available platform.
|
||||
"""
|
||||
# Use a dict (insertion-ordered) instead of a list so error messages and
|
||||
# membership checks see one entry per platform even if the user has
|
||||
# multiple ``ota:`` items of the same platform; the web_server OTA
|
||||
# platform's final-validate hook merges duplicates anyway.
|
||||
available: dict[str, None] = {}
|
||||
for ota_item in config.get(CONF_OTA, []):
|
||||
if ota_item[CONF_PLATFORM] == CONF_ESPHOME:
|
||||
platform = ota_item.get(CONF_PLATFORM)
|
||||
if platform in (CONF_ESPHOME, CONF_WEB_SERVER):
|
||||
available[platform] = None
|
||||
|
||||
if not available:
|
||||
raise EsphomeError(
|
||||
f"Cannot upload Over the Air as the {CONF_OTA} configuration is not "
|
||||
f"present or does not include {CONF_PLATFORM}: {CONF_ESPHOME} or "
|
||||
f"{CONF_PLATFORM}: {CONF_WEB_SERVER}"
|
||||
)
|
||||
|
||||
if requested is not None:
|
||||
if requested not in available:
|
||||
raise EsphomeError(
|
||||
f"--ota-platform {requested} was requested but the configuration "
|
||||
f"only provides: {', '.join(available)}"
|
||||
)
|
||||
return requested
|
||||
|
||||
if CONF_ESPHOME in available:
|
||||
return CONF_ESPHOME
|
||||
return CONF_WEB_SERVER
|
||||
|
||||
|
||||
def _upload_via_native_api(
|
||||
config: ConfigType, network_devices: list[str], args: ArgsProtocol
|
||||
) -> tuple[int, str | None]:
|
||||
ota_conf: ConfigType = {}
|
||||
for ota_item in config.get(CONF_OTA, []):
|
||||
if ota_item.get(CONF_PLATFORM) == CONF_ESPHOME:
|
||||
ota_conf = ota_item
|
||||
break
|
||||
|
||||
if not ota_conf:
|
||||
raise EsphomeError(
|
||||
f"Cannot upload Over the Air as the {CONF_OTA} configuration is not present or does not include {CONF_PLATFORM}: {CONF_ESPHOME}"
|
||||
)
|
||||
|
||||
from esphome import espota2
|
||||
|
||||
remote_port = int(ota_conf[CONF_PORT])
|
||||
password = ota_conf.get(CONF_PASSWORD)
|
||||
|
||||
binary = CORE.firmware_bin
|
||||
ota_type = espota2.OTA_TYPE_UPDATE_APP
|
||||
if getattr(args, "partition_table", False):
|
||||
# Fail fast if the resolved ESPHome OTA config does not enable allow_partition_access.
|
||||
# The device-side handshake also rejects this with "Device only supports app updates",
|
||||
# but checking here surfaces the misconfiguration before opening a network connection.
|
||||
if not ota_conf.get("allow_partition_access"):
|
||||
raise EsphomeError(
|
||||
"The option --partition-table requires 'allow_partition_access: true' on the "
|
||||
"esphome OTA platform in the device's YAML configuration. Add it, recompile, "
|
||||
"flash a build with the option enabled, and then retry --partition-table."
|
||||
)
|
||||
binary = CORE.partition_table_bin
|
||||
ota_type = espota2.OTA_TYPE_UPDATE_PARTITION_TABLE
|
||||
if getattr(args, "file", None) is not None:
|
||||
binary = Path(args.file)
|
||||
else:
|
||||
binary = CORE.firmware_bin
|
||||
|
||||
# Resolve MQTT magic strings to actual IP addresses
|
||||
network_devices = _resolve_network_devices(devices, config, args)
|
||||
if ota_type == espota2.OTA_TYPE_UPDATE_PARTITION_TABLE:
|
||||
_validate_partition_table_binary(binary)
|
||||
|
||||
return espota2.run_ota(network_devices, remote_port, password, binary)
|
||||
return espota2.run_ota(network_devices, remote_port, password, binary, ota_type)
|
||||
|
||||
|
||||
def _upload_via_web_server(
|
||||
config: ConfigType, network_devices: list[str], binary: Path
|
||||
) -> tuple[int, str | None]:
|
||||
web_conf = config.get(CONF_WEB_SERVER)
|
||||
if not web_conf:
|
||||
raise EsphomeError(
|
||||
f"Cannot upload via web_server OTA: the {CONF_WEB_SERVER} component "
|
||||
f"is not configured."
|
||||
)
|
||||
|
||||
remote_port = int(web_conf[CONF_PORT])
|
||||
auth = web_conf.get(CONF_AUTH) or {}
|
||||
username = auth.get(CONF_USERNAME)
|
||||
password = auth.get(CONF_PASSWORD)
|
||||
|
||||
from esphome import web_server_ota
|
||||
|
||||
return web_server_ota.run_ota(
|
||||
network_devices, remote_port, username, password, binary
|
||||
)
|
||||
|
||||
|
||||
# Layout of esp_partition_info_t on flash. Each entry is 32 bytes, leading with a
|
||||
# 16-bit little-endian magic. ESP-IDF defines ESP_PARTITION_MAGIC = 0x50AA (stored as
|
||||
# bytes 0xAA, 0x50) for partition entries and ESP_PARTITION_MAGIC_MD5 = 0xEBEB for the
|
||||
# trailing checksum entry. Padding past the last entry is 0xFF. The full table is
|
||||
# exactly ESP_PARTITION_TABLE_MAX_LEN bytes.
|
||||
_PARTITION_TABLE_MAX_LEN = 0xC00
|
||||
_ESP_PARTITION_MAGIC = 0x50AA
|
||||
_ESP_PARTITION_MAGIC_MD5 = 0xEBEB
|
||||
|
||||
|
||||
def _validate_partition_table_binary(binary: Path) -> None:
|
||||
"""Validate that ``binary`` looks like an ESP32 partition table image.
|
||||
|
||||
Catches common mistakes (wrong file, truncated build output, swapped --file path)
|
||||
before opening a network connection so the failure mode is a clear local error
|
||||
instead of a post-handshake device rejection.
|
||||
"""
|
||||
try:
|
||||
data = binary.read_bytes()
|
||||
except OSError as err:
|
||||
raise EsphomeError(
|
||||
f"Cannot read partition table file '{binary}': {err}"
|
||||
) from err
|
||||
|
||||
if len(data) != _PARTITION_TABLE_MAX_LEN:
|
||||
raise EsphomeError(
|
||||
f"Partition table file '{binary}' has wrong size: expected "
|
||||
f"{_PARTITION_TABLE_MAX_LEN} bytes, got {len(data)}. "
|
||||
"Pass the partition table image (e.g. partitions.bin / partition-table.bin), "
|
||||
"not the firmware image."
|
||||
)
|
||||
|
||||
first_magic = data[0] | (data[1] << 8)
|
||||
if first_magic != _ESP_PARTITION_MAGIC:
|
||||
raise EsphomeError(
|
||||
f"Partition table file '{binary}' does not start with the expected "
|
||||
f"partition magic 0x{_ESP_PARTITION_MAGIC:04X} (got 0x{first_magic:04X}). "
|
||||
"This file does not look like an ESP32 partition table."
|
||||
)
|
||||
|
||||
# The MD5 checksum entry is required: without it the device-side
|
||||
# esp_partition_table_verify will accept the table but the bootloader will
|
||||
# refuse to boot from it. Scan the 32-byte entries for the MD5 magic.
|
||||
if not any(
|
||||
(data[off] | (data[off + 1] << 8)) == _ESP_PARTITION_MAGIC_MD5
|
||||
for off in range(0, _PARTITION_TABLE_MAX_LEN, 32)
|
||||
):
|
||||
raise EsphomeError(
|
||||
f"Partition table file '{binary}' is missing the MD5 checksum entry. "
|
||||
"Regenerate the partition table with gen_esp32part.py or rebuild the project."
|
||||
)
|
||||
|
||||
|
||||
def show_logs(config: ConfigType, args: ArgsProtocol, devices: list[str]) -> int | None:
|
||||
@@ -1803,6 +1993,22 @@ def parse_args(argv):
|
||||
"--file",
|
||||
help="Manually specify the binary file to upload.",
|
||||
)
|
||||
parser_upload.add_argument(
|
||||
"--ota-platform",
|
||||
choices=[CONF_ESPHOME, CONF_WEB_SERVER],
|
||||
help=(
|
||||
"OTA platform to use for network uploads. Defaults to "
|
||||
f"'{CONF_ESPHOME}' (native API) when configured because it uses "
|
||||
"challenge-response auth so the password is never sent in "
|
||||
f"cleartext on the wire. Falls back to '{CONF_WEB_SERVER}' "
|
||||
"(HTTP Basic auth) when that is the only configured platform."
|
||||
),
|
||||
)
|
||||
parser_upload.add_argument(
|
||||
"--partition-table",
|
||||
help="Upload as partition table (OTA).",
|
||||
action="store_true",
|
||||
)
|
||||
|
||||
parser_logs = subparsers.add_parser(
|
||||
"logs",
|
||||
@@ -1872,6 +2078,17 @@ def parse_args(argv):
|
||||
help="Build with native ESP-IDF instead of PlatformIO (ESP32 esp-idf framework only).",
|
||||
action="store_true",
|
||||
)
|
||||
parser_run.add_argument(
|
||||
"--ota-platform",
|
||||
choices=[CONF_ESPHOME, CONF_WEB_SERVER],
|
||||
help=(
|
||||
"OTA platform to use for network uploads. Defaults to "
|
||||
f"'{CONF_ESPHOME}' (native API) when configured because it uses "
|
||||
"challenge-response auth so the password is never sent in "
|
||||
f"cleartext on the wire. Falls back to '{CONF_WEB_SERVER}' "
|
||||
"(HTTP Basic auth) when that is the only configured platform."
|
||||
),
|
||||
)
|
||||
|
||||
parser_clean = subparsers.add_parser(
|
||||
"clean-mqtt",
|
||||
@@ -2084,8 +2301,9 @@ def run_esphome(argv):
|
||||
CORE.config_path = conf_path
|
||||
CORE.dashboard = args.dashboard
|
||||
|
||||
# For logs command, skip updating external components
|
||||
skip_external = args.command == "logs"
|
||||
# Commands that don't need fresh external components: logs just connects
|
||||
# to the device, and clean is about to delete the build directory.
|
||||
skip_external = args.command in ("logs", "clean")
|
||||
config = read_config(
|
||||
dict(args.substitution) if args.substitution else {},
|
||||
skip_external_update=skip_external,
|
||||
|
||||
+7
-5
@@ -98,11 +98,13 @@ _KNOWN_FILE_EXTENSIONS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
# Matches !secret references in YAML text. This is intentionally a simple
|
||||
# regex scan rather than a YAML parse — it may match inside comments or
|
||||
# multi-line strings, which is the conservative direction (include more
|
||||
# secrets rather than fewer).
|
||||
_SECRET_RE = re.compile(r"!secret\s+(\S+)")
|
||||
# Matches !secret references in YAML text. An optional surrounding
|
||||
# quote pair around the key is allowed and ignored: YAML treats
|
||||
# ``!secret 'foo'`` and ``!secret foo`` as the same key. This is
|
||||
# intentionally a simple regex scan rather than a YAML parse — it may
|
||||
# match inside comments or multi-line strings, which is the conservative
|
||||
# direction (include more secrets rather than fewer).
|
||||
_SECRET_RE = re.compile(r"""!secret\s+['"]?([^\s'"]+)""")
|
||||
|
||||
|
||||
def _find_used_secret_keys(yaml_files: list[Path]) -> set[str]:
|
||||
|
||||
@@ -72,17 +72,35 @@ APIUnregisterServiceCallAction = api_ns.class_(
|
||||
|
||||
UserServiceTrigger = api_ns.class_("UserServiceTrigger", automation.Trigger)
|
||||
ListEntitiesServicesArgument = api_ns.class_("ListEntitiesServicesArgument")
|
||||
SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = {
|
||||
# Owning element type for each YAML service variable type. Used to derive both
|
||||
# the zero-copy native types and the owning fallback types below.
|
||||
_SERVICE_ARG_SCALAR_TYPES: dict[str, MockObj] = {
|
||||
"bool": cg.bool_,
|
||||
"int": cg.int32,
|
||||
"float": cg.float_,
|
||||
"string": cg.std_string,
|
||||
}
|
||||
SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = {
|
||||
# Scalars are passed by value; string uses a non-owning view into rx_buf_.
|
||||
**_SERVICE_ARG_SCALAR_TYPES,
|
||||
"string": cg.StringRef,
|
||||
"bool[]": cg.FixedVector.template(cg.bool_).operator("const").operator("ref"),
|
||||
"int[]": cg.FixedVector.template(cg.int32).operator("const").operator("ref"),
|
||||
"float[]": cg.FixedVector.template(cg.float_).operator("const").operator("ref"),
|
||||
"string[]": cg.FixedVector.template(cg.std_string)
|
||||
.operator("const")
|
||||
.operator("ref"),
|
||||
# Arrays are passed as non-owning const references into rx_buf_.
|
||||
**{
|
||||
f"{name}[]": cg.FixedVector.template(t).operator("const").operator("ref")
|
||||
for name, t in _SERVICE_ARG_SCALAR_TYPES.items()
|
||||
},
|
||||
}
|
||||
# Owning fallback types used when the action chain contains non-synchronous actions
|
||||
# (delay, wait_until, script.wait, etc.). The default non-owning types reference
|
||||
# storage in the receive buffer, which is reused once the synchronous portion of
|
||||
# the chain returns. FixedVector is also non-copyable, so the deferred lambda
|
||||
# capture in DelayAction::play_complex would fail to compile.
|
||||
SERVICE_ARG_FALLBACK_TYPES: dict[str, MockObj] = {
|
||||
"string": cg.std_string,
|
||||
**{
|
||||
f"{name}[]": cg.std_vector.template(t)
|
||||
for name, t in _SERVICE_ARG_SCALAR_TYPES.items()
|
||||
},
|
||||
}
|
||||
CONF_ENCRYPTION = "encryption"
|
||||
CONF_BATCH_DELAY = "batch_delay"
|
||||
@@ -381,17 +399,20 @@ async def to_code(config: ConfigType) -> None:
|
||||
func_args.append((cg.bool_, "return_response"))
|
||||
|
||||
# Check if action chain has non-synchronous actions that would make
|
||||
# non-owning StringRef dangle (rx_buf_ reused after delay)
|
||||
# non-owning args (StringRef, const FixedVector&) dangle once the
|
||||
# rx_buf_ is reused after a delay/wait_until/script.wait/etc. The
|
||||
# FixedVector references would also fail to compile because they
|
||||
# are non-copyable and DelayAction captures args by value.
|
||||
has_non_synchronous = automation.has_non_synchronous_actions(
|
||||
conf.get(CONF_THEN, [])
|
||||
)
|
||||
|
||||
service_arg_names: list[str] = []
|
||||
for name, var_ in conf[CONF_VARIABLES].items():
|
||||
native = SERVICE_ARG_NATIVE_TYPES[var_]
|
||||
# Fall back to std::string for string args if non-synchronous actions exist
|
||||
if has_non_synchronous and native is cg.StringRef:
|
||||
native = cg.std_string
|
||||
if has_non_synchronous and var_ in SERVICE_ARG_FALLBACK_TYPES:
|
||||
native = SERVICE_ARG_FALLBACK_TYPES[var_]
|
||||
else:
|
||||
native = SERVICE_ARG_NATIVE_TYPES[var_]
|
||||
service_template_args.append(native)
|
||||
func_args.append((native, name))
|
||||
service_arg_names.append(name)
|
||||
|
||||
@@ -2511,6 +2511,7 @@ message ZWaveProxyFrame {
|
||||
option (source) = SOURCE_BOTH;
|
||||
option (ifdef) = "USE_ZWAVE_PROXY";
|
||||
option (no_delay) = true;
|
||||
option (speed_optimized) = true;
|
||||
|
||||
bytes data = 1;
|
||||
}
|
||||
@@ -2571,6 +2572,7 @@ message InfraredRFReceiveEvent {
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_IR_RF || USE_RADIO_FREQUENCY";
|
||||
option (no_delay) = true;
|
||||
option (speed_optimized) = true;
|
||||
|
||||
uint32 device_id = 1 [(field_ifdef) = "USE_DEVICES"];
|
||||
fixed32 key = 2 [(force) = true]; // Key identifying the receiver instance
|
||||
@@ -2627,6 +2629,7 @@ message SerialProxyDataReceived {
|
||||
option (source) = SOURCE_SERVER;
|
||||
option (ifdef) = "USE_SERIAL_PROXY";
|
||||
option (no_delay) = true;
|
||||
option (speed_optimized) = true;
|
||||
|
||||
uint32 instance = 1; // Instance index (0-based)
|
||||
bytes data = 2; // Raw data received from the serial device
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
// DNM: integration-test bucketing CI probe — do not merge.
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_API
|
||||
#include "api_frame_helper.h"
|
||||
|
||||
@@ -3784,12 +3784,16 @@ bool ZWaveProxyFrame::decode_length(uint32_t field_id, ProtoLengthDelimited valu
|
||||
}
|
||||
return true;
|
||||
}
|
||||
uint8_t *ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint8_t *
|
||||
ZWaveProxyFrame::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 1, this->data, this->data_len);
|
||||
return pos;
|
||||
}
|
||||
uint32_t ZWaveProxyFrame::calculate_size() const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint32_t
|
||||
ZWaveProxyFrame::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_length(1, this->data_len);
|
||||
return size;
|
||||
@@ -3910,7 +3914,9 @@ bool InfraredRFTransmitRawTimingsRequest::decode_32bit(uint32_t field_id, Proto3
|
||||
}
|
||||
return true;
|
||||
}
|
||||
uint8_t *InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint8_t *
|
||||
InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
#ifdef USE_DEVICES
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->device_id);
|
||||
@@ -3921,7 +3927,9 @@ uint8_t *InfraredRFReceiveEvent::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DE
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
uint32_t InfraredRFReceiveEvent::calculate_size() const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint32_t
|
||||
InfraredRFReceiveEvent::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
#ifdef USE_DEVICES
|
||||
size += ProtoSize::calc_uint32(1, this->device_id);
|
||||
@@ -4001,13 +4009,17 @@ bool SerialProxyConfigureRequest::decode_varint(uint32_t field_id, proto_varint_
|
||||
}
|
||||
return true;
|
||||
}
|
||||
uint8_t *SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint8_t *
|
||||
SerialProxyDataReceived::encode(ProtoWriteBuffer &buffer PROTO_ENCODE_DEBUG_PARAM) const {
|
||||
uint8_t *__restrict__ pos = buffer.get_pos();
|
||||
ProtoEncode::encode_uint32(pos PROTO_ENCODE_DEBUG_ARG, 1, this->instance);
|
||||
ProtoEncode::encode_bytes(pos PROTO_ENCODE_DEBUG_ARG, 2, this->data_ptr_, this->data_len_);
|
||||
return pos;
|
||||
}
|
||||
uint32_t SerialProxyDataReceived::calculate_size() const {
|
||||
__attribute__((optimize("O2"))) // NOLINT(clang-diagnostic-unknown-attributes)
|
||||
uint32_t
|
||||
SerialProxyDataReceived::calculate_size() const {
|
||||
uint32_t size = 0;
|
||||
size += ProtoSize::calc_uint32(1, this->instance);
|
||||
size += ProtoSize::calc_length(1, this->data_len_);
|
||||
|
||||
@@ -18,8 +18,8 @@ with warnings.catch_warnings():
|
||||
import contextlib
|
||||
|
||||
from esphome.const import CONF_KEY, CONF_PORT, __version__
|
||||
from esphome.core import CORE
|
||||
from esphome.platformio_api import process_stacktrace
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.util import safe_print
|
||||
|
||||
from . import CONF_ENCRYPTION
|
||||
|
||||
@@ -32,6 +32,48 @@ if TYPE_CHECKING:
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _LogLineProcessor:
|
||||
"""Feeds incoming log lines to the stack-trace decoder.
|
||||
|
||||
Two responsibilities beyond just calling the decoder:
|
||||
1. Catch EsphomeError. on_log runs inside an asyncio protocol
|
||||
callback; if an exception escapes, the loop tears the transport
|
||||
down with "Fatal error: protocol.data_received() call failed."
|
||||
and ReconnectLogic immediately reconnects, the device replays
|
||||
the same crash trace, and we loop forever.
|
||||
2. Disable decoding after the first failure. _decode_pc shells out
|
||||
to PlatformIO via _run_idedata, which is expensive; a single
|
||||
crash dump can contain many PC/BT lines and we don't want to
|
||||
retry the failing subprocess for each one.
|
||||
"""
|
||||
|
||||
def __init__(self, config: dict[str, Any], platform_handler: Any | None) -> None:
|
||||
self._config = config
|
||||
self._platform_handler = platform_handler
|
||||
self._decode_enabled = True
|
||||
self.backtrace_state = False
|
||||
|
||||
def process_line(self, raw_line: str) -> None:
|
||||
if not self._decode_enabled:
|
||||
return
|
||||
try:
|
||||
if self._platform_handler is not None:
|
||||
self.backtrace_state = self._platform_handler(
|
||||
self._config, raw_line, self.backtrace_state
|
||||
)
|
||||
except EsphomeError as exc:
|
||||
self._decode_enabled = False
|
||||
self.backtrace_state = False
|
||||
# _run_idedata raises EsphomeError with no message; fall back
|
||||
# to a generic explanation when str(exc) is empty.
|
||||
detail = str(exc) or "build artifacts not found locally"
|
||||
_LOGGER.warning(
|
||||
"Crash trace decoding unavailable: %s. "
|
||||
"Run 'esphome compile' for this device to enable PC decoding.",
|
||||
detail,
|
||||
)
|
||||
|
||||
|
||||
async def async_run_logs(
|
||||
config: dict[str, Any],
|
||||
addresses: list[str],
|
||||
@@ -60,8 +102,6 @@ async def async_run_logs(
|
||||
noise_psk=noise_psk,
|
||||
addresses=addresses, # Pass all addresses for automatic retry
|
||||
)
|
||||
dashboard = CORE.dashboard
|
||||
backtrace_state = False
|
||||
|
||||
# Try platform-specific stacktrace handler first, fall back to generic
|
||||
platform_process_stacktrace = None
|
||||
@@ -69,11 +109,15 @@ async def async_run_logs(
|
||||
module = importlib.import_module("esphome.components." + CORE.target_platform)
|
||||
platform_process_stacktrace = getattr(module, "process_stacktrace")
|
||||
except (AttributeError, ImportError):
|
||||
pass
|
||||
_LOGGER.info(
|
||||
'Stacktrace analysis is unavailable: no compatible analyzer found for target platform "%s".',
|
||||
CORE.target_platform,
|
||||
)
|
||||
|
||||
processor = _LogLineProcessor(config, platform_process_stacktrace)
|
||||
|
||||
def on_log(msg: SubscribeLogsResponse) -> None:
|
||||
"""Handle a new log message."""
|
||||
nonlocal backtrace_state
|
||||
time_ = datetime.now()
|
||||
message: bytes = msg.message
|
||||
text = message.decode("utf8", "backslashreplace")
|
||||
@@ -82,16 +126,13 @@ async def async_run_logs(
|
||||
f"[{time_.hour:02}:{time_.minute:02}:{time_.second:02}.{nanoseconds:03}]"
|
||||
)
|
||||
for parsed_msg in parse_log_message(text, timestamp):
|
||||
print(parsed_msg.replace("\033", "\\033") if dashboard else parsed_msg)
|
||||
# safe_print handles the dashboard \033 escaping and falls back
|
||||
# to backslashreplace encoding on stdouts that can't represent
|
||||
# the wifi signal-bar block characters (Windows redirected
|
||||
# cp1252 pipe).
|
||||
safe_print(parsed_msg)
|
||||
for raw_line in text.splitlines():
|
||||
if platform_process_stacktrace:
|
||||
backtrace_state = platform_process_stacktrace(
|
||||
config, raw_line, backtrace_state
|
||||
)
|
||||
else:
|
||||
backtrace_state = process_stacktrace(
|
||||
config, raw_line, backtrace_state=backtrace_state
|
||||
)
|
||||
processor.process_line(raw_line)
|
||||
|
||||
# Safe to fall back to plaintext here only for this diagnostics use
|
||||
# case: the stream is one-way from device to client, and this code
|
||||
|
||||
@@ -18,83 +18,22 @@ class APIConnection;
|
||||
class ListEntitiesIterator final : public ComponentIterator {
|
||||
public:
|
||||
ListEntitiesIterator(APIConnection *client);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
bool on_binary_sensor(binary_sensor::BinarySensor *entity) override;
|
||||
#endif
|
||||
#ifdef USE_COVER
|
||||
bool on_cover(cover::Cover *entity) override;
|
||||
#endif
|
||||
#ifdef USE_FAN
|
||||
bool on_fan(fan::Fan *entity) override;
|
||||
#endif
|
||||
#ifdef USE_LIGHT
|
||||
bool on_light(light::LightState *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
bool on_sensor(sensor::Sensor *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SWITCH
|
||||
bool on_switch(switch_::Switch *entity) override;
|
||||
#endif
|
||||
#ifdef USE_BUTTON
|
||||
bool on_button(button::Button *entity) override;
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
bool on_text_sensor(text_sensor::TextSensor *entity) override;
|
||||
#endif
|
||||
|
||||
// Entity overrides (generated from entity_types.h).
|
||||
// All implementations live in list_entities.cpp via LIST_ENTITIES_HANDLER.
|
||||
// NOLINTBEGIN(bugprone-macro-parentheses)
|
||||
#define ENTITY_TYPE_(type, singular, plural, count, upper) bool on_##singular(type *entity) override;
|
||||
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
|
||||
ENTITY_TYPE_(type, singular, plural, count, upper)
|
||||
#include "esphome/core/entity_types.h"
|
||||
#undef ENTITY_TYPE_
|
||||
#undef ENTITY_CONTROLLER_TYPE_
|
||||
// NOLINTEND(bugprone-macro-parentheses)
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
bool on_service(UserServiceDescriptor *service) override;
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
bool on_camera(camera::Camera *entity) override;
|
||||
#endif
|
||||
#ifdef USE_CLIMATE
|
||||
bool on_climate(climate::Climate *entity) override;
|
||||
#endif
|
||||
#ifdef USE_NUMBER
|
||||
bool on_number(number::Number *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_DATE
|
||||
bool on_date(datetime::DateEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_TIME
|
||||
bool on_time(datetime::TimeEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_DATETIME
|
||||
bool on_datetime(datetime::DateTimeEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_TEXT
|
||||
bool on_text(text::Text *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
bool on_select(select::Select *entity) override;
|
||||
#endif
|
||||
#ifdef USE_LOCK
|
||||
bool on_lock(lock::Lock *entity) override;
|
||||
#endif
|
||||
#ifdef USE_VALVE
|
||||
bool on_valve(valve::Valve *entity) override;
|
||||
#endif
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
bool on_media_player(media_player::MediaPlayer *entity) override;
|
||||
#endif
|
||||
#ifdef USE_ALARM_CONTROL_PANEL
|
||||
bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override;
|
||||
#endif
|
||||
#ifdef USE_WATER_HEATER
|
||||
bool on_water_heater(water_heater::WaterHeater *entity) override;
|
||||
#endif
|
||||
#ifdef USE_INFRARED
|
||||
bool on_infrared(infrared::Infrared *entity) override;
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
bool on_radio_frequency(radio_frequency::RadioFrequency *entity) override;
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
bool on_event(event::Event *entity) override;
|
||||
#endif
|
||||
#ifdef USE_UPDATE
|
||||
bool on_update(update::UpdateEntity *entity) override;
|
||||
#endif
|
||||
bool on_end() override;
|
||||
|
||||
|
||||
@@ -67,7 +67,10 @@ INITIAL_STATE_HANDLER(water_heater, water_heater::WaterHeater)
|
||||
INITIAL_STATE_HANDLER(update, update::UpdateEntity)
|
||||
#endif
|
||||
|
||||
// Special cases (button and event) are already defined inline in subscribe_state.h
|
||||
// event is an ENTITY_CONTROLLER_TYPE_ but has no state to send.
|
||||
#ifdef USE_EVENT
|
||||
bool InitialStateIterator::on_event(event::Event *entity) { return true; }
|
||||
#endif
|
||||
|
||||
InitialStateIterator::InitialStateIterator(APIConnection *client) : client_(client) {}
|
||||
|
||||
|
||||
@@ -19,78 +19,20 @@ class APIConnection;
|
||||
class InitialStateIterator final : public ComponentIterator {
|
||||
public:
|
||||
InitialStateIterator(APIConnection *client);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
bool on_binary_sensor(binary_sensor::BinarySensor *entity) override;
|
||||
#endif
|
||||
#ifdef USE_COVER
|
||||
bool on_cover(cover::Cover *entity) override;
|
||||
#endif
|
||||
#ifdef USE_FAN
|
||||
bool on_fan(fan::Fan *entity) override;
|
||||
#endif
|
||||
#ifdef USE_LIGHT
|
||||
bool on_light(light::LightState *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
bool on_sensor(sensor::Sensor *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SWITCH
|
||||
bool on_switch(switch_::Switch *entity) override;
|
||||
#endif
|
||||
#ifdef USE_BUTTON
|
||||
bool on_button(button::Button *button) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
bool on_text_sensor(text_sensor::TextSensor *entity) override;
|
||||
#endif
|
||||
#ifdef USE_CLIMATE
|
||||
bool on_climate(climate::Climate *entity) override;
|
||||
#endif
|
||||
#ifdef USE_NUMBER
|
||||
bool on_number(number::Number *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_DATE
|
||||
bool on_date(datetime::DateEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_TIME
|
||||
bool on_time(datetime::TimeEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_DATETIME
|
||||
bool on_datetime(datetime::DateTimeEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_TEXT
|
||||
bool on_text(text::Text *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
bool on_select(select::Select *entity) override;
|
||||
#endif
|
||||
#ifdef USE_LOCK
|
||||
bool on_lock(lock::Lock *entity) override;
|
||||
#endif
|
||||
#ifdef USE_VALVE
|
||||
bool on_valve(valve::Valve *entity) override;
|
||||
#endif
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
bool on_media_player(media_player::MediaPlayer *entity) override;
|
||||
#endif
|
||||
#ifdef USE_ALARM_CONTROL_PANEL
|
||||
bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override;
|
||||
#endif
|
||||
#ifdef USE_WATER_HEATER
|
||||
bool on_water_heater(water_heater::WaterHeater *entity) override;
|
||||
#endif
|
||||
#ifdef USE_INFRARED
|
||||
bool on_infrared(infrared::Infrared *infrared) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
bool on_radio_frequency(radio_frequency::RadioFrequency *radio_frequency) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
bool on_event(event::Event *event) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_UPDATE
|
||||
bool on_update(update::UpdateEntity *entity) override;
|
||||
#endif
|
||||
|
||||
// Entity overrides (generated from entity_types.h).
|
||||
// ENTITY_TYPE_ entities have no state to send and default to a no-op.
|
||||
// ENTITY_CONTROLLER_TYPE_ entities are implemented in subscribe_state.cpp via INITIAL_STATE_HANDLER,
|
||||
// except on_event which has no state (defined out-of-line in subscribe_state.cpp).
|
||||
// NOLINTBEGIN(bugprone-macro-parentheses)
|
||||
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
|
||||
bool on_##singular(type *entity) override { return true; }
|
||||
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
|
||||
bool on_##singular(type *entity) override;
|
||||
#include "esphome/core/entity_types.h"
|
||||
#undef ENTITY_TYPE_
|
||||
#undef ENTITY_CONTROLLER_TYPE_
|
||||
// NOLINTEND(bugprone-macro-parentheses)
|
||||
|
||||
protected:
|
||||
APIConnection *client_;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components.esp32 import (
|
||||
@@ -7,7 +7,12 @@ from esphome.components.esp32 import (
|
||||
include_builtin_idf_component,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_BITS_PER_SAMPLE, CONF_NUM_CHANNELS, CONF_SAMPLE_RATE
|
||||
from esphome.const import (
|
||||
CONF_BITS_PER_SAMPLE,
|
||||
CONF_NUM_CHANNELS,
|
||||
CONF_SAMPLE_RATE,
|
||||
CONF_SIZE,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
|
||||
@@ -25,13 +30,45 @@ AUDIO_FILE_TYPE_ENUM = {
|
||||
"OPUS": AudioFileType.OPUS,
|
||||
}
|
||||
|
||||
MEMORY_PSRAM = "psram"
|
||||
MEMORY_INTERNAL = "internal"
|
||||
MEMORY_LOCATIONS = [MEMORY_PSRAM, MEMORY_INTERNAL]
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlacOptions:
|
||||
buffer_memory: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Mp3Options:
|
||||
buffer_memory: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpusPseudostackOptions:
|
||||
threadsafe: bool | None = None
|
||||
buffer_memory: str | None = None
|
||||
size: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class OpusOptions:
|
||||
floating_point: bool | None = None
|
||||
state_memory: str | None = None
|
||||
pseudostack: OpusPseudostackOptions = field(default_factory=OpusPseudostackOptions)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AudioData:
|
||||
flac_support: bool = False
|
||||
mp3_support: bool = False
|
||||
opus_support: bool = False
|
||||
wav_support: bool = False
|
||||
micro_decoder_support: bool = False
|
||||
flac: FlacOptions = field(default_factory=FlacOptions)
|
||||
mp3: Mp3Options = field(default_factory=Mp3Options)
|
||||
opus: OpusOptions = field(default_factory=OpusOptions)
|
||||
|
||||
|
||||
def _get_data() -> AudioData:
|
||||
@@ -55,6 +92,11 @@ def request_opus_support() -> None:
|
||||
_get_data().opus_support = True
|
||||
|
||||
|
||||
def request_wav_support() -> None:
|
||||
"""Request WAV codec support for audio decoding."""
|
||||
_get_data().wav_support = True
|
||||
|
||||
|
||||
def request_micro_decoder_support() -> None:
|
||||
"""Request micro-decoder library support for audio decoding."""
|
||||
_get_data().micro_decoder_support = True
|
||||
@@ -67,9 +109,78 @@ CONF_MAX_CHANNELS = "max_channels"
|
||||
CONF_MIN_SAMPLE_RATE = "min_sample_rate"
|
||||
CONF_MAX_SAMPLE_RATE = "max_sample_rate"
|
||||
|
||||
CONF_CODECS = "codecs"
|
||||
CONF_WAV = "wav"
|
||||
CONF_FLAC = "flac"
|
||||
CONF_MP3 = "mp3"
|
||||
CONF_OPUS = "opus"
|
||||
CONF_BUFFER_MEMORY = "buffer_memory"
|
||||
CONF_FLOATING_POINT = "floating_point"
|
||||
CONF_STATE_MEMORY = "state_memory"
|
||||
CONF_PSEUDOSTACK = "pseudostack"
|
||||
CONF_THREADSAFE = "threadsafe"
|
||||
|
||||
|
||||
_MEMORY_LOCATION_VALIDATOR = cv.one_of(*MEMORY_LOCATIONS, lower=True)
|
||||
|
||||
|
||||
def _maybe_empty_codec(schema):
|
||||
"""Wrap a codec dict schema so that a bare key (None value) is treated as an empty dict."""
|
||||
|
||||
def validator(value):
|
||||
if value is None:
|
||||
value = {}
|
||||
return schema(value)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
CODEC_FLAC_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
|
||||
}
|
||||
)
|
||||
|
||||
CODEC_MP3_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
|
||||
}
|
||||
)
|
||||
|
||||
OPUS_PSEUDOSTACK_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_THREADSAFE): cv.boolean,
|
||||
cv.Optional(CONF_BUFFER_MEMORY): _MEMORY_LOCATION_VALIDATOR,
|
||||
cv.Optional(CONF_SIZE): cv.int_range(60000, 240000),
|
||||
}
|
||||
)
|
||||
|
||||
CODEC_OPUS_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_FLOATING_POINT): cv.boolean,
|
||||
cv.Optional(CONF_STATE_MEMORY): _MEMORY_LOCATION_VALIDATOR,
|
||||
cv.Optional(CONF_PSEUDOSTACK): _maybe_empty_codec(OPUS_PSEUDOSTACK_SCHEMA),
|
||||
}
|
||||
)
|
||||
|
||||
CODEC_WAV_SCHEMA = cv.Schema({})
|
||||
|
||||
CODECS_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_FLAC): _maybe_empty_codec(CODEC_FLAC_SCHEMA),
|
||||
cv.Optional(CONF_MP3): _maybe_empty_codec(CODEC_MP3_SCHEMA),
|
||||
cv.Optional(CONF_OPUS): _maybe_empty_codec(CODEC_OPUS_SCHEMA),
|
||||
cv.Optional(CONF_WAV): _maybe_empty_codec(CODEC_WAV_SCHEMA),
|
||||
}
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema({}),
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(CONF_CODECS): _maybe_empty_codec(CODECS_SCHEMA),
|
||||
}
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
)
|
||||
|
||||
AUDIO_COMPONENT_SCHEMA = cv.Schema(
|
||||
@@ -208,17 +319,56 @@ def final_validate_audio_schema(
|
||||
)
|
||||
|
||||
|
||||
def _emit_memory_pair(value: str | None, psram_key: str, internal_key: str) -> None:
|
||||
if value == MEMORY_PSRAM:
|
||||
add_idf_sdkconfig_option(psram_key, True)
|
||||
add_idf_sdkconfig_option(internal_key, False)
|
||||
elif value == MEMORY_INTERNAL:
|
||||
add_idf_sdkconfig_option(psram_key, False)
|
||||
add_idf_sdkconfig_option(internal_key, True)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
# Re-enable ESP-IDF's HTTP client (excluded by default to save compile time)
|
||||
include_builtin_idf_component("esp_http_client")
|
||||
|
||||
add_idf_component(
|
||||
name="esphome/esp-audio-libs",
|
||||
ref="2.0.4",
|
||||
ref="3.0.0",
|
||||
)
|
||||
|
||||
data = _get_data()
|
||||
|
||||
# Merge user-supplied codec configuration (additive: presence enables the codec)
|
||||
if codecs_config := config.get(CONF_CODECS):
|
||||
if (flac_config := codecs_config.get(CONF_FLAC)) is not None:
|
||||
data.flac_support = True
|
||||
if (buffer_memory := flac_config.get(CONF_BUFFER_MEMORY)) is not None:
|
||||
data.flac.buffer_memory = buffer_memory
|
||||
if (mp3_config := codecs_config.get(CONF_MP3)) is not None:
|
||||
data.mp3_support = True
|
||||
if (buffer_memory := mp3_config.get(CONF_BUFFER_MEMORY)) is not None:
|
||||
data.mp3.buffer_memory = buffer_memory
|
||||
if (opus_config := codecs_config.get(CONF_OPUS)) is not None:
|
||||
data.opus_support = True
|
||||
floating_point = opus_config.get(CONF_FLOATING_POINT)
|
||||
if floating_point is not None:
|
||||
data.opus.floating_point = floating_point
|
||||
if (state_memory := opus_config.get(CONF_STATE_MEMORY)) is not None:
|
||||
data.opus.state_memory = state_memory
|
||||
if (pseudostack_config := opus_config.get(CONF_PSEUDOSTACK)) is not None:
|
||||
threadsafe = pseudostack_config.get(CONF_THREADSAFE)
|
||||
if threadsafe is not None:
|
||||
data.opus.pseudostack.threadsafe = threadsafe
|
||||
if (
|
||||
buffer_memory := pseudostack_config.get(CONF_BUFFER_MEMORY)
|
||||
) is not None:
|
||||
data.opus.pseudostack.buffer_memory = buffer_memory
|
||||
if (size := pseudostack_config.get(CONF_SIZE)) is not None:
|
||||
data.opus.pseudostack.size = size
|
||||
if CONF_WAV in codecs_config:
|
||||
data.wav_support = True
|
||||
|
||||
if data.micro_decoder_support:
|
||||
add_idf_component(name="esphome/micro-decoder", ref="0.2.0")
|
||||
|
||||
@@ -229,13 +379,54 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_MP3", False)
|
||||
if not data.opus_support:
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_OPUS", False)
|
||||
if not data.wav_support:
|
||||
add_idf_sdkconfig_option("CONFIG_MICRO_DECODER_CODEC_WAV", False)
|
||||
|
||||
# Legacy audio_decoder.cpp support defines and components
|
||||
# Configure each codec library.
|
||||
# Adds a define and IDF component for legacy `audio_decoder.cpp`.
|
||||
if data.flac_support:
|
||||
cg.add_define("USE_AUDIO_FLAC_SUPPORT")
|
||||
add_idf_component(name="esphome/micro-flac", ref="0.1.1")
|
||||
_emit_memory_pair(
|
||||
data.flac.buffer_memory,
|
||||
"CONFIG_MICRO_FLAC_PREFER_PSRAM",
|
||||
"CONFIG_MICRO_FLAC_PREFER_INTERNAL",
|
||||
)
|
||||
if data.mp3_support:
|
||||
cg.add_define("USE_AUDIO_MP3_SUPPORT")
|
||||
add_idf_component(name="esphome/micro-mp3", ref="0.2.0")
|
||||
_emit_memory_pair(
|
||||
data.mp3.buffer_memory,
|
||||
"CONFIG_MP3_DECODER_PREFER_PSRAM",
|
||||
"CONFIG_MP3_DECODER_PREFER_INTERNAL",
|
||||
)
|
||||
if data.opus_support:
|
||||
cg.add_define("USE_AUDIO_OPUS_SUPPORT")
|
||||
add_idf_component(name="esphome/micro-opus", ref="0.3.6")
|
||||
add_idf_component(name="esphome/micro-opus", ref="0.4.1")
|
||||
if data.opus.floating_point is not None:
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_OPUS_FLOATING_POINT", data.opus.floating_point
|
||||
)
|
||||
_emit_memory_pair(
|
||||
data.opus.state_memory,
|
||||
"CONFIG_OPUS_STATE_PREFER_PSRAM",
|
||||
"CONFIG_OPUS_STATE_PREFER_INTERNAL",
|
||||
)
|
||||
if data.opus.pseudostack.threadsafe is True:
|
||||
add_idf_sdkconfig_option("CONFIG_OPUS_THREADSAFE_PSEUDOSTACK", True)
|
||||
add_idf_sdkconfig_option("CONFIG_OPUS_NONTHREADSAFE_PSEUDOSTACK", False)
|
||||
elif data.opus.pseudostack.threadsafe is False:
|
||||
add_idf_sdkconfig_option("CONFIG_OPUS_THREADSAFE_PSEUDOSTACK", False)
|
||||
add_idf_sdkconfig_option("CONFIG_OPUS_NONTHREADSAFE_PSEUDOSTACK", True)
|
||||
_emit_memory_pair(
|
||||
data.opus.pseudostack.buffer_memory,
|
||||
"CONFIG_OPUS_PSEUDOSTACK_PREFER_PSRAM",
|
||||
"CONFIG_OPUS_PSEUDOSTACK_PREFER_INTERNAL",
|
||||
)
|
||||
if data.opus.pseudostack.size is not None:
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_OPUS_PSEUDOSTACK_SIZE", data.opus.pseudostack.size
|
||||
)
|
||||
if data.wav_support:
|
||||
cg.add_define("USE_AUDIO_WAV_SUPPORT")
|
||||
add_idf_component(name="esphome/micro-wav", ref="0.2.0")
|
||||
|
||||
@@ -55,8 +55,10 @@ const char *audio_file_type_to_string(AudioFileType file_type) {
|
||||
case AudioFileType::OPUS:
|
||||
return "OPUS";
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
case AudioFileType::WAV:
|
||||
return "WAV";
|
||||
#endif
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
@@ -71,9 +73,11 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url)
|
||||
return AudioFileType::MP3;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
if (strcasecmp(content_type, "audio/wav") == 0) {
|
||||
return AudioFileType::WAV;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_AUDIO_FLAC_SUPPORT
|
||||
if (strcasecmp(content_type, "audio/flac") == 0 || strcasecmp(content_type, "audio/x-flac") == 0) {
|
||||
return AudioFileType::FLAC;
|
||||
@@ -91,9 +95,11 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url)
|
||||
|
||||
// Fallback to URL extension
|
||||
if (url != nullptr && url[0] != '\0') {
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
if (str_endswith_ignore_case(url, ".wav")) {
|
||||
return AudioFileType::WAV;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
if (str_endswith_ignore_case(url, ".mp3")) {
|
||||
return AudioFileType::MP3;
|
||||
|
||||
@@ -116,7 +116,9 @@ enum class AudioFileType : uint8_t {
|
||||
#ifdef USE_AUDIO_OPUS_SUPPORT
|
||||
OPUS,
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
WAV,
|
||||
#endif
|
||||
};
|
||||
|
||||
struct AudioFile {
|
||||
|
||||
@@ -20,14 +20,6 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
|
||||
this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size);
|
||||
}
|
||||
|
||||
AudioDecoder::~AudioDecoder() {
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
if (this->audio_file_type_ == AudioFileType::MP3) {
|
||||
esp_audio_libs::helix_decoder::MP3FreeDecoder(this->mp3_decoder_);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
esp_err_t AudioDecoder::add_source(std::weak_ptr<RingBuffer> &input_ring_buffer) {
|
||||
auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_);
|
||||
if (source == nullptr) {
|
||||
@@ -87,18 +79,13 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) {
|
||||
this->flac_decoder_ = make_unique<micro_flac::FLACDecoder>();
|
||||
this->free_buffer_required_ =
|
||||
this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header
|
||||
this->decoder_buffers_internally_ = true;
|
||||
break;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
case AudioFileType::MP3:
|
||||
this->mp3_decoder_ = esp_audio_libs::helix_decoder::MP3InitDecoder();
|
||||
|
||||
// MP3 always has 1152 samples per chunk
|
||||
this->free_buffer_required_ = 1152 * sizeof(int16_t) * 2; // samples * size per sample * channels
|
||||
|
||||
// Always reallocate the output transfer buffer to the smallest necessary size
|
||||
this->output_transfer_buffer_->reallocate(this->free_buffer_required_);
|
||||
this->mp3_decoder_ = make_unique<micro_mp3::Mp3Decoder>();
|
||||
this->free_buffer_required_ =
|
||||
this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header
|
||||
break;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_OPUS_SUPPORT
|
||||
@@ -106,20 +93,18 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) {
|
||||
this->opus_decoder_ = make_unique<micro_opus::OggOpusDecoder>();
|
||||
this->free_buffer_required_ =
|
||||
this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header
|
||||
this->decoder_buffers_internally_ = true;
|
||||
break;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
case AudioFileType::WAV:
|
||||
this->wav_decoder_ = make_unique<esp_audio_libs::wav_decoder::WAVDecoder>();
|
||||
this->wav_decoder_->reset();
|
||||
|
||||
// Processing WAVs doesn't actually require a specific amount of buffer size, as it is already in PCM format.
|
||||
// Thus, we don't reallocate to a minimum size.
|
||||
this->wav_decoder_ = make_unique<micro_wav::WAVDecoder>();
|
||||
// 1 KiB suffices to always make progress while avoiding excessive CPU spinning for decoding
|
||||
this->free_buffer_required_ = 1024;
|
||||
if (this->output_transfer_buffer_->capacity() < this->free_buffer_required_) {
|
||||
this->output_transfer_buffer_->reallocate(this->free_buffer_required_);
|
||||
}
|
||||
break;
|
||||
#endif
|
||||
case AudioFileType::NONE:
|
||||
default:
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
@@ -190,10 +175,8 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
|
||||
// Decode more audio
|
||||
|
||||
// Only shift data on the first loop iteration to avoid unnecessary, slow moves
|
||||
// If the decoder buffers internally, then never shift
|
||||
size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS),
|
||||
first_loop_iteration && !this->decoder_buffers_internally_);
|
||||
// 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.
|
||||
@@ -237,9 +220,11 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
state = this->decode_opus_();
|
||||
break;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
case AudioFileType::WAV:
|
||||
state = this->decode_wav_();
|
||||
break;
|
||||
#endif
|
||||
case AudioFileType::NONE:
|
||||
default:
|
||||
state = FileDecoderState::IDLE;
|
||||
@@ -312,51 +297,56 @@ FileDecoderState AudioDecoder::decode_flac_() {
|
||||
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
FileDecoderState AudioDecoder::decode_mp3_() {
|
||||
// Look for the next sync word
|
||||
int buffer_length = (int) this->input_buffer_->available();
|
||||
int32_t offset = esp_audio_libs::helix_decoder::MP3FindSyncWord(this->input_buffer_->data(), buffer_length);
|
||||
// microMP3's samples_decoded value is samples per channel; e.g., what ESPHome typically calls an audio frame.
|
||||
// microMP3 uses the term frame to refer to an MP3 frame: an encoded packet that contains multiple audio frames.
|
||||
size_t bytes_consumed = 0;
|
||||
size_t samples_decoded = 0;
|
||||
|
||||
if (offset < 0) {
|
||||
// New data may have the sync word
|
||||
this->input_buffer_->consume(buffer_length);
|
||||
// microMP3 buffers internally: it consumes from our input buffer at its own pace, emits MP3_STREAM_INFO_READY once
|
||||
// the first frame header is parsed, and only then produces PCM. It handles sync-word search and ID3v2 tag skipping.
|
||||
micro_mp3::Mp3Result result = this->mp3_decoder_->decode(
|
||||
this->input_buffer_->data(), this->input_buffer_->available(), this->output_transfer_buffer_->get_buffer_end(),
|
||||
this->output_transfer_buffer_->free(), bytes_consumed, samples_decoded);
|
||||
|
||||
this->input_buffer_->consume(bytes_consumed);
|
||||
|
||||
if (result == micro_mp3::MP3_OK) {
|
||||
if (samples_decoded > 0 && this->audio_stream_info_.has_value()) {
|
||||
this->output_transfer_buffer_->increase_buffer_length(
|
||||
this->audio_stream_info_.value().frames_to_bytes(samples_decoded));
|
||||
}
|
||||
} else if (result == micro_mp3::MP3_STREAM_INFO_READY) {
|
||||
// First successful header parse: capture stream info and resize the output buffer to fit one full frame.
|
||||
// microMP3 always outputs 16-bit PCM.
|
||||
this->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate());
|
||||
this->free_buffer_required_ =
|
||||
this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t);
|
||||
if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) {
|
||||
return FileDecoderState::FAILED;
|
||||
}
|
||||
} else if (result == micro_mp3::MP3_NEED_MORE_DATA) {
|
||||
return FileDecoderState::MORE_TO_PROCESS;
|
||||
} else if (result == micro_mp3::MP3_OUTPUT_BUFFER_TOO_SMALL) {
|
||||
// Reallocate to decode the frame on the next call
|
||||
if (this->mp3_decoder_->get_channels() > 0) {
|
||||
this->free_buffer_required_ =
|
||||
this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t);
|
||||
} else {
|
||||
// Fallback to worst-case size if channel info isn't available
|
||||
this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes();
|
||||
}
|
||||
if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) {
|
||||
return FileDecoderState::FAILED;
|
||||
}
|
||||
} else if (result == micro_mp3::MP3_DECODE_ERROR) {
|
||||
// Corrupt frame skipped; recoverable, retry on next call
|
||||
ESP_LOGW(TAG, "MP3 decoder skipped a corrupt frame");
|
||||
return FileDecoderState::POTENTIALLY_FAILED;
|
||||
}
|
||||
|
||||
// Advance read pointer to match the offset for the syncword
|
||||
this->input_buffer_->consume(offset);
|
||||
const uint8_t *buffer_start = this->input_buffer_->data();
|
||||
|
||||
buffer_length = (int) this->input_buffer_->available();
|
||||
int err = esp_audio_libs::helix_decoder::MP3Decode(this->mp3_decoder_, &buffer_start, &buffer_length,
|
||||
(int16_t *) this->output_transfer_buffer_->get_buffer_end(), 0);
|
||||
|
||||
size_t consumed = this->input_buffer_->available() - buffer_length;
|
||||
this->input_buffer_->consume(consumed);
|
||||
|
||||
if (err) {
|
||||
switch (err) {
|
||||
case esp_audio_libs::helix_decoder::ERR_MP3_OUT_OF_MEMORY:
|
||||
[[fallthrough]];
|
||||
case esp_audio_libs::helix_decoder::ERR_MP3_NULL_POINTER:
|
||||
return FileDecoderState::FAILED;
|
||||
break;
|
||||
default:
|
||||
// Most errors are recoverable by moving on to the next frame, so mark as potentailly failed
|
||||
return FileDecoderState::POTENTIALLY_FAILED;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
esp_audio_libs::helix_decoder::MP3FrameInfo mp3_frame_info;
|
||||
esp_audio_libs::helix_decoder::MP3GetLastFrameInfo(this->mp3_decoder_, &mp3_frame_info);
|
||||
if (mp3_frame_info.outputSamps > 0) {
|
||||
int bytes_per_sample = (mp3_frame_info.bitsPerSample / 8);
|
||||
this->output_transfer_buffer_->increase_buffer_length(mp3_frame_info.outputSamps * bytes_per_sample);
|
||||
|
||||
if (!this->audio_stream_info_.has_value()) {
|
||||
this->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(mp3_frame_info.bitsPerSample, mp3_frame_info.nChans, mp3_frame_info.samprate);
|
||||
}
|
||||
}
|
||||
// MP3_ALLOCATION_FAILED, MP3_INPUT_INVALID, or any future error -- not recoverable
|
||||
ESP_LOGE(TAG, "MP3 decoder failed: %d", static_cast<int>(result));
|
||||
return FileDecoderState::FAILED;
|
||||
}
|
||||
|
||||
return FileDecoderState::MORE_TO_PROCESS;
|
||||
@@ -401,52 +391,42 @@ FileDecoderState AudioDecoder::decode_opus_() {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
FileDecoderState AudioDecoder::decode_wav_() {
|
||||
if (!this->audio_stream_info_.has_value()) {
|
||||
// Header hasn't been processed
|
||||
// microWAV's samples_decoded counts individual channel samples; e.g., for
|
||||
// 16-bit stereo, 4 input bytes results in 2 samples_decoded.
|
||||
size_t bytes_consumed = 0;
|
||||
size_t samples_decoded = 0;
|
||||
|
||||
esp_audio_libs::wav_decoder::WAVDecoderResult result =
|
||||
this->wav_decoder_->decode_header(this->input_buffer_->data(), this->input_buffer_->available());
|
||||
micro_wav::WAVDecoderResult result = this->wav_decoder_->decode(
|
||||
this->input_buffer_->data(), this->input_buffer_->available(), this->output_transfer_buffer_->get_buffer_end(),
|
||||
this->output_transfer_buffer_->free(), bytes_consumed, samples_decoded);
|
||||
|
||||
if (result == esp_audio_libs::wav_decoder::WAV_DECODER_SUCCESS_IN_DATA) {
|
||||
this->input_buffer_->consume(this->wav_decoder_->bytes_processed());
|
||||
this->input_buffer_->consume(bytes_consumed);
|
||||
|
||||
this->audio_stream_info_ = audio::AudioStreamInfo(
|
||||
this->wav_decoder_->bits_per_sample(), this->wav_decoder_->num_channels(), this->wav_decoder_->sample_rate());
|
||||
|
||||
this->wav_bytes_left_ = this->wav_decoder_->chunk_bytes_left();
|
||||
this->wav_has_known_end_ = (this->wav_bytes_left_ > 0);
|
||||
return FileDecoderState::MORE_TO_PROCESS;
|
||||
} else if (result == esp_audio_libs::wav_decoder::WAV_DECODER_WARNING_INCOMPLETE_DATA) {
|
||||
// Available data didn't have the full header
|
||||
return FileDecoderState::POTENTIALLY_FAILED;
|
||||
} else {
|
||||
return FileDecoderState::FAILED;
|
||||
if (result == micro_wav::WAV_DECODER_SUCCESS) {
|
||||
if (samples_decoded > 0 && this->audio_stream_info_.has_value()) {
|
||||
this->output_transfer_buffer_->increase_buffer_length(
|
||||
this->audio_stream_info_.value().samples_to_bytes(samples_decoded));
|
||||
}
|
||||
} else if (result == micro_wav::WAV_DECODER_HEADER_READY) {
|
||||
// After HEADER_READY, get_bits_per_sample() returns the output bit depth
|
||||
// (16 for A-law/mu-law, 32 for IEEE float, original value for PCM).
|
||||
this->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(this->wav_decoder_->get_bits_per_sample(), this->wav_decoder_->get_channels(),
|
||||
this->wav_decoder_->get_sample_rate());
|
||||
} else if (result == micro_wav::WAV_DECODER_NEED_MORE_DATA) {
|
||||
return FileDecoderState::MORE_TO_PROCESS;
|
||||
} else if (result == micro_wav::WAV_DECODER_END_OF_STREAM) {
|
||||
return FileDecoderState::END_OF_FILE;
|
||||
} else {
|
||||
if (!this->wav_has_known_end_ || (this->wav_bytes_left_ > 0)) {
|
||||
size_t bytes_to_copy = this->input_buffer_->available();
|
||||
|
||||
if (this->wav_has_known_end_) {
|
||||
bytes_to_copy = std::min(bytes_to_copy, this->wav_bytes_left_);
|
||||
}
|
||||
|
||||
bytes_to_copy = std::min(bytes_to_copy, this->output_transfer_buffer_->free());
|
||||
|
||||
if (bytes_to_copy > 0) {
|
||||
std::memcpy(this->output_transfer_buffer_->get_buffer_end(), this->input_buffer_->data(), bytes_to_copy);
|
||||
this->input_buffer_->consume(bytes_to_copy);
|
||||
this->output_transfer_buffer_->increase_buffer_length(bytes_to_copy);
|
||||
if (this->wav_has_known_end_) {
|
||||
this->wav_bytes_left_ -= bytes_to_copy;
|
||||
}
|
||||
}
|
||||
return FileDecoderState::IDLE;
|
||||
}
|
||||
ESP_LOGE(TAG, "WAV decoder failed: %d", static_cast<int>(result));
|
||||
return FileDecoderState::FAILED;
|
||||
}
|
||||
|
||||
return FileDecoderState::END_OF_FILE;
|
||||
return FileDecoderState::MORE_TO_PROCESS;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace audio
|
||||
} // namespace esphome
|
||||
|
||||
@@ -15,22 +15,26 @@
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
// esp-audio-libs
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
#include <mp3_decoder.h>
|
||||
#endif
|
||||
#include <wav_decoder.h>
|
||||
|
||||
// micro-flac
|
||||
#ifdef USE_AUDIO_FLAC_SUPPORT
|
||||
#include <micro_flac/flac_decoder.h>
|
||||
#endif
|
||||
|
||||
// micro-mp3
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
#include <micro_mp3/mp3_decoder.h>
|
||||
#endif
|
||||
|
||||
// micro-opus
|
||||
#ifdef USE_AUDIO_OPUS_SUPPORT
|
||||
#include <micro_opus/ogg_opus_decoder.h>
|
||||
#endif
|
||||
|
||||
// micro-wav
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
#include <micro_wav/wav_decoder.h>
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
namespace audio {
|
||||
|
||||
@@ -54,7 +58,7 @@ class AudioDecoder {
|
||||
* @brief Class that facilitates decoding an audio file.
|
||||
* The audio file is read from a source (ring buffer or const data pointer), decoded, and sent to an audio sink
|
||||
* (ring buffer, speaker component, or callback).
|
||||
* Supports wav, flac, mp3, and ogg opus formats.
|
||||
* Supports flac, mp3, ogg opus, and wav formats (each enabled independently at compile time).
|
||||
*/
|
||||
public:
|
||||
/// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source()
|
||||
@@ -62,8 +66,7 @@ class AudioDecoder {
|
||||
/// @param output_buffer_size Size of the output transfer buffer in bytes.
|
||||
AudioDecoder(size_t input_buffer_size, size_t output_buffer_size);
|
||||
|
||||
/// @brief Deallocates the MP3 decoder (the flac, opus, and wav decoders are deallocated automatically)
|
||||
~AudioDecoder();
|
||||
~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
|
||||
@@ -118,20 +121,22 @@ class AudioDecoder {
|
||||
void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; }
|
||||
|
||||
protected:
|
||||
std::unique_ptr<esp_audio_libs::wav_decoder::WAVDecoder> wav_decoder_;
|
||||
#ifdef USE_AUDIO_FLAC_SUPPORT
|
||||
FileDecoderState decode_flac_();
|
||||
std::unique_ptr<micro_flac::FLACDecoder> flac_decoder_;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
FileDecoderState decode_mp3_();
|
||||
esp_audio_libs::helix_decoder::HMP3Decoder mp3_decoder_;
|
||||
std::unique_ptr<micro_mp3::Mp3Decoder> mp3_decoder_;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_OPUS_SUPPORT
|
||||
FileDecoderState decode_opus_();
|
||||
std::unique_ptr<micro_opus::OggOpusDecoder> opus_decoder_;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
FileDecoderState decode_wav_();
|
||||
std::unique_ptr<micro_wav::WAVDecoder> wav_decoder_;
|
||||
#endif
|
||||
|
||||
std::unique_ptr<AudioReadableBuffer> input_buffer_;
|
||||
std::unique_ptr<AudioSinkTransferBuffer> output_transfer_buffer_;
|
||||
@@ -141,16 +146,12 @@ class AudioDecoder {
|
||||
|
||||
size_t input_buffer_size_{0};
|
||||
size_t free_buffer_required_{0};
|
||||
size_t wav_bytes_left_{0};
|
||||
|
||||
uint32_t potentially_failed_count_{0};
|
||||
uint32_t accumulated_frames_written_{0};
|
||||
uint32_t playback_ms_{0};
|
||||
|
||||
bool end_of_file_{false};
|
||||
bool wav_has_known_end_{false};
|
||||
|
||||
bool decoder_buffers_internally_{false};
|
||||
|
||||
bool pause_output_{false};
|
||||
};
|
||||
|
||||
@@ -193,55 +193,66 @@ def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType]
|
||||
audio.request_mp3_support()
|
||||
elif media_file_type_str == str(audio.AUDIO_FILE_TYPE_ENUM["OPUS"]):
|
||||
audio.request_opus_support()
|
||||
elif media_file_type_str == str(audio.AUDIO_FILE_TYPE_ENUM["WAV"]):
|
||||
audio.request_wav_support()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def audio_files_schema() -> cv.All:
|
||||
"""Schema for a list of audio file entries.
|
||||
|
||||
Validates each entry, downloads any web files, and detects the audio file
|
||||
type while requesting codec support. Reusable by other components (e.g.
|
||||
speaker media_player) that embed audio files in firmware without going
|
||||
through the audio_file component's C++ registry.
|
||||
"""
|
||||
return cv.All(
|
||||
cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA),
|
||||
partial(download_web_files_in_config, path_for=_compute_local_file_path),
|
||||
_validate_supported_local_file,
|
||||
)
|
||||
|
||||
|
||||
def generate_audio_file_code(file_config: ConfigType) -> MockObj:
|
||||
"""Generate the progmem data, AudioFile struct, and Pvariable for one file.
|
||||
|
||||
Returns the created Pvariable. Caller is responsible for any further
|
||||
registration (the audio_file component additionally registers each file in
|
||||
its named C++ registry; other consumers may skip that).
|
||||
"""
|
||||
cache = _get_data().file_cache
|
||||
file_id = str(file_config[CONF_ID])
|
||||
if file_id in cache:
|
||||
data, media_file_type = cache[file_id]
|
||||
else:
|
||||
data, media_file_type = read_audio_file_and_type(file_config)
|
||||
|
||||
rhs = [HexInt(x) for x in data]
|
||||
prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs)
|
||||
|
||||
media_files_struct = cg.StructInitializer(
|
||||
audio.AudioFile,
|
||||
("data", prog_arr),
|
||||
("length", len(rhs)),
|
||||
("file_type", media_file_type),
|
||||
)
|
||||
|
||||
return cg.new_Pvariable(file_config[CONF_ID], media_files_struct)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.only_on_esp32,
|
||||
cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA),
|
||||
partial(download_web_files_in_config, path_for=_compute_local_file_path),
|
||||
_validate_supported_local_file,
|
||||
audio_files_schema(),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: list[ConfigType]) -> None:
|
||||
cache = _get_data().file_cache
|
||||
|
||||
for file_config in config:
|
||||
file_id = str(file_config[CONF_ID])
|
||||
data, media_file_type = cache[file_id]
|
||||
|
||||
rhs = [HexInt(x) for x in data]
|
||||
prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs)
|
||||
|
||||
media_files_struct = cg.StructInitializer(
|
||||
audio.AudioFile,
|
||||
(
|
||||
"data",
|
||||
prog_arr,
|
||||
),
|
||||
(
|
||||
"length",
|
||||
len(rhs),
|
||||
),
|
||||
(
|
||||
"file_type",
|
||||
media_file_type,
|
||||
),
|
||||
)
|
||||
|
||||
cg.new_Pvariable(
|
||||
file_config[CONF_ID],
|
||||
media_files_struct,
|
||||
)
|
||||
|
||||
# Store file ID for cross-component access
|
||||
file_var = generate_audio_file_code(file_config)
|
||||
_get_data().file_ids[file_id] = file_config[CONF_ID]
|
||||
cg.add(audio_file_ns.add_named_audio_file(file_var, file_id))
|
||||
|
||||
# Register all files in the shared C++ registry
|
||||
cg.add_define("AUDIO_FILE_MAX_FILES", len(config))
|
||||
for file_config in config:
|
||||
file_id = str(file_config[CONF_ID])
|
||||
file_var = await cg.get_variable(file_config[CONF_ID])
|
||||
cg.add(audio_file_ns.add_named_audio_file(file_var, file_id))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import media_source, psram
|
||||
from esphome.components import audio, esp32, 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
|
||||
@@ -13,19 +15,30 @@ AudioFileMediaSource = audio_file_ns.class_(
|
||||
"AudioFileMediaSource", cg.Component, media_source.MediaSource
|
||||
)
|
||||
|
||||
|
||||
def _request_micro_decoder(config: ConfigType) -> ConfigType:
|
||||
audio.request_micro_decoder_support()
|
||||
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): cv.All(
|
||||
cv.boolean, cv.requires_component(psram.DOMAIN)
|
||||
),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
cv.only_on_esp32,
|
||||
_request_micro_decoder,
|
||||
)
|
||||
|
||||
|
||||
@@ -34,5 +47,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await media_source.register_media_source(var, config)
|
||||
|
||||
if CONF_TASK_STACK_IN_PSRAM in config:
|
||||
cg.add(var.set_task_stack_in_psram(config[CONF_TASK_STACK_IN_PSRAM]))
|
||||
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
|
||||
)
|
||||
|
||||
@@ -2,281 +2,185 @@
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/components/audio/audio_decoder.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::audio_file {
|
||||
|
||||
namespace { // anonymous namespace for internal linkage
|
||||
struct AudioSinkAdapter : public audio::AudioSinkCallback {
|
||||
media_source::MediaSource *source;
|
||||
audio::AudioStreamInfo stream_info;
|
||||
|
||||
size_t audio_sink_write(uint8_t *data, size_t length, TickType_t ticks_to_wait) override {
|
||||
return this->source->write_output(data, length, pdTICKS_TO_MS(ticks_to_wait), this->stream_info);
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
#if defined(USE_AUDIO_OPUS_SUPPORT)
|
||||
static constexpr uint32_t DECODE_TASK_STACK_SIZE = 5 * 1024;
|
||||
#else
|
||||
static constexpr uint32_t DECODE_TASK_STACK_SIZE = 3 * 1024;
|
||||
#endif
|
||||
|
||||
static const char *const TAG = "audio_file_media_source";
|
||||
|
||||
enum EventGroupBits : uint32_t {
|
||||
// Requests to start playback (set by play_uri, handled by loop)
|
||||
REQUEST_START = (1 << 0),
|
||||
// Commands from main loop to decode task
|
||||
COMMAND_STOP = (1 << 1),
|
||||
COMMAND_PAUSE = (1 << 2),
|
||||
// Decode task lifecycle signals (one-shot, cleared by loop)
|
||||
TASK_STARTING = (1 << 7),
|
||||
TASK_RUNNING = (1 << 8),
|
||||
TASK_STOPPING = (1 << 9),
|
||||
TASK_STOPPED = (1 << 10),
|
||||
TASK_ERROR = (1 << 11),
|
||||
// Decode task state (level-triggered, set/cleared by decode task)
|
||||
TASK_PAUSED = (1 << 12),
|
||||
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
|
||||
};
|
||||
static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50;
|
||||
static constexpr size_t DECODER_TASK_STACK_SIZE = 5120;
|
||||
static constexpr uint8_t DECODER_TASK_PRIORITY = 2;
|
||||
static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20;
|
||||
static constexpr char URI_PREFIX[] = "audio-file://";
|
||||
|
||||
namespace { // anonymous namespace for internal linkage
|
||||
|
||||
// audio::AudioFileType and micro_decoder::AudioFileType use different numeric layouts (audio's
|
||||
// values shift with USE_AUDIO_*_SUPPORT defines; micro_decoder's are fixed and guarded by
|
||||
// MICRO_DECODER_CODEC_*). The codec request flow in audio/__init__.py keeps the two sets of
|
||||
// guards aligned, so a switch with matching #ifdefs covers all reachable cases.
|
||||
micro_decoder::AudioFileType to_micro_decoder_type(audio::AudioFileType type) {
|
||||
switch (type) {
|
||||
#ifdef USE_AUDIO_FLAC_SUPPORT
|
||||
case audio::AudioFileType::FLAC:
|
||||
return micro_decoder::AudioFileType::FLAC;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
case audio::AudioFileType::MP3:
|
||||
return micro_decoder::AudioFileType::MP3;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_OPUS_SUPPORT
|
||||
case audio::AudioFileType::OPUS:
|
||||
return micro_decoder::AudioFileType::OPUS;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
case audio::AudioFileType::WAV:
|
||||
return micro_decoder::AudioFileType::WAV;
|
||||
#endif
|
||||
default:
|
||||
return micro_decoder::AudioFileType::NONE;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void AudioFileMediaSource::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Audio File Media Source:");
|
||||
ESP_LOGCONFIG(TAG, " Task Stack in PSRAM: %s", this->task_stack_in_psram_ ? "Yes" : "No");
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Audio File Media Source:\n"
|
||||
" Decoder Task Stack in PSRAM: %s",
|
||||
YESNO(this->decoder_task_stack_in_psram_));
|
||||
}
|
||||
|
||||
void AudioFileMediaSource::setup() {
|
||||
this->disable_loop();
|
||||
|
||||
this->event_group_ = xEventGroupCreate();
|
||||
if (this->event_group_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to create event group");
|
||||
micro_decoder::DecoderConfig config;
|
||||
config.audio_write_timeout_ms = AUDIO_WRITE_TIMEOUT_MS;
|
||||
config.decoder_priority = DECODER_TASK_PRIORITY;
|
||||
config.decoder_stack_size = DECODER_TASK_STACK_SIZE;
|
||||
config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_;
|
||||
|
||||
this->decoder_ = std::make_unique<micro_decoder::DecoderSource>(config);
|
||||
if (this->decoder_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to allocate decoder");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
this->decoder_->set_listener(this);
|
||||
}
|
||||
|
||||
void AudioFileMediaSource::loop() {
|
||||
EventBits_t event_bits = xEventGroupGetBits(this->event_group_);
|
||||
void AudioFileMediaSource::loop() { this->decoder_->loop(); }
|
||||
|
||||
if (event_bits & REQUEST_START) {
|
||||
xEventGroupClearBits(this->event_group_, REQUEST_START);
|
||||
this->decoding_state_ = AudioFileDecodingState::START_TASK;
|
||||
}
|
||||
|
||||
switch (this->decoding_state_) {
|
||||
case AudioFileDecodingState::START_TASK: {
|
||||
if (!this->decode_task_.is_created()) {
|
||||
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
||||
if (!this->decode_task_.create(decode_task, "AudioFileDec", DECODE_TASK_STACK_SIZE, this, 1,
|
||||
this->task_stack_in_psram_)) {
|
||||
ESP_LOGE(TAG, "Failed to create task");
|
||||
this->status_momentary_error("task_create", 1000);
|
||||
this->set_state_(media_source::MediaSourceState::ERROR);
|
||||
this->decoding_state_ = AudioFileDecodingState::IDLE;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->decoding_state_ = AudioFileDecodingState::DECODING;
|
||||
break;
|
||||
}
|
||||
case AudioFileDecodingState::DECODING: {
|
||||
if (event_bits & TASK_STARTING) {
|
||||
ESP_LOGD(TAG, "Starting");
|
||||
xEventGroupClearBits(this->event_group_, TASK_STARTING);
|
||||
}
|
||||
|
||||
if (event_bits & TASK_RUNNING) {
|
||||
ESP_LOGV(TAG, "Started");
|
||||
xEventGroupClearBits(this->event_group_, TASK_RUNNING);
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
}
|
||||
|
||||
if ((event_bits & TASK_PAUSED) && this->get_state() != media_source::MediaSourceState::PAUSED) {
|
||||
this->set_state_(media_source::MediaSourceState::PAUSED);
|
||||
} else if (!(event_bits & TASK_PAUSED) && this->get_state() == media_source::MediaSourceState::PAUSED) {
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
}
|
||||
|
||||
if (event_bits & TASK_STOPPING) {
|
||||
ESP_LOGV(TAG, "Stopping");
|
||||
xEventGroupClearBits(this->event_group_, TASK_STOPPING);
|
||||
}
|
||||
|
||||
if (event_bits & TASK_ERROR) {
|
||||
// Report error so the orchestrator knows playback failed; task will have already logged the specific error
|
||||
this->set_state_(media_source::MediaSourceState::ERROR);
|
||||
}
|
||||
|
||||
if (event_bits & TASK_STOPPED) {
|
||||
ESP_LOGD(TAG, "Stopped");
|
||||
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
||||
|
||||
this->decode_task_.deallocate();
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
this->decoding_state_ = AudioFileDecodingState::IDLE;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AudioFileDecodingState::IDLE: {
|
||||
if (this->get_state() == media_source::MediaSourceState::ERROR && !this->status_has_error()) {
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ((this->decoding_state_ == AudioFileDecodingState::IDLE) &&
|
||||
(this->get_state() == media_source::MediaSourceState::IDLE)) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
bool AudioFileMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); }
|
||||
|
||||
// Called from the orchestrator's main loop, so no synchronization needed with loop()
|
||||
bool AudioFileMediaSource::play_uri(const std::string &uri) {
|
||||
if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener() ||
|
||||
xEventGroupGetBits(this->event_group_) & REQUEST_START) {
|
||||
if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if source is already playing
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate URI starts with "audio-file://"
|
||||
if (!uri.starts_with("audio-file://")) {
|
||||
if (!uri.starts_with(URI_PREFIX)) {
|
||||
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Strip "audio-file://" prefix and find the file
|
||||
const char *file_id = uri.c_str() + 13; // "audio-file://" is 13 characters
|
||||
|
||||
const char *file_id = uri.c_str() + sizeof(URI_PREFIX) - 1;
|
||||
this->current_file_ = nullptr;
|
||||
for (const auto &named_file : get_named_audio_files()) {
|
||||
if (strcmp(named_file.file_id, file_id) == 0) {
|
||||
this->current_file_ = named_file.file;
|
||||
xEventGroupSetBits(this->event_group_, EventGroupBits::REQUEST_START);
|
||||
this->enable_loop();
|
||||
return true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGE(TAG, "Unknown file: '%s'", file_id);
|
||||
if (this->current_file_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Unknown file: '%s'", file_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
micro_decoder::AudioFileType type = to_micro_decoder_type(this->current_file_->file_type);
|
||||
if (this->decoder_->play_buffer(this->current_file_->data, this->current_file_->length, type)) {
|
||||
this->pause_.store(false, std::memory_order_relaxed);
|
||||
this->enable_loop();
|
||||
return true;
|
||||
}
|
||||
|
||||
ESP_LOGE(TAG, "Failed to start playback of '%s'", file_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Called from the orchestrator's main loop, so no synchronization needed with loop()
|
||||
void AudioFileMediaSource::handle_command(media_source::MediaSourceCommand command) {
|
||||
if (this->decoding_state_ != AudioFileDecodingState::DECODING) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case media_source::MediaSourceCommand::STOP:
|
||||
xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_STOP);
|
||||
this->decoder_->stop();
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PAUSE:
|
||||
xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_PAUSE);
|
||||
// Only valid while actively playing; ignoring from IDLE/ERROR/PAUSED prevents the state
|
||||
// machine from getting stuck in PAUSED when no playback is active (which would block the
|
||||
// next play_uri() call via its IDLE-state precondition).
|
||||
if (this->get_state() != media_source::MediaSourceState::PLAYING)
|
||||
break;
|
||||
// PAUSE does not stop the decoder task. Instead, on_audio_write() returns 0 and temporarily
|
||||
// yields, which fills any internal buffering and applies back pressure that effectively
|
||||
// pauses the decoder task.
|
||||
this->set_state_(media_source::MediaSourceState::PAUSED);
|
||||
this->pause_.store(true, std::memory_order_relaxed);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PLAY:
|
||||
xEventGroupClearBits(this->event_group_, EventGroupBits::COMMAND_PAUSE);
|
||||
if (this->get_state() != media_source::MediaSourceState::PAUSED)
|
||||
break;
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
this->pause_.store(false, std::memory_order_relaxed);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void AudioFileMediaSource::decode_task(void *params) {
|
||||
AudioFileMediaSource *this_source = static_cast<AudioFileMediaSource *>(params);
|
||||
// Called from the decoder task. Forwards to the orchestrator's listener, which is responsible for
|
||||
// being thread-safe with respect to its own audio writer.
|
||||
size_t AudioFileMediaSource::on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) {
|
||||
if (this->pause_.load(std::memory_order_relaxed)) {
|
||||
vTaskDelay(pdMS_TO_TICKS(PAUSE_POLL_DELAY_MS));
|
||||
return 0;
|
||||
}
|
||||
return this->write_output(data, length, timeout_ms, this->stream_info_);
|
||||
}
|
||||
|
||||
do { // do-while(false) ensures RAII objects are destroyed on all exit paths via break
|
||||
// Called from the decoder task before the first on_audio_write().
|
||||
void AudioFileMediaSource::on_stream_info(const micro_decoder::AudioStreamInfo &info) {
|
||||
this->stream_info_ = audio::AudioStreamInfo(info.get_bits_per_sample(), info.get_channels(), info.get_sample_rate());
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STARTING);
|
||||
|
||||
// 0 bytes for input transfer buffer makes it an inplace buffer
|
||||
std::unique_ptr<audio::AudioDecoder> decoder = make_unique<audio::AudioDecoder>(0, 4096);
|
||||
|
||||
esp_err_t err = decoder->start(this_source->current_file_->file_type);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to start decoder: %s", esp_err_to_name(err));
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR | EventGroupBits::TASK_STOPPING);
|
||||
// microDecoder invokes on_state_change() from inside decoder_->loop(), so this runs on the main
|
||||
// loop thread and it's safe to call set_state_() directly.
|
||||
void AudioFileMediaSource::on_state_change(micro_decoder::DecoderState state) {
|
||||
switch (state) {
|
||||
case micro_decoder::DecoderState::IDLE:
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
this->disable_loop();
|
||||
break;
|
||||
}
|
||||
|
||||
// Add the file as a const data source
|
||||
decoder->add_source(this_source->current_file_->data, this_source->current_file_->length);
|
||||
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_RUNNING);
|
||||
|
||||
AudioSinkAdapter audio_sink;
|
||||
bool has_stream_info = false;
|
||||
|
||||
while (true) {
|
||||
EventBits_t event_bits = xEventGroupGetBits(this_source->event_group_);
|
||||
|
||||
if (event_bits & EventGroupBits::COMMAND_STOP) {
|
||||
break;
|
||||
}
|
||||
|
||||
bool paused = event_bits & EventGroupBits::COMMAND_PAUSE;
|
||||
decoder->set_pause_output_state(paused);
|
||||
if (paused) {
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_PAUSED);
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
} else {
|
||||
xEventGroupClearBits(this_source->event_group_, EventGroupBits::TASK_PAUSED);
|
||||
}
|
||||
|
||||
// Will stop gracefully once finished with the current file
|
||||
audio::AudioDecoderState decoder_state = decoder->decode(true);
|
||||
|
||||
if (decoder_state == audio::AudioDecoderState::FINISHED) {
|
||||
break;
|
||||
} else if (decoder_state == audio::AudioDecoderState::FAILED) {
|
||||
ESP_LOGE(TAG, "Decoder failed");
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!has_stream_info && decoder->get_audio_stream_info().has_value()) {
|
||||
has_stream_info = true;
|
||||
|
||||
audio::AudioStreamInfo stream_info = decoder->get_audio_stream_info().value();
|
||||
|
||||
ESP_LOGD(TAG, "Bits per sample: %d, Channels: %d, Sample rate: %" PRIu32, stream_info.get_bits_per_sample(),
|
||||
stream_info.get_channels(), stream_info.get_sample_rate());
|
||||
|
||||
if (stream_info.get_bits_per_sample() != 16 || stream_info.get_channels() > 2) {
|
||||
ESP_LOGE(TAG, "Incompatible audio stream. Only 16 bits per sample and 1 or 2 channels are supported");
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
|
||||
break;
|
||||
}
|
||||
|
||||
audio_sink.source = this_source;
|
||||
audio_sink.stream_info = stream_info;
|
||||
esp_err_t err = decoder->add_sink(&audio_sink);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to add sink: %s", esp_err_to_name(err));
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STOPPING);
|
||||
} while (false);
|
||||
|
||||
// All RAII objects from the do-while block (decoder, audio_sink, etc.) are now destroyed.
|
||||
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STOPPED);
|
||||
vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it
|
||||
case micro_decoder::DecoderState::PLAYING:
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
break;
|
||||
case micro_decoder::DecoderState::FAILED:
|
||||
this->set_state_(media_source::MediaSourceState::ERROR);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::audio_file
|
||||
|
||||
@@ -8,41 +8,48 @@
|
||||
#include "esphome/components/audio_file/audio_file.h"
|
||||
#include "esphome/components/media_source/media_source.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/static_task.h"
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/event_groups.h>
|
||||
#include <micro_decoder/decoder_source.h>
|
||||
#include <micro_decoder/types.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace esphome::audio_file {
|
||||
|
||||
enum class AudioFileDecodingState : uint8_t {
|
||||
START_TASK,
|
||||
DECODING,
|
||||
IDLE,
|
||||
};
|
||||
|
||||
class AudioFileMediaSource : public Component, public media_source::MediaSource {
|
||||
// Inherits from two unrelated listener-style interfaces:
|
||||
// - media_source::MediaSource: this source reports state and writes audio *to* an orchestrator
|
||||
// (the orchestrator calls set_listener() on us with a MediaSourceListener*).
|
||||
// - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded
|
||||
// audio and state changes (we call decoder_->set_listener(this) in setup()).
|
||||
class AudioFileMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
|
||||
|
||||
// MediaSource interface implementation
|
||||
bool play_uri(const std::string &uri) override;
|
||||
void handle_command(media_source::MediaSourceCommand command) override;
|
||||
bool can_handle(const std::string &uri) const override { return uri.starts_with("audio-file://"); }
|
||||
bool can_handle(const std::string &uri) const override;
|
||||
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
|
||||
// DecoderListener interface implementation
|
||||
size_t on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) override;
|
||||
void on_stream_info(const micro_decoder::AudioStreamInfo &info) override;
|
||||
void on_state_change(micro_decoder::DecoderState state) override;
|
||||
|
||||
protected:
|
||||
static void decode_task(void *params);
|
||||
|
||||
std::unique_ptr<micro_decoder::DecoderSource> decoder_;
|
||||
audio::AudioStreamInfo stream_info_;
|
||||
audio::AudioFile *current_file_{nullptr};
|
||||
AudioFileDecodingState decoding_state_{AudioFileDecodingState::IDLE};
|
||||
EventGroupHandle_t event_group_{nullptr};
|
||||
StaticTask decode_task_;
|
||||
|
||||
bool task_stack_in_psram_{false};
|
||||
// Written from the main loop in handle_command(), read from the decoder task in
|
||||
// on_audio_write(). Must be atomic to avoid a data race.
|
||||
std::atomic<bool> pause_{false};
|
||||
bool decoder_task_stack_in_psram_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::audio_file
|
||||
|
||||
@@ -62,6 +62,7 @@ CONF_IS_WRGB = "is_wrgb"
|
||||
SUPPORTED_PINS = {
|
||||
libretiny.const.FAMILY_BK7231N: [16],
|
||||
libretiny.const.FAMILY_BK7231T: [16],
|
||||
libretiny.const.FAMILY_BK7238: [16],
|
||||
libretiny.const.FAMILY_BK7251: [16],
|
||||
}
|
||||
|
||||
|
||||
@@ -143,15 +143,15 @@ BinarySensorCondition = binary_sensor_ns.class_("BinarySensorCondition", Conditi
|
||||
|
||||
# Filters
|
||||
Filter = binary_sensor_ns.class_("Filter")
|
||||
TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter, cg.Component)
|
||||
DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter, cg.Component)
|
||||
DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter, cg.Component)
|
||||
DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter, cg.Component)
|
||||
TimeoutFilter = binary_sensor_ns.class_("TimeoutFilter", Filter)
|
||||
DelayedOnOffFilter = binary_sensor_ns.class_("DelayedOnOffFilter", Filter)
|
||||
DelayedOnFilter = binary_sensor_ns.class_("DelayedOnFilter", Filter)
|
||||
DelayedOffFilter = binary_sensor_ns.class_("DelayedOffFilter", Filter)
|
||||
InvertFilter = binary_sensor_ns.class_("InvertFilter", Filter)
|
||||
AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter, cg.Component)
|
||||
AutorepeatFilter = binary_sensor_ns.class_("AutorepeatFilter", Filter)
|
||||
LambdaFilter = binary_sensor_ns.class_("LambdaFilter", Filter)
|
||||
StatelessLambdaFilter = binary_sensor_ns.class_("StatelessLambdaFilter", Filter)
|
||||
SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter, cg.Component)
|
||||
SettleFilter = binary_sensor_ns.class_("SettleFilter", Filter)
|
||||
|
||||
_LOGGER = getLogger(__name__)
|
||||
|
||||
@@ -175,7 +175,6 @@ async def invert_filter_to_code(config, filter_id):
|
||||
)
|
||||
async def timeout_filter_to_code(config, filter_id):
|
||||
var = cg.new_Pvariable(filter_id)
|
||||
await cg.register_component(var, {})
|
||||
template_ = await cg.templatable(config, [], cg.uint32)
|
||||
cg.add(var.set_timeout_value(template_))
|
||||
return var
|
||||
@@ -203,7 +202,6 @@ async def timeout_filter_to_code(config, filter_id):
|
||||
)
|
||||
async def delayed_on_off_filter_to_code(config, filter_id):
|
||||
var = cg.new_Pvariable(filter_id)
|
||||
await cg.register_component(var, {})
|
||||
if isinstance(config, dict):
|
||||
template_ = await cg.templatable(config[CONF_TIME_ON], [], cg.uint32)
|
||||
cg.add(var.set_on_delay(template_))
|
||||
@@ -221,7 +219,6 @@ async def delayed_on_off_filter_to_code(config, filter_id):
|
||||
)
|
||||
async def delayed_on_filter_to_code(config, filter_id):
|
||||
var = cg.new_Pvariable(filter_id)
|
||||
await cg.register_component(var, {})
|
||||
template_ = await cg.templatable(config, [], cg.uint32)
|
||||
cg.add(var.set_delay(template_))
|
||||
return var
|
||||
@@ -234,7 +231,6 @@ async def delayed_on_filter_to_code(config, filter_id):
|
||||
)
|
||||
async def delayed_off_filter_to_code(config, filter_id):
|
||||
var = cg.new_Pvariable(filter_id)
|
||||
await cg.register_component(var, {})
|
||||
template_ = await cg.templatable(config, [], cg.uint32)
|
||||
cg.add(var.set_delay(template_))
|
||||
return var
|
||||
@@ -286,9 +282,7 @@ async def autorepeat_filter_to_code(config, filter_id):
|
||||
),
|
||||
)
|
||||
]
|
||||
var = cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings)
|
||||
await cg.register_component(var, {})
|
||||
return var
|
||||
return cg.new_Pvariable(filter_id, cg.TemplateArguments(len(timings)), timings)
|
||||
|
||||
|
||||
@register_filter("lambda", LambdaFilter, cv.returning_lambda)
|
||||
@@ -306,7 +300,6 @@ async def lambda_filter_to_code(config, filter_id):
|
||||
)
|
||||
async def settle_filter_to_code(config, filter_id):
|
||||
var = cg.new_Pvariable(filter_id)
|
||||
await cg.register_component(var, {})
|
||||
template_ = await cg.templatable(config, [], cg.uint32)
|
||||
cg.add(var.set_delay(template_))
|
||||
return var
|
||||
|
||||
@@ -4,19 +4,12 @@
|
||||
#include "filter.h"
|
||||
|
||||
#include "binary_sensor.h"
|
||||
#include "esphome/core/application.h"
|
||||
|
||||
namespace esphome::binary_sensor {
|
||||
|
||||
static const char *const TAG = "sensor.filter";
|
||||
|
||||
// Timeout IDs for filter classes.
|
||||
// Each filter is its own Component instance, so the scheduler scopes
|
||||
// IDs by component pointer — no risk of collisions between instances.
|
||||
constexpr uint32_t FILTER_TIMEOUT_ID = 0;
|
||||
// AutorepeatFilter needs two distinct IDs (both timeouts on the same component)
|
||||
constexpr uint32_t AUTOREPEAT_TIMING_ID = 0;
|
||||
constexpr uint32_t AUTOREPEAT_ON_OFF_ID = 1;
|
||||
|
||||
void Filter::output(bool value) {
|
||||
if (this->next_ == nullptr) {
|
||||
this->parent_->send_state_internal(value);
|
||||
@@ -34,49 +27,47 @@ void Filter::input(bool value) {
|
||||
}
|
||||
|
||||
void TimeoutFilter::input(bool value) {
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); });
|
||||
App.scheduler.set_timeout(this, this->timeout_delay_.value(), [this]() { this->parent_->invalidate_state(); });
|
||||
// we do not de-dup here otherwise changes from invalid to valid state will not be output
|
||||
this->output(value);
|
||||
}
|
||||
|
||||
optional<bool> DelayedOnOffFilter::new_value(bool value) {
|
||||
if (value) {
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->on_delay_.value(), [this]() { this->output(true); });
|
||||
App.scheduler.set_timeout(this, this->on_delay_.value(), [this]() { this->output(true); });
|
||||
} else {
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->off_delay_.value(), [this]() { this->output(false); });
|
||||
App.scheduler.set_timeout(this, this->off_delay_.value(), [this]() { this->output(false); });
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
float DelayedOnOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
optional<bool> DelayedOnFilter::new_value(bool value) {
|
||||
if (value) {
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(true); });
|
||||
App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(true); });
|
||||
return {};
|
||||
} else {
|
||||
this->cancel_timeout(FILTER_TIMEOUT_ID);
|
||||
App.scheduler.cancel_timeout(this);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
float DelayedOnFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
optional<bool> DelayedOffFilter::new_value(bool value) {
|
||||
if (!value) {
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->output(false); });
|
||||
App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->output(false); });
|
||||
return {};
|
||||
} else {
|
||||
this->cancel_timeout(FILTER_TIMEOUT_ID);
|
||||
App.scheduler.cancel_timeout(this);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
float DelayedOffFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
optional<bool> InvertFilter::new_value(bool value) { return !value; }
|
||||
|
||||
// AutorepeatFilterBase
|
||||
// Two independent timers per instance, keyed off two stable addresses inside
|
||||
// the filter: `this` for the timing-step timer, `&active_timing_` for the
|
||||
// on/off timer. Both are unique per instance and don't collide with anything
|
||||
// else, so the self-keyed scheduler API is sufficient.
|
||||
optional<bool> AutorepeatFilterBase::new_value(bool value) {
|
||||
if (value) {
|
||||
if (this->active_timing_ != 0)
|
||||
@@ -84,8 +75,8 @@ optional<bool> AutorepeatFilterBase::new_value(bool value) {
|
||||
this->next_timing_();
|
||||
return true;
|
||||
} else {
|
||||
this->cancel_timeout(AUTOREPEAT_TIMING_ID);
|
||||
this->cancel_timeout(AUTOREPEAT_ON_OFF_ID);
|
||||
App.scheduler.cancel_timeout(this);
|
||||
App.scheduler.cancel_timeout(&this->active_timing_);
|
||||
this->active_timing_ = 0;
|
||||
return false;
|
||||
}
|
||||
@@ -93,8 +84,7 @@ optional<bool> AutorepeatFilterBase::new_value(bool value) {
|
||||
|
||||
void AutorepeatFilterBase::next_timing_() {
|
||||
if (this->active_timing_ < this->timings_count_) {
|
||||
this->set_timeout(AUTOREPEAT_TIMING_ID, this->timings_[this->active_timing_].delay,
|
||||
[this]() { this->next_timing_(); });
|
||||
App.scheduler.set_timeout(this, this->timings_[this->active_timing_].delay, [this]() { this->next_timing_(); });
|
||||
}
|
||||
if (this->active_timing_ <= this->timings_count_) {
|
||||
this->active_timing_++;
|
||||
@@ -106,32 +96,28 @@ void AutorepeatFilterBase::next_timing_() {
|
||||
void AutorepeatFilterBase::next_value_(bool val) {
|
||||
const AutorepeatFilterTiming &timing = this->timings_[this->active_timing_ - 2];
|
||||
this->output(val);
|
||||
this->set_timeout(AUTOREPEAT_ON_OFF_ID, val ? timing.time_on : timing.time_off,
|
||||
[this, val]() { this->next_value_(!val); });
|
||||
App.scheduler.set_timeout(&this->active_timing_, val ? timing.time_on : timing.time_off,
|
||||
[this, val]() { this->next_value_(!val); });
|
||||
}
|
||||
|
||||
float AutorepeatFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
LambdaFilter::LambdaFilter(std::function<optional<bool>(bool)> f) : f_(std::move(f)) {}
|
||||
|
||||
optional<bool> LambdaFilter::new_value(bool value) { return this->f_(value); }
|
||||
|
||||
optional<bool> SettleFilter::new_value(bool value) {
|
||||
if (!this->steady_) {
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this, value]() {
|
||||
App.scheduler.set_timeout(this, this->delay_.value(), [this, value]() {
|
||||
this->steady_ = true;
|
||||
this->output(value);
|
||||
});
|
||||
return {};
|
||||
} else {
|
||||
this->steady_ = false;
|
||||
this->set_timeout(FILTER_TIMEOUT_ID, this->delay_.value(), [this]() { this->steady_ = true; });
|
||||
App.scheduler.set_timeout(this, this->delay_.value(), [this]() { this->steady_ = true; });
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
float SettleFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
} // namespace esphome::binary_sensor
|
||||
|
||||
#endif // USE_BINARY_SENSOR_FILTER
|
||||
|
||||
@@ -29,7 +29,7 @@ class Filter {
|
||||
Deduplicator<bool> dedup_;
|
||||
};
|
||||
|
||||
class TimeoutFilter : public Filter, public Component {
|
||||
class TimeoutFilter : public Filter {
|
||||
public:
|
||||
optional<bool> new_value(bool value) override { return value; }
|
||||
void input(bool value) override;
|
||||
@@ -39,12 +39,10 @@ class TimeoutFilter : public Filter, public Component {
|
||||
TemplatableFn<uint32_t> timeout_delay_{};
|
||||
};
|
||||
|
||||
class DelayedOnOffFilter final : public Filter, public Component {
|
||||
class DelayedOnOffFilter final : public Filter {
|
||||
public:
|
||||
optional<bool> new_value(bool value) override;
|
||||
|
||||
float get_setup_priority() const override;
|
||||
|
||||
template<typename T> void set_on_delay(T delay) { this->on_delay_ = delay; }
|
||||
template<typename T> void set_off_delay(T delay) { this->off_delay_ = delay; }
|
||||
|
||||
@@ -53,24 +51,20 @@ class DelayedOnOffFilter final : public Filter, public Component {
|
||||
TemplatableFn<uint32_t> off_delay_{};
|
||||
};
|
||||
|
||||
class DelayedOnFilter : public Filter, public Component {
|
||||
class DelayedOnFilter : public Filter {
|
||||
public:
|
||||
optional<bool> new_value(bool value) override;
|
||||
|
||||
float get_setup_priority() const override;
|
||||
|
||||
template<typename T> void set_delay(T delay) { this->delay_ = delay; }
|
||||
|
||||
protected:
|
||||
TemplatableFn<uint32_t> delay_{};
|
||||
};
|
||||
|
||||
class DelayedOffFilter : public Filter, public Component {
|
||||
class DelayedOffFilter : public Filter {
|
||||
public:
|
||||
optional<bool> new_value(bool value) override;
|
||||
|
||||
float get_setup_priority() const override;
|
||||
|
||||
template<typename T> void set_delay(T delay) { this->delay_ = delay; }
|
||||
|
||||
protected:
|
||||
@@ -90,10 +84,11 @@ struct AutorepeatFilterTiming {
|
||||
|
||||
/// Non-template base for AutorepeatFilter — all methods in filter.cpp.
|
||||
/// Lambdas capture this base pointer, so set_timeout/cancel_timeout are instantiated once.
|
||||
class AutorepeatFilterBase : public Filter, public Component {
|
||||
/// The two scheduled timers are keyed off `this` and `&active_timing_`; since the address
|
||||
/// of `active_timing_` is taken as a scheduler key, the class must not be copied or moved.
|
||||
class AutorepeatFilterBase : public Filter {
|
||||
public:
|
||||
optional<bool> new_value(bool value) override;
|
||||
float get_setup_priority() const override;
|
||||
AutorepeatFilterBase(const AutorepeatFilterBase &) = delete;
|
||||
AutorepeatFilterBase &operator=(const AutorepeatFilterBase &) = delete;
|
||||
|
||||
@@ -146,12 +141,10 @@ class StatelessLambdaFilter : public Filter {
|
||||
optional<bool> (*f_)(bool);
|
||||
};
|
||||
|
||||
class SettleFilter : public Filter, public Component {
|
||||
class SettleFilter : public Filter {
|
||||
public:
|
||||
optional<bool> new_value(bool value) override;
|
||||
|
||||
float get_setup_priority() const override;
|
||||
|
||||
template<typename T> void set_delay(T delay) { this->delay_ = delay; }
|
||||
|
||||
protected:
|
||||
|
||||
@@ -48,13 +48,13 @@ from esphome.const import (
|
||||
CONF_VISUAL,
|
||||
CONF_WEB_SERVER,
|
||||
)
|
||||
from esphome.core import CORE, CoroPriority, coroutine_with_priority
|
||||
from esphome.core import CORE, CoroPriority, Lambda, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
setup_entity,
|
||||
)
|
||||
from esphome.cpp_generator import MockObjClass
|
||||
from esphome.cpp_generator import LambdaExpression, MockObjClass
|
||||
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
|
||||
@@ -487,38 +487,65 @@ CLIMATE_CONTROL_ACTION_SCHEMA = cv.Schema(
|
||||
)
|
||||
async def climate_control_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
if (mode := config.get(CONF_MODE)) is not None:
|
||||
template_ = await cg.templatable(mode, args, ClimateMode)
|
||||
cg.add(var.set_mode(template_))
|
||||
if (target_temp := config.get(CONF_TARGET_TEMPERATURE)) is not None:
|
||||
template_ = await cg.templatable(target_temp, args, cg.float_)
|
||||
cg.add(var.set_target_temperature(template_))
|
||||
if (target_temp_low := config.get(CONF_TARGET_TEMPERATURE_LOW)) is not None:
|
||||
template_ = await cg.templatable(target_temp_low, args, cg.float_)
|
||||
cg.add(var.set_target_temperature_low(template_))
|
||||
if (target_temp_high := config.get(CONF_TARGET_TEMPERATURE_HIGH)) is not None:
|
||||
template_ = await cg.templatable(target_temp_high, args, cg.float_)
|
||||
cg.add(var.set_target_temperature_high(template_))
|
||||
if (target_humidity := config.get(CONF_TARGET_HUMIDITY)) is not None:
|
||||
template_ = await cg.templatable(target_humidity, args, cg.float_)
|
||||
cg.add(var.set_target_humidity(template_))
|
||||
if (fan_mode := config.get(CONF_FAN_MODE)) is not None:
|
||||
template_ = await cg.templatable(fan_mode, args, ClimateFanMode)
|
||||
cg.add(var.set_fan_mode(template_))
|
||||
if (custom_fan_mode := config.get(CONF_CUSTOM_FAN_MODE)) is not None:
|
||||
template_ = await cg.templatable(custom_fan_mode, args, cg.std_string)
|
||||
cg.add(var.set_custom_fan_mode(template_))
|
||||
if (preset := config.get(CONF_PRESET)) is not None:
|
||||
template_ = await cg.templatable(preset, args, ClimatePreset)
|
||||
cg.add(var.set_preset(template_))
|
||||
if (custom_preset := config.get(CONF_CUSTOM_PRESET)) is not None:
|
||||
template_ = await cg.templatable(custom_preset, args, cg.std_string)
|
||||
cg.add(var.set_custom_preset(template_))
|
||||
if (swing_mode := config.get(CONF_SWING_MODE)) is not None:
|
||||
template_ = await cg.templatable(swing_mode, args, ClimateSwingMode)
|
||||
cg.add(var.set_swing_mode(template_))
|
||||
return var
|
||||
|
||||
# All configured fields are folded into a single stateless lambda whose
|
||||
# constants live in flash; the action stores only a function pointer.
|
||||
# For custom_fan_mode/custom_preset the static-string path emits the
|
||||
# (const char *, size_t) overload of set_fan_mode/set_preset to avoid
|
||||
# constructing a std::string and calling runtime strlen.
|
||||
FIELDS = (
|
||||
(CONF_MODE, "set_mode", ClimateMode),
|
||||
(CONF_TARGET_TEMPERATURE, "set_target_temperature", cg.float_),
|
||||
(CONF_TARGET_TEMPERATURE_LOW, "set_target_temperature_low", cg.float_),
|
||||
(CONF_TARGET_TEMPERATURE_HIGH, "set_target_temperature_high", cg.float_),
|
||||
(CONF_TARGET_HUMIDITY, "set_target_humidity", cg.float_),
|
||||
(CONF_FAN_MODE, "set_fan_mode", ClimateFanMode),
|
||||
(CONF_CUSTOM_FAN_MODE, "set_fan_mode", cg.std_string),
|
||||
(CONF_PRESET, "set_preset", ClimatePreset),
|
||||
(CONF_CUSTOM_PRESET, "set_preset", cg.std_string),
|
||||
(CONF_SWING_MODE, "set_swing_mode", ClimateSwingMode),
|
||||
)
|
||||
|
||||
# Normalize trigger args to `const std::remove_cvref_t<T> &` so the
|
||||
# apply lambda and any inner field lambdas (generated below via
|
||||
# `process_lambda`) share one parameter spelling that's well-formed for
|
||||
# any T (value, ref, or const-ref). Matches ControlAction::ApplyFn.
|
||||
normalized_args = [
|
||||
(cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n)
|
||||
for t, n in args
|
||||
]
|
||||
|
||||
fwd_args = ", ".join(name for _, name in args)
|
||||
body_lines: list[str] = []
|
||||
|
||||
for conf_key, setter, type_ in FIELDS:
|
||||
if (value := config.get(conf_key)) is None:
|
||||
continue
|
||||
if isinstance(value, Lambda):
|
||||
inner = await cg.process_lambda(value, normalized_args, return_type=type_)
|
||||
body_lines.append(f"call.{setter}(({inner})({fwd_args}));")
|
||||
elif type_ is cg.std_string:
|
||||
# Static custom strings: emit a flash literal and pass the
|
||||
# UTF-8 byte length to skip the runtime strlen inside
|
||||
# set_fan_mode/set_preset.
|
||||
literal = cg.safe_exp(value)
|
||||
body_lines.append(
|
||||
f"call.{setter}({literal}, {len(value.encode('utf-8'))});"
|
||||
)
|
||||
else:
|
||||
body_lines.append(f"call.{setter}({cg.safe_exp(value)});")
|
||||
|
||||
apply_args = [
|
||||
(ClimateCall.operator("ref"), "call"),
|
||||
*normalized_args,
|
||||
]
|
||||
apply_lambda = LambdaExpression(
|
||||
["\n".join(body_lines)],
|
||||
apply_args,
|
||||
capture="",
|
||||
return_type=cg.void,
|
||||
)
|
||||
return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.CORE)
|
||||
|
||||
@@ -5,42 +5,32 @@
|
||||
|
||||
namespace esphome::climate {
|
||||
|
||||
// All configured fields are baked into a single stateless lambda whose
|
||||
// constants live in flash. The action only stores one function pointer
|
||||
// plus one parent pointer, regardless of how many fields the user set.
|
||||
// Trigger args are forwarded to the apply function so user lambdas
|
||||
// (e.g. `target_temperature: !lambda "return x;"`) keep working.
|
||||
//
|
||||
// Trigger args are normalized to `const std::remove_cvref_t<Ts> &...` so
|
||||
// the codegen can emit a matching parameter list for both the apply lambda
|
||||
// and any inner field lambdas without producing invalid C++ source text
|
||||
// (e.g. `const T & &` if Ts already carries a reference, or `const const
|
||||
// T &` if Ts already carries a const). This keeps trigger args no-copy
|
||||
// regardless of whether the trigger supplies `T`, `T &`, or `const T &`.
|
||||
template<typename... Ts> class ControlAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit ControlAction(Climate *climate) : climate_(climate) {}
|
||||
|
||||
TEMPLATABLE_VALUE(ClimateMode, mode)
|
||||
TEMPLATABLE_VALUE(float, target_temperature)
|
||||
TEMPLATABLE_VALUE(float, target_temperature_low)
|
||||
TEMPLATABLE_VALUE(float, target_temperature_high)
|
||||
TEMPLATABLE_VALUE(float, target_humidity)
|
||||
TEMPLATABLE_VALUE(bool, away)
|
||||
TEMPLATABLE_VALUE(ClimateFanMode, fan_mode)
|
||||
TEMPLATABLE_VALUE(std::string, custom_fan_mode)
|
||||
TEMPLATABLE_VALUE(ClimatePreset, preset)
|
||||
TEMPLATABLE_VALUE(std::string, custom_preset)
|
||||
TEMPLATABLE_VALUE(ClimateSwingMode, swing_mode)
|
||||
using ApplyFn = void (*)(ClimateCall &, const std::remove_cvref_t<Ts> &...);
|
||||
ControlAction(Climate *climate, ApplyFn apply) : climate_(climate), apply_(apply) {}
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
auto call = this->climate_->make_call();
|
||||
call.set_mode(this->mode_.optional_value(x...));
|
||||
call.set_target_temperature(this->target_temperature_.optional_value(x...));
|
||||
call.set_target_temperature_low(this->target_temperature_low_.optional_value(x...));
|
||||
call.set_target_temperature_high(this->target_temperature_high_.optional_value(x...));
|
||||
call.set_target_humidity(this->target_humidity_.optional_value(x...));
|
||||
if (away_.has_value()) {
|
||||
call.set_preset(away_.value(x...) ? CLIMATE_PRESET_AWAY : CLIMATE_PRESET_HOME);
|
||||
}
|
||||
call.set_fan_mode(this->fan_mode_.optional_value(x...));
|
||||
call.set_fan_mode(this->custom_fan_mode_.optional_value(x...));
|
||||
call.set_preset(this->preset_.optional_value(x...));
|
||||
call.set_preset(this->custom_preset_.optional_value(x...));
|
||||
call.set_swing_mode(this->swing_mode_.optional_value(x...));
|
||||
this->apply_(call, x...);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
protected:
|
||||
Climate *climate_;
|
||||
ApplyFn apply_;
|
||||
};
|
||||
|
||||
class ControlTrigger : public Trigger<ClimateCall &> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
|
||||
from esphome import automation
|
||||
@@ -36,14 +38,14 @@ from esphome.const import (
|
||||
DEVICE_CLASS_SHUTTER,
|
||||
DEVICE_CLASS_WINDOW,
|
||||
)
|
||||
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
|
||||
from esphome.core import CORE, ID, CoroPriority, Lambda, coroutine_with_priority
|
||||
from esphome.core.entity_helpers import (
|
||||
entity_duplicate_validator,
|
||||
queue_entity_register,
|
||||
setup_device_class,
|
||||
setup_entity,
|
||||
)
|
||||
from esphome.cpp_generator import MockObj, MockObjClass
|
||||
from esphome.cpp_generator import LambdaExpression, MockObj, MockObjClass
|
||||
from esphome.types import ConfigType, TemplateArgsType
|
||||
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
@@ -68,6 +70,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
cover_ns = cg.esphome_ns.namespace("cover")
|
||||
|
||||
Cover = cover_ns.class_("Cover", cg.EntityBase)
|
||||
CoverCall = cover_ns.class_("CoverCall")
|
||||
|
||||
COVER_OPEN = cover_ns.COVER_OPEN
|
||||
COVER_CLOSED = cover_ns.COVER_CLOSED
|
||||
@@ -294,25 +297,105 @@ COVER_CONTROL_ACTION_SCHEMA = cv.Schema(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ApplyField:
|
||||
"""One field in a folded-lambda action.
|
||||
|
||||
`conf_key` is the YAML key looked up in `config`. When present, the
|
||||
helper emits `statement_fn(target, value_expr)` into the lambda body.
|
||||
`target` is whatever the statement function needs to identify the
|
||||
field (typically a setter name like `"set_position"` or a struct
|
||||
member like `"position"`). `type_` is the C++ return type for
|
||||
`cg.process_lambda` when the value is a user lambda.
|
||||
"""
|
||||
|
||||
conf_key: str
|
||||
target: str
|
||||
type_: object
|
||||
|
||||
|
||||
async def build_apply_lambda_action(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
fields: tuple[ApplyField, ...],
|
||||
prefix_args: list[tuple[object, str]],
|
||||
statement_fn: Callable[[str, str], str],
|
||||
) -> MockObj:
|
||||
"""Fold configured fields into a single stateless apply lambda action.
|
||||
|
||||
Used by both `cover.control` and `cover.template.publish` (and shared
|
||||
with the template/cover platform). Constants are emitted as flash
|
||||
immediates; user lambdas are invoked inline so trigger args still flow.
|
||||
Trigger arg types are normalized to `const std::remove_cvref_t<T> &`
|
||||
to match the ApplyFn signature for any T (value, ref, or const-ref).
|
||||
"""
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
# Normalize trigger args to `const std::remove_cvref_t<T> &` so the
|
||||
# apply lambda and any inner field lambdas (generated below via
|
||||
# `process_lambda`) share one parameter spelling that's well-formed for
|
||||
# any T.
|
||||
normalized_args = [
|
||||
(cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n)
|
||||
for t, n in args
|
||||
]
|
||||
|
||||
fwd_args = ", ".join(name for _, name in args)
|
||||
body_lines: list[str] = []
|
||||
for field in fields:
|
||||
if (value := config.get(field.conf_key)) is None:
|
||||
continue
|
||||
if isinstance(value, Lambda):
|
||||
inner = await cg.process_lambda(
|
||||
value, normalized_args, return_type=field.type_
|
||||
)
|
||||
value_expr = f"({inner})({fwd_args})"
|
||||
else:
|
||||
value_expr = str(cg.safe_exp(value))
|
||||
body_lines.append(statement_fn(field.target, value_expr))
|
||||
|
||||
apply_args = [
|
||||
*prefix_args,
|
||||
*normalized_args,
|
||||
]
|
||||
apply_lambda = LambdaExpression(
|
||||
["\n".join(body_lines)],
|
||||
apply_args,
|
||||
capture="",
|
||||
return_type=cg.void,
|
||||
)
|
||||
return cg.new_Pvariable(action_id, template_arg, paren, apply_lambda)
|
||||
|
||||
|
||||
# CONF_STATE and CONF_POSITION are cv.Exclusive in the schema, so at most
|
||||
# one is present and both dispatch to set_position.
|
||||
_COVER_CONTROL_FIELDS: tuple[ApplyField, ...] = (
|
||||
ApplyField(CONF_STOP, "set_stop", cg.bool_),
|
||||
ApplyField(CONF_STATE, "set_position", cg.float_),
|
||||
ApplyField(CONF_POSITION, "set_position", cg.float_),
|
||||
ApplyField(CONF_TILT, "set_tilt", cg.float_),
|
||||
)
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"cover.control", ControlAction, COVER_CONTROL_ACTION_SCHEMA, synchronous=True
|
||||
)
|
||||
async def cover_control_to_code(config, action_id, template_arg, args):
|
||||
paren = await cg.get_variable(config[CONF_ID])
|
||||
var = cg.new_Pvariable(action_id, template_arg, paren)
|
||||
if (stop := config.get(CONF_STOP)) is not None:
|
||||
template_ = await cg.templatable(stop, args, cg.bool_)
|
||||
cg.add(var.set_stop(template_))
|
||||
if (state := config.get(CONF_STATE)) is not None:
|
||||
template_ = await cg.templatable(state, args, cg.float_)
|
||||
cg.add(var.set_position(template_))
|
||||
if (position := config.get(CONF_POSITION)) is not None:
|
||||
template_ = await cg.templatable(position, args, cg.float_)
|
||||
cg.add(var.set_position(template_))
|
||||
if (tilt := config.get(CONF_TILT)) is not None:
|
||||
template_ = await cg.templatable(tilt, args, cg.float_)
|
||||
cg.add(var.set_tilt(template_))
|
||||
return var
|
||||
async def cover_control_to_code(
|
||||
config: ConfigType,
|
||||
action_id: ID,
|
||||
template_arg: cg.TemplateArguments,
|
||||
args: TemplateArgsType,
|
||||
) -> MockObj:
|
||||
return await build_apply_lambda_action(
|
||||
config=config,
|
||||
action_id=action_id,
|
||||
template_arg=template_arg,
|
||||
args=args,
|
||||
fields=_COVER_CONTROL_FIELDS,
|
||||
prefix_args=[(CoverCall.operator("ref"), "call")],
|
||||
statement_fn=lambda setter, expr: f"call.{setter}({expr});",
|
||||
)
|
||||
|
||||
|
||||
COVER_CONDITION_SCHEMA = cv.maybe_simple_value(
|
||||
|
||||
@@ -46,48 +46,48 @@ template<typename... Ts> class ToggleAction : public Action<Ts...> {
|
||||
Cover *cover_;
|
||||
};
|
||||
|
||||
// All configured fields are baked into a single stateless lambda whose
|
||||
// constants live in flash. Each action stores only one function pointer
|
||||
// plus one parent pointer, regardless of how many fields the user set.
|
||||
// Trigger args are forwarded to the apply function so user lambdas
|
||||
// (e.g. `position: !lambda "return x;"`) keep working.
|
||||
//
|
||||
// Trigger args are normalized to `const std::remove_cvref_t<Ts> &...` so
|
||||
// the codegen can emit a matching parameter list for both the apply lambda
|
||||
// and any inner field lambdas without producing invalid C++ source text
|
||||
// (e.g. `const T & &` if Ts already carries a reference, or `const const
|
||||
// T &` if Ts already carries a const). This keeps trigger args no-copy
|
||||
// regardless of whether the trigger supplies `T`, `T &`, or `const T &`.
|
||||
|
||||
template<typename... Ts> class ControlAction : public Action<Ts...> {
|
||||
public:
|
||||
explicit ControlAction(Cover *cover) : cover_(cover) {}
|
||||
|
||||
TEMPLATABLE_VALUE(bool, stop)
|
||||
TEMPLATABLE_VALUE(float, position)
|
||||
TEMPLATABLE_VALUE(float, tilt)
|
||||
using ApplyFn = void (*)(CoverCall &, const std::remove_cvref_t<Ts> &...);
|
||||
ControlAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {}
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
auto call = this->cover_->make_call();
|
||||
if (this->stop_.has_value())
|
||||
call.set_stop(this->stop_.value(x...));
|
||||
if (this->position_.has_value())
|
||||
call.set_position(this->position_.value(x...));
|
||||
if (this->tilt_.has_value())
|
||||
call.set_tilt(this->tilt_.value(x...));
|
||||
this->apply_(call, x...);
|
||||
call.perform();
|
||||
}
|
||||
|
||||
protected:
|
||||
Cover *cover_;
|
||||
ApplyFn apply_;
|
||||
};
|
||||
|
||||
template<typename... Ts> class CoverPublishAction : public Action<Ts...> {
|
||||
public:
|
||||
CoverPublishAction(Cover *cover) : cover_(cover) {}
|
||||
TEMPLATABLE_VALUE(float, position)
|
||||
TEMPLATABLE_VALUE(float, tilt)
|
||||
TEMPLATABLE_VALUE(CoverOperation, current_operation)
|
||||
using ApplyFn = void (*)(Cover *, const std::remove_cvref_t<Ts> &...);
|
||||
CoverPublishAction(Cover *cover, ApplyFn apply) : cover_(cover), apply_(apply) {}
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
if (this->position_.has_value())
|
||||
this->cover_->position = this->position_.value(x...);
|
||||
if (this->tilt_.has_value())
|
||||
this->cover_->tilt = this->tilt_.value(x...);
|
||||
if (this->current_operation_.has_value())
|
||||
this->cover_->current_operation = this->current_operation_.value(x...);
|
||||
this->apply_(this->cover_, x...);
|
||||
this->cover_->publish_state();
|
||||
}
|
||||
|
||||
protected:
|
||||
Cover *cover_;
|
||||
ApplyFn apply_;
|
||||
};
|
||||
|
||||
template<bool OPEN, typename... Ts> class CoverPositionCondition : public Condition<Ts...> {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#pragma once
|
||||
|
||||
// DNM: integration-test bucketing CI probe — do not merge.
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/entity_base.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
@@ -89,6 +89,17 @@ def import_config(
|
||||
network: str = CONF_WIFI,
|
||||
encryption: bool = False,
|
||||
) -> None:
|
||||
"""Materialise a dashboard-imported device's YAML on disk.
|
||||
|
||||
Used by:
|
||||
- esphome.dashboard (legacy dashboard)
|
||||
- device-builder (esphome/device-builder) — called from the
|
||||
``devices/import`` WS handler to seed the YAML for an adopted
|
||||
factory firmware. Coordinate before changing the kwargs or the
|
||||
generated YAML's top-level keys; both consumers depend on the
|
||||
output shape (``esphome.name`` / ``packages:`` import url) to
|
||||
route subsequent compile + flash operations.
|
||||
"""
|
||||
p = Path(path)
|
||||
|
||||
if p.exists():
|
||||
|
||||
@@ -401,7 +401,6 @@ size_t DebugComponent::get_device_info_(std::span<char, DEVICE_INFO_BUFFER_SIZE>
|
||||
#endif
|
||||
auto uicr = [](volatile uint32_t *data, uint8_t size) {
|
||||
std::string res;
|
||||
char buf[sizeof(uint32_t) * 2 + 1];
|
||||
for (size_t i = 0; i < size; i++) {
|
||||
if (i > 0) {
|
||||
res += ' ';
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from esphome import yaml_util
|
||||
import esphome.codegen as cg
|
||||
@@ -489,6 +490,18 @@ def get_board(core_obj=None):
|
||||
|
||||
|
||||
def get_download_types(storage_json):
|
||||
"""Binary-download entries for a built ESP32 firmware.
|
||||
|
||||
Used by:
|
||||
- esphome.dashboard (legacy "Download .bin" button)
|
||||
- device-builder (esphome/device-builder) — same dispatch via
|
||||
``importlib.import_module(f"esphome.components.{platform}")``
|
||||
then ``module.get_download_types(storage)``. The contract is
|
||||
"returns ``list[dict]`` with at least ``title`` /
|
||||
``description`` / ``file`` / ``download`` keys"; please keep
|
||||
the shape stable so the new dashboard's download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"title": "Factory format (Previously Modern)",
|
||||
@@ -1753,7 +1766,17 @@ async def to_code(config):
|
||||
|
||||
# Wrap FILE*-based printf functions to eliminate newlib's _vfprintf_r
|
||||
# (~11 KB). See printf_stubs.cpp for implementation.
|
||||
if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF]:
|
||||
#
|
||||
# The wrap is only beneficial against newlib. Picolibc's tinystdio
|
||||
# implements vsnprintf by building a string-output FILE and calling
|
||||
# vfprintf, so vfprintf is unconditionally linked in by any caller
|
||||
# of snprintf/vsnprintf — effectively every build — and the wrap
|
||||
# saves nothing while costing ~170 B of shim. IDF 5.x defaults to
|
||||
# newlib on every variant; IDF 6.0+ switches to picolibc on every
|
||||
# variant.
|
||||
if conf[CONF_ADVANCED][CONF_ENABLE_FULL_PRINTF] or idf_version() >= cv.Version(
|
||||
6, 0, 0
|
||||
):
|
||||
cg.add_define("USE_FULL_PRINTF")
|
||||
else:
|
||||
for symbol in ("vprintf", "printf", "fprintf", "vfprintf"):
|
||||
@@ -2493,3 +2516,78 @@ def copy_files():
|
||||
CORE.relative_build_path(name).write_bytes(content)
|
||||
else:
|
||||
copy_file_if_changed(path, CORE.relative_build_path(name))
|
||||
|
||||
|
||||
def _decode_pc(config, addr):
|
||||
from esphome import platformio_api
|
||||
|
||||
idedata = platformio_api.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
_LOGGER.debug("decode_pc no addr2line")
|
||||
return
|
||||
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||
try:
|
||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
||||
return
|
||||
|
||||
if "?? ??:0" in translation:
|
||||
# Nothing useful
|
||||
return
|
||||
translation = translation.replace(" at ??:?", "").replace(":?", "")
|
||||
_LOGGER.warning("Decoded %s", translation)
|
||||
|
||||
|
||||
def _parse_register(config, regex, line):
|
||||
match = regex.match(line)
|
||||
if match is not None:
|
||||
_decode_pc(config, match.group(1))
|
||||
|
||||
|
||||
STACKTRACE_ESP32_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7}).*")
|
||||
STACKTRACE_ESP32_EXCVADDR_RE = re.compile(r"EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
|
||||
STACKTRACE_ESP32_C3_PC_RE = re.compile(r"MEPC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
|
||||
STACKTRACE_ESP32_C3_RA_RE = re.compile(r"RA\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
|
||||
STACKTRACE_BAD_ALLOC_RE = re.compile(
|
||||
r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$"
|
||||
)
|
||||
STACKTRACE_ESP32_BACKTRACE_RE = re.compile(
|
||||
r"Backtrace:(?:\s*0x[0-9a-fA-F]{8}:0x[0-9a-fA-F]{8})+"
|
||||
)
|
||||
STACKTRACE_ESP32_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}")
|
||||
# ESP32 crash handler (stored backtrace from previous boot)
|
||||
STACKTRACE_ESP32_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})")
|
||||
|
||||
|
||||
def process_stacktrace(config, line, backtrace_state):
|
||||
line = line.strip()
|
||||
|
||||
# ESP32 PC/EXCVADDR
|
||||
_parse_register(config, STACKTRACE_ESP32_PC_RE, line)
|
||||
_parse_register(config, STACKTRACE_ESP32_EXCVADDR_RE, line)
|
||||
# ESP32-C3 PC/RA
|
||||
_parse_register(config, STACKTRACE_ESP32_C3_PC_RE, line)
|
||||
_parse_register(config, STACKTRACE_ESP32_C3_RA_RE, line)
|
||||
|
||||
# bad alloc
|
||||
match = re.match(STACKTRACE_BAD_ALLOC_RE, line)
|
||||
if match is not None:
|
||||
_LOGGER.warning(
|
||||
"Memory allocation of %s bytes failed at %s", match.group(2), match.group(1)
|
||||
)
|
||||
_decode_pc(config, match.group(1))
|
||||
|
||||
# ESP32 crash handler backtrace (from previous boot)
|
||||
match = re.search(STACKTRACE_ESP32_CRASH_BT_RE, line)
|
||||
if match is not None:
|
||||
_decode_pc(config, match.group(1))
|
||||
|
||||
# ESP32 single-line backtrace
|
||||
match = re.match(STACKTRACE_ESP32_BACKTRACE_RE, line)
|
||||
if match is not None:
|
||||
_LOGGER.warning("Found stack trace! Trying to decode it")
|
||||
for addr in re.finditer(STACKTRACE_ESP32_BACKTRACE_PC_RE, line):
|
||||
_decode_pc(config, addr.group())
|
||||
|
||||
return backtrace_state
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#define PROGMEM
|
||||
#endif
|
||||
|
||||
namespace esphome::esp32 {}
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// Forward decl from helpers.h (esphome/core/helpers.h) — kept here so this
|
||||
@@ -42,6 +44,9 @@ __attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { dela
|
||||
__attribute__((always_inline)) inline void arch_feed_wdt() { esp_task_wdt_reset(); }
|
||||
__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_cpu_get_cycle_count(); }
|
||||
|
||||
void arch_init();
|
||||
uint32_t arch_get_cpu_freq_hz();
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_ESP32
|
||||
@@ -1,32 +1,38 @@
|
||||
/*
|
||||
* Linker wrap stubs for FILE*-based printf functions.
|
||||
* Linker wrap stubs for FILE*-based printf functions (newlib only).
|
||||
*
|
||||
* ESP-IDF SDK components (gpio driver, ringbuf, log_write) reference
|
||||
* fprintf(), printf(), vprintf(), and vfprintf() which pull in the full
|
||||
* printf implementation (~11 KB on newlib's _vfprintf_r, ~2.8 KB on
|
||||
* picolibc's vfprintf). This is a separate implementation from the one
|
||||
* used by snprintf/vsnprintf that handles FILE* stream I/O with buffering
|
||||
* and locking.
|
||||
* fprintf(), printf(), vprintf(), and vfprintf(), which on newlib pull
|
||||
* in _vfprintf_r (~11 KB) — a separate implementation from the one used
|
||||
* by snprintf/vsnprintf that handles FILE* stream I/O with buffering.
|
||||
*
|
||||
* ESPHome replaces the ESP-IDF log handler via esp_log_set_vprintf_(),
|
||||
* so the SDK's vprintf() path is dead code at runtime. The fprintf()
|
||||
* and printf() calls in SDK components are only in debug/assert paths
|
||||
* (gpio_dump_io_configuration, ringbuf diagnostics) that are either
|
||||
* GC'd or never called. Crash backtraces and panic output are
|
||||
* unaffected — they use esp_rom_printf() which is a ROM function
|
||||
* and does not go through libc.
|
||||
* unaffected; they use esp_rom_printf() which is a ROM function and
|
||||
* does not go through libc.
|
||||
*
|
||||
* These stubs redirect through vsnprintf() (which uses _svfprintf_r
|
||||
* already in the binary) and fwrite(), allowing the linker to
|
||||
* dead-code eliminate _vfprintf_r.
|
||||
* This wrap is newlib-only. On picolibc, vsnprintf is implemented as
|
||||
* vfprintf into a string-output FILE, so vfprintf is unconditionally
|
||||
* linked in by any caller of snprintf/vsnprintf and the wrap can never
|
||||
* elide it — it just adds shim cost. Codegen forces USE_FULL_PRINTF
|
||||
* on picolibc builds (IDF 6.0+ on all variants) so this file compiles
|
||||
* to nothing there; the #error below catches a desynchronised gate.
|
||||
*
|
||||
* Saves ~11 KB of flash.
|
||||
* Saves ~11 KB of flash on newlib.
|
||||
*
|
||||
* To disable these wraps, set enable_full_printf: true in the esp32
|
||||
* advanced config section.
|
||||
* To disable this wrap on newlib, set enable_full_printf: true in the
|
||||
* esp32 advanced config section.
|
||||
*/
|
||||
|
||||
#if defined(USE_ESP_IDF) && !defined(USE_FULL_PRINTF)
|
||||
|
||||
#ifdef __PICOLIBC__
|
||||
#error "printf wrap is net-negative on picolibc; codegen should set USE_FULL_PRINTF"
|
||||
#endif
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
|
||||
@@ -34,6 +40,9 @@
|
||||
|
||||
namespace esphome::esp32 {}
|
||||
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
extern "C" {
|
||||
|
||||
static constexpr size_t PRINTF_BUFFER_SIZE = 512;
|
||||
|
||||
// These stubs are essentially dead code at runtime — ESPHome replaces the
|
||||
@@ -55,14 +64,16 @@ static int write_printf_buffer(FILE *stream, char *buf, int len) {
|
||||
return len;
|
||||
}
|
||||
|
||||
// NOLINTBEGIN(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
extern "C" {
|
||||
|
||||
int __wrap_vprintf(const char *fmt, va_list ap) {
|
||||
char buf[PRINTF_BUFFER_SIZE];
|
||||
return write_printf_buffer(stdout, buf, vsnprintf(buf, sizeof(buf), fmt, ap));
|
||||
}
|
||||
|
||||
int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) {
|
||||
char buf[PRINTF_BUFFER_SIZE];
|
||||
return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap));
|
||||
}
|
||||
|
||||
int __wrap_printf(const char *fmt, ...) {
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
@@ -71,11 +82,6 @@ int __wrap_printf(const char *fmt, ...) {
|
||||
return len;
|
||||
}
|
||||
|
||||
int __wrap_vfprintf(FILE *stream, const char *fmt, va_list ap) {
|
||||
char buf[PRINTF_BUFFER_SIZE];
|
||||
return write_printf_buffer(stream, buf, vsnprintf(buf, sizeof(buf), fmt, ap));
|
||||
}
|
||||
|
||||
int __wrap_fprintf(FILE *stream, const char *fmt, ...) {
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
|
||||
@@ -246,9 +246,10 @@ async def to_code(config):
|
||||
idf_ver = esp32.idf_version()
|
||||
os.environ["ESP_IDF_VERSION"] = f"{idf_ver.major}.{idf_ver.minor}"
|
||||
if idf_ver >= cv.Version(5, 5, 0):
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.4.0")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.4")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.1")
|
||||
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1")
|
||||
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2")
|
||||
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
|
||||
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.6")
|
||||
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")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
@@ -94,6 +95,18 @@ def set_core_data(config):
|
||||
|
||||
|
||||
def get_download_types(storage_json):
|
||||
"""Binary-download entries for a built ESP8266 firmware.
|
||||
|
||||
Used by:
|
||||
- esphome.dashboard (legacy "Download .bin" button)
|
||||
- device-builder (esphome/device-builder) — same dispatch via
|
||||
``importlib.import_module(f"esphome.components.{platform}")``
|
||||
then ``module.get_download_types(storage)``. The contract is
|
||||
"returns ``list[dict]`` with at least ``title`` /
|
||||
``description`` / ``file`` / ``download`` keys"; please keep
|
||||
the shape stable so the new dashboard's download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"title": "Standard format",
|
||||
@@ -407,3 +420,117 @@ def copy_files() -> None:
|
||||
remove_float_scanf_file,
|
||||
CORE.relative_build_path("remove_float_scanf.py"),
|
||||
)
|
||||
|
||||
|
||||
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
|
||||
ESP8266_EXCEPTION_CODES = {
|
||||
0: "Illegal instruction (Is the flash damaged?)",
|
||||
1: "SYSCALL instruction",
|
||||
2: "InstructionFetchError: Processor internal physical address or data error during "
|
||||
"instruction fetch",
|
||||
3: "LoadStoreError: Processor internal physical address or data error during load or store",
|
||||
4: "Level1Interrupt: Level-1 interrupt as indicated by set level-1 bits in the INTERRUPT "
|
||||
"register",
|
||||
5: "Alloca: MOVSP instruction, if caller's registers are not in the register file",
|
||||
6: "Integer Divide By Zero",
|
||||
7: "reserved",
|
||||
8: "Privileged: Attempt to execute a privileged operation when CRING ? 0",
|
||||
9: "LoadStoreAlignmentCause: Load or store to an unaligned address",
|
||||
10: "reserved",
|
||||
11: "reserved",
|
||||
12: "InstrPIFDataError: PIF data error during instruction fetch",
|
||||
13: "LoadStorePIFDataError: Synchronous PIF data error during LoadStore access",
|
||||
14: "InstrPIFAddrError: PIF address error during instruction fetch",
|
||||
15: "LoadStorePIFAddrError: Synchronous PIF address error during LoadStore access",
|
||||
16: "InstTLBMiss: Error during Instruction TLB refill",
|
||||
17: "InstTLBMultiHit: Multiple instruction TLB entries matched",
|
||||
18: "InstFetchPrivilege: An instruction fetch referenced a virtual address at a ring level "
|
||||
"less than CRING",
|
||||
19: "reserved",
|
||||
20: "InstFetchProhibited: An instruction fetch referenced a page mapped with an attribute "
|
||||
"that does not permit instruction fetch",
|
||||
21: "reserved",
|
||||
22: "reserved",
|
||||
23: "reserved",
|
||||
24: "LoadStoreTLBMiss: Error during TLB refill for a load or store",
|
||||
25: "LoadStoreTLBMultiHit: Multiple TLB entries matched for a load or store",
|
||||
26: "LoadStorePrivilege: A load or store referenced a virtual address at a ring level less "
|
||||
"than ",
|
||||
27: "reserved",
|
||||
28: "Access to invalid address: LOAD (wild pointer?)",
|
||||
29: "Access to invalid address: STORE (wild pointer?)",
|
||||
}
|
||||
|
||||
|
||||
def _decode_pc(config, addr):
|
||||
from esphome import platformio_api
|
||||
|
||||
idedata = platformio_api.get_idedata(config)
|
||||
if not idedata.addr2line_path or not idedata.firmware_elf_path:
|
||||
_LOGGER.debug("decode_pc no addr2line")
|
||||
return
|
||||
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
|
||||
try:
|
||||
translation = subprocess.check_output(command, close_fds=False).decode().strip()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
|
||||
return
|
||||
|
||||
if "?? ??:0" in translation:
|
||||
# Nothing useful
|
||||
return
|
||||
translation = translation.replace(" at ??:?", "").replace(":?", "")
|
||||
_LOGGER.warning("Decoded %s", translation)
|
||||
|
||||
|
||||
def _parse_register(config, regex, line):
|
||||
match = regex.match(line)
|
||||
if match is not None:
|
||||
_decode_pc(config, match.group(1))
|
||||
|
||||
|
||||
STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):")
|
||||
STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})")
|
||||
STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})")
|
||||
STACKTRACE_BAD_ALLOC_RE = re.compile(
|
||||
r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$"
|
||||
)
|
||||
STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}")
|
||||
|
||||
|
||||
def process_stacktrace(config, line, backtrace_state):
|
||||
line = line.strip()
|
||||
# ESP8266 Exception type
|
||||
match = re.match(STACKTRACE_ESP8266_EXCEPTION_TYPE_RE, line)
|
||||
if match is not None:
|
||||
code = int(match.group(1))
|
||||
_LOGGER.warning(
|
||||
"Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown")
|
||||
)
|
||||
|
||||
# ESP8266 PC/EXCVADDR
|
||||
_parse_register(config, STACKTRACE_ESP8266_PC_RE, line)
|
||||
_parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line)
|
||||
|
||||
# bad alloc
|
||||
match = re.match(STACKTRACE_BAD_ALLOC_RE, line)
|
||||
if match is not None:
|
||||
_LOGGER.warning(
|
||||
"Memory allocation of %s bytes failed at %s", match.group(2), match.group(1)
|
||||
)
|
||||
_decode_pc(config, match.group(1))
|
||||
|
||||
# ESP8266 multi-line backtrace
|
||||
if ">>>stack>>>" in line:
|
||||
# Start of backtrace
|
||||
backtrace_state = True
|
||||
_LOGGER.warning("Found stack trace! Trying to decode it")
|
||||
elif "<<<stack<<<" in line:
|
||||
# End of backtrace
|
||||
backtrace_state = False
|
||||
|
||||
if backtrace_state:
|
||||
for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line):
|
||||
_decode_pc(config, addr.group())
|
||||
|
||||
return backtrace_state
|
||||
|
||||
@@ -3,98 +3,12 @@
|
||||
#include "core.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/time_64.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "preferences.h"
|
||||
#include <Arduino.h>
|
||||
#include <core_esp8266_features.h>
|
||||
|
||||
extern "C" {
|
||||
#include <user_interface.h>
|
||||
}
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// yield(), micros(), millis_64() inlined in hal.h.
|
||||
// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit
|
||||
// multiplies on the LX106). Tracks a running ms counter from 32-bit
|
||||
// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis
|
||||
// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g.
|
||||
// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static
|
||||
// state against ISR re-entry; the critical section is bounded (≤10 while-loop
|
||||
// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on
|
||||
// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level
|
||||
// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis().
|
||||
//
|
||||
// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles
|
||||
// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a
|
||||
// >71 min block would trip the watchdog long before it could matter here.
|
||||
static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000;
|
||||
static constexpr uint32_t US_PER_MS = 1000;
|
||||
|
||||
uint32_t IRAM_ATTR HOT millis() {
|
||||
// Struct packs the three statics so the compiler loads one base address
|
||||
// instead of three separate literal pool entries (saves ~8 bytes IRAM).
|
||||
static struct {
|
||||
uint32_t cache;
|
||||
uint32_t remainder;
|
||||
uint32_t last_us;
|
||||
} state = {0, 0, 0};
|
||||
uint32_t ps = xt_rsil(15);
|
||||
uint32_t now_us = system_get_time();
|
||||
uint32_t delta = now_us - state.last_us;
|
||||
state.last_us = now_us;
|
||||
state.remainder += delta;
|
||||
if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) {
|
||||
// Rare path: large gap (WiFi scan, boot, long block). Constant-time
|
||||
// conversion keeps the critical section bounded.
|
||||
uint32_t ms = state.remainder / US_PER_MS;
|
||||
state.cache += ms;
|
||||
// Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a
|
||||
// second __umodsi3 call on the LX106 (no hardware divide).
|
||||
state.remainder -= ms * US_PER_MS;
|
||||
} else {
|
||||
// Common path: small gap. At most ~10 iterations since remainder was
|
||||
// < threshold (10 ms) on entry and delta adds at most one more threshold
|
||||
// before exiting this branch.
|
||||
while (state.remainder >= US_PER_MS) {
|
||||
state.cache++;
|
||||
state.remainder -= US_PER_MS;
|
||||
}
|
||||
}
|
||||
uint32_t result = state.cache;
|
||||
xt_wsr_ps(ps);
|
||||
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.
|
||||
void HOT delay(uint32_t ms) {
|
||||
if (ms == 0) {
|
||||
optimistic_yield(1000);
|
||||
return;
|
||||
}
|
||||
uint32_t start = millis();
|
||||
while (millis() - start < ms) {
|
||||
optimistic_yield(1000);
|
||||
}
|
||||
}
|
||||
// delayMicroseconds(), arch_feed_wdt(), and progmem_read_*() are inlined in hal/hal_esp8266.h.
|
||||
void arch_restart() {
|
||||
system_restart();
|
||||
// restart() doesn't always end execution
|
||||
while (true) { // NOLINT(clang-diagnostic-unreachable-code)
|
||||
yield();
|
||||
}
|
||||
}
|
||||
void arch_init() {}
|
||||
uint32_t IRAM_ATTR HOT arch_get_cpu_cycle_count() { return esp_get_cycle_count(); }
|
||||
uint32_t arch_get_cpu_freq_hz() { return F_CPU; }
|
||||
// HAL functions live in hal.cpp. This file keeps only the ESP8266-specific
|
||||
// firmware bootstrap (Tasmota OTA magic bytes, optional GPIO pre-init).
|
||||
|
||||
void force_link_symbols() {
|
||||
// Tasmota uses magic bytes in the binary to check if an OTA firmware is compatible
|
||||
@@ -131,12 +45,4 @@ extern "C" void resetPins() { // NOLINT
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator.
|
||||
// Requires -Wl,--wrap=millis in build flags (added by __init__.py).
|
||||
// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); }
|
||||
// Note: Arduino's init() registers a 60-second overflow timer for micros64().
|
||||
// We leave it running — wrapping init() as a no-op would break micros64()'s
|
||||
// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s).
|
||||
|
||||
#endif // USE_ESP8266
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
#ifdef USE_ESP8266
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <Arduino.h>
|
||||
#include <core_esp8266_features.h>
|
||||
|
||||
extern "C" {
|
||||
#include <user_interface.h>
|
||||
}
|
||||
|
||||
// Empty esp8266 namespace block to satisfy ci-custom's lint_namespace check.
|
||||
// HAL functions live in namespace esphome (root) — they are not part of the
|
||||
// esp8266 component's API.
|
||||
namespace esphome::esp8266 {} // namespace esphome::esp8266
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// yield(), micros(), millis_64(), delayMicroseconds(), arch_feed_wdt(),
|
||||
// progmem_read_*() are inlined in components/esp8266/hal.h.
|
||||
//
|
||||
// Fast accumulator replacement for Arduino's millis() (~3.3 μs via 4× 64-bit
|
||||
// multiplies on the LX106). Tracks a running ms counter from 32-bit
|
||||
// system_get_time() deltas using pure 32-bit ops. Installed as __wrap_millis
|
||||
// (via -Wl,--wrap=millis) so Arduino libs and IRAM_ATTR ISR handlers (e.g.
|
||||
// Wiegand, ZyAura) also get the fast version. xt_rsil(15) guards the static
|
||||
// state against ISR re-entry; the critical section is bounded (≤10 while-loop
|
||||
// iterations, ~100 ns on the common path, or a constant-time /1000 ~2.5 μs on
|
||||
// the rare path — well under WiFi's ~10 μs ISR latency budget). NMIs (level
|
||||
// >15) are not masked, but the ESP8266 SDK's NMI handlers don't call millis().
|
||||
//
|
||||
// system_get_time() wraps every ~71.6 min; unsigned (now_us - last_us) handles
|
||||
// one wrap. The main loop calls millis() at 60+ Hz, so delta stays tiny — a
|
||||
// >71 min block would trip the watchdog long before it could matter here.
|
||||
static constexpr uint32_t MILLIS_RARE_PATH_THRESHOLD_US = 10000;
|
||||
static constexpr uint32_t US_PER_MS = 1000;
|
||||
|
||||
uint32_t IRAM_ATTR HOT millis() {
|
||||
// Struct packs the three statics so the compiler loads one base address
|
||||
// instead of three separate literal pool entries (saves ~8 bytes IRAM).
|
||||
static struct {
|
||||
uint32_t cache;
|
||||
uint32_t remainder;
|
||||
uint32_t last_us;
|
||||
} state = {0, 0, 0};
|
||||
uint32_t ps = xt_rsil(15);
|
||||
uint32_t now_us = system_get_time();
|
||||
uint32_t delta = now_us - state.last_us;
|
||||
state.last_us = now_us;
|
||||
state.remainder += delta;
|
||||
if (state.remainder >= MILLIS_RARE_PATH_THRESHOLD_US) {
|
||||
// Rare path: large gap (WiFi scan, boot, long block). Constant-time
|
||||
// conversion keeps the critical section bounded.
|
||||
uint32_t ms = state.remainder / US_PER_MS;
|
||||
state.cache += ms;
|
||||
// Reuse ms instead of `remainder %= US_PER_MS` — `%` would compile to a
|
||||
// second __umodsi3 call on the LX106 (no hardware divide).
|
||||
state.remainder -= ms * US_PER_MS;
|
||||
} else {
|
||||
// Common path: small gap. At most ~10 iterations since remainder was
|
||||
// < threshold (10 ms) on entry and delta adds at most one more threshold
|
||||
// before exiting this branch.
|
||||
while (state.remainder >= US_PER_MS) {
|
||||
state.cache++;
|
||||
state.remainder -= US_PER_MS;
|
||||
}
|
||||
}
|
||||
uint32_t result = state.cache;
|
||||
xt_wsr_ps(ps);
|
||||
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.
|
||||
void HOT delay(uint32_t ms) {
|
||||
if (ms == 0) {
|
||||
optimistic_yield(1000);
|
||||
return;
|
||||
}
|
||||
uint32_t start = millis();
|
||||
while (millis() - start < ms) {
|
||||
optimistic_yield(1000);
|
||||
}
|
||||
}
|
||||
|
||||
void arch_restart() {
|
||||
system_restart();
|
||||
// restart() doesn't always end execution
|
||||
while (true) { // NOLINT(clang-diagnostic-unreachable-code)
|
||||
yield();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
// Linker wrap: redirect all ::millis() calls (Arduino libs, ISRs) to our accumulator.
|
||||
// Requires -Wl,--wrap=millis in build flags (added by __init__.py).
|
||||
// NOLINTNEXTLINE(bugprone-reserved-identifier,cert-dcl37-c,cert-dcl51-cpp,readability-identifier-naming)
|
||||
extern "C" uint32_t IRAM_ATTR __wrap_millis() { return esphome::millis(); }
|
||||
// Note: Arduino's init() registers a 60-second overflow timer for micros64().
|
||||
// We leave it running — wrapping init() as a no-op would break micros64()'s
|
||||
// overflow tracking, and the timer's cost is negligible (~3 μs per 60 s).
|
||||
|
||||
#endif // USE_ESP8266
|
||||
@@ -3,6 +3,7 @@
|
||||
#ifdef USE_ESP8266
|
||||
|
||||
#include <c_types.h>
|
||||
#include <core_esp8266_features.h>
|
||||
#include <cstdint>
|
||||
#include <pgmspace.h>
|
||||
|
||||
@@ -24,6 +25,8 @@ extern "C" unsigned long millis(void);
|
||||
// NOLINTNEXTLINE(readability-redundant-declaration)
|
||||
extern "C" void system_soft_wdt_feed(void);
|
||||
|
||||
namespace esphome::esp8266 {}
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// Forward decl from helpers.h so this header stays cheap.
|
||||
@@ -59,8 +62,11 @@ __attribute__((always_inline)) inline uint16_t progmem_read_uint16(const uint16_
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
|
||||
__attribute__((always_inline)) inline void arch_feed_wdt() { system_soft_wdt_feed(); }
|
||||
|
||||
uint32_t arch_get_cpu_cycle_count();
|
||||
__attribute__((always_inline)) inline void arch_init() {}
|
||||
// esp_get_cycle_count() declared in <core_esp8266_features.h>; F_CPU is a
|
||||
// compiler-driven macro from the ESP8266 Arduino board defs (-DF_CPU=...).
|
||||
__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return esp_get_cycle_count(); }
|
||||
__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return F_CPU; }
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -16,11 +16,13 @@ from esphome.const import (
|
||||
CONF_SAFE_MODE,
|
||||
CONF_VERSION,
|
||||
)
|
||||
from esphome.core import coroutine_with_priority
|
||||
from esphome.core import CORE, coroutine_with_priority
|
||||
from esphome.coroutine import CoroPriority
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CONF_ALLOW_PARTITION_ACCESS = "allow_partition_access"
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -75,6 +77,10 @@ def ota_esphome_final_validate(config):
|
||||
merged_ota_esphome_configs_by_port[conf_port] = merge_config(
|
||||
merged_ota_esphome_configs_by_port[conf_port], ota_conf
|
||||
)
|
||||
if ota_conf.get(CONF_ALLOW_PARTITION_ACCESS) and not CORE.is_esp32:
|
||||
raise cv.Invalid(
|
||||
f"{CONF_ALLOW_PARTITION_ACCESS} is only supported on the esp32"
|
||||
)
|
||||
else:
|
||||
new_ota_conf.append(ota_conf)
|
||||
|
||||
@@ -125,6 +131,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
ln882x=8820,
|
||||
rtl87xx=8892,
|
||||
): cv.port,
|
||||
cv.Optional(CONF_ALLOW_PARTITION_ACCESS, default=False): cv.boolean,
|
||||
cv.Optional(CONF_PASSWORD): cv.string,
|
||||
cv.Optional(CONF_NUM_ATTEMPTS): cv.invalid(
|
||||
f"'{CONF_SAFE_MODE}' (and its related configuration variables) has moved from 'ota' to its own component. See https://esphome.io/components/safe_mode"
|
||||
@@ -159,6 +166,10 @@ async def to_code(config: ConfigType) -> None:
|
||||
if config[CONF_PASSWORD]:
|
||||
cg.add(var.set_auth_password(config[CONF_PASSWORD]))
|
||||
cg.add_define("USE_OTA_VERSION", config[CONF_VERSION])
|
||||
|
||||
if config.get(CONF_ALLOW_PARTITION_ACCESS):
|
||||
cg.add_define("USE_OTA_PARTITIONS")
|
||||
|
||||
# Build flag so lwip_fast_select.c (a .c file that can't include defines.h) sees it.
|
||||
cg.add_build_flag("-DUSE_OTA_PLATFORM_ESPHOME")
|
||||
|
||||
|
||||
@@ -87,6 +87,10 @@ void ESPHomeOTAComponent::setup() {
|
||||
// no wakes fire and loop() falls back to the self-disable safety net.
|
||||
esphome_fast_select_set_ota_listener_sock(esphome_lwip_get_sock(this->server_->get_fd()));
|
||||
#endif
|
||||
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
ota::get_running_app_position(this->running_app_offset_, this->running_app_size_);
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::dump_config() {
|
||||
@@ -100,6 +104,29 @@ void ESPHomeOTAComponent::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, " Password configured");
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Partition access allowed\n"
|
||||
" Running app:\n"
|
||||
" Partition address: 0x%X\n"
|
||||
" Used size: %zu bytes (0x%X)",
|
||||
this->running_app_offset_, this->running_app_size_, this->running_app_size_);
|
||||
|
||||
#ifdef USE_ESP32
|
||||
ESP_LOGCONFIG(TAG,
|
||||
" Partition table:\n"
|
||||
" %-12s %-4s %-8s %-10s %-10s",
|
||||
"Name", "Type", "Subtype", "Address", "Size");
|
||||
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL);
|
||||
while (it != NULL) {
|
||||
const esp_partition_t *partition = esp_partition_get(it);
|
||||
ESP_LOGCONFIG(TAG, " %-12s 0x%-2X 0x%-6X 0x%-8" PRIX32 " 0x%-8" PRIX32, partition->label, partition->type,
|
||||
partition->subtype, partition->address, partition->size);
|
||||
it = esp_partition_next(it);
|
||||
}
|
||||
esp_partition_iterator_release(it);
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
|
||||
void ESPHomeOTAComponent::loop() {
|
||||
@@ -114,8 +141,11 @@ void ESPHomeOTAComponent::loop() {
|
||||
this->handle_handshake_();
|
||||
}
|
||||
|
||||
static const uint8_t FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
static const uint8_t FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_SHA256_AUTH = 0x02;
|
||||
static constexpr uint8_t CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL = 0x04;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_COMPRESSION = 0x01;
|
||||
static constexpr uint8_t SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS = 0x02;
|
||||
|
||||
void ESPHomeOTAComponent::handle_handshake_() {
|
||||
/// Handle the OTA handshake and authentication.
|
||||
@@ -201,16 +231,33 @@ void ESPHomeOTAComponent::handle_handshake_() {
|
||||
this->ota_features_ = this->handshake_buf_[0];
|
||||
ESP_LOGV(TAG, "Features: 0x%02X", this->ota_features_);
|
||||
this->transition_ota_state_(OTAState::FEATURE_ACK);
|
||||
this->handshake_buf_[0] =
|
||||
((this->ota_features_ & FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression())
|
||||
? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION
|
||||
: ota::OTA_RESPONSE_HEADER_OK;
|
||||
|
||||
const bool supports_compression =
|
||||
(this->ota_features_ & CLIENT_FEATURE_SUPPORTS_COMPRESSION) != 0 && this->backend_->supports_compression();
|
||||
|
||||
// Compose the feature-ack response. When the client negotiates the extended protocol we emit
|
||||
// a 2-byte response (marker + server feature flags); otherwise we emit the single-byte
|
||||
// legacy response.
|
||||
this->extended_proto_ = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_EXTENDED_PROTOCOL) != 0;
|
||||
if (this->extended_proto_) {
|
||||
static_assert(HANDSHAKE_BUF_SIZE >= 2, "handshake_buf_ must hold the 2-byte extended-protocol feature ack");
|
||||
this->handshake_buf_[0] = ota::OTA_RESPONSE_FEATURE_FLAGS;
|
||||
this->handshake_buf_[1] = (supports_compression ? SERVER_FEATURE_SUPPORTS_COMPRESSION : 0);
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
this->handshake_buf_[1] |= SERVER_FEATURE_SUPPORTS_PARTITION_ACCESS;
|
||||
#endif
|
||||
} else {
|
||||
this->handshake_buf_[0] =
|
||||
supports_compression ? ota::OTA_RESPONSE_SUPPORTS_COMPRESSION : ota::OTA_RESPONSE_HEADER_OK;
|
||||
}
|
||||
[[fallthrough]];
|
||||
}
|
||||
|
||||
case OTAState::FEATURE_ACK: {
|
||||
// Acknowledge header - 1 byte
|
||||
if (!this->try_write_(1, LOG_STR("ack feature"))) {
|
||||
static constexpr size_t STANDARD_PROTO_ACK_SIZE = 1;
|
||||
static constexpr size_t EXTENDED_PROTO_ACK_SIZE = 2;
|
||||
const size_t ack_size = this->extended_proto_ ? EXTENDED_PROTO_ACK_SIZE : STANDARD_PROTO_ACK_SIZE;
|
||||
if (!this->try_write_(ack_size, LOG_STR("ack feature"))) {
|
||||
return;
|
||||
}
|
||||
#ifdef USE_OTA_PASSWORD
|
||||
@@ -296,6 +343,7 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
uint8_t buf[OTA_BUFFER_SIZE];
|
||||
char *sbuf = reinterpret_cast<char *>(buf);
|
||||
size_t ota_size;
|
||||
ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP;
|
||||
#if USE_OTA_VERSION == 2
|
||||
size_t size_acknowledged = 0;
|
||||
#endif
|
||||
@@ -311,6 +359,16 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
// Acknowledge auth OK - 1 byte
|
||||
this->write_byte_(ota::OTA_RESPONSE_AUTH_OK);
|
||||
|
||||
if (this->extended_proto_) {
|
||||
// Read ota type, 1 byte
|
||||
if (!this->readall_(buf, 1)) {
|
||||
this->log_read_error_(LOG_STR("OTA type"));
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
ota_type = static_cast<ota::OTAType>(buf[0]);
|
||||
}
|
||||
ESP_LOGV(TAG, "OTA type is 0x%02x", ota_type);
|
||||
|
||||
// Read size, 4 bytes MSB first
|
||||
if (!this->readall_(buf, 4)) {
|
||||
this->log_read_error_(LOG_STR("size"));
|
||||
@@ -320,6 +378,13 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
(static_cast<size_t>(buf[2]) << 8) | buf[3];
|
||||
ESP_LOGV(TAG, "Size is %u bytes", ota_size);
|
||||
|
||||
#ifndef USE_OTA_PARTITIONS
|
||||
if (ota_type != ota::OTA_TYPE_UPDATE_APP) {
|
||||
error_code = ota::OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
}
|
||||
#endif
|
||||
|
||||
// Now that we've passed authentication and are actually
|
||||
// starting the update, set the warning status and notify
|
||||
// listeners. This ensures that port scanners do not
|
||||
@@ -330,8 +395,8 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
this->notify_state_(ota::OTA_STARTED, 0.0f, 0);
|
||||
#endif
|
||||
|
||||
// This will block for a few seconds as it locks flash
|
||||
error_code = this->backend_->begin(ota_size);
|
||||
// begin() may block for a few seconds while it locks flash.
|
||||
error_code = this->backend_->begin(ota_size, ota_type);
|
||||
if (error_code != ota::OTA_RESPONSE_OK)
|
||||
goto error; // NOLINT(cppcoreguidelines-avoid-goto)
|
||||
update_started = true;
|
||||
@@ -433,6 +498,13 @@ void ESPHomeOTAComponent::handle_data_() {
|
||||
this->notify_state_(ota::OTA_COMPLETED, 100.0f, 0);
|
||||
#endif
|
||||
delay(100); // NOLINT
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
if (ota_type == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) {
|
||||
// Skip on_safe_shutdown: nvs_flash_deinit() has already invalidated open NVS handles, so
|
||||
// preferences flush would emit ESP_ERR_NVS_INVALID_HANDLE for every entry. Reboot directly.
|
||||
App.reboot();
|
||||
}
|
||||
#endif
|
||||
App.safe_reboot();
|
||||
|
||||
error:
|
||||
@@ -616,7 +688,7 @@ void ESPHomeOTAComponent::yield_and_feed_watchdog_() {
|
||||
void ESPHomeOTAComponent::log_auth_warning_(const LogString *msg) { ESP_LOGW(TAG, "Auth: %s", LOG_STR_ARG(msg)); }
|
||||
|
||||
bool ESPHomeOTAComponent::select_auth_type_() {
|
||||
bool client_supports_sha256 = (this->ota_features_ & FEATURE_SUPPORTS_SHA256_AUTH) != 0;
|
||||
bool client_supports_sha256 = (this->ota_features_ & CLIENT_FEATURE_SUPPORTS_SHA256_AUTH) != 0;
|
||||
|
||||
// Require SHA256
|
||||
if (!client_supports_sha256) {
|
||||
|
||||
@@ -97,8 +97,13 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
ota::OTABackendPtr backend_;
|
||||
|
||||
uint32_t client_connect_time_{0};
|
||||
static constexpr size_t HANDSHAKE_BUF_SIZE = 5;
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
uint32_t running_app_offset_{0};
|
||||
size_t running_app_size_{0};
|
||||
#endif
|
||||
uint16_t port_;
|
||||
uint8_t handshake_buf_[5];
|
||||
uint8_t handshake_buf_[HANDSHAKE_BUF_SIZE];
|
||||
OTAState ota_state_{OTAState::IDLE};
|
||||
uint8_t handshake_buf_pos_{0};
|
||||
uint8_t ota_features_{0};
|
||||
@@ -106,6 +111,7 @@ class ESPHomeOTAComponent final : public ota::OTAComponent {
|
||||
uint8_t auth_buf_pos_{0};
|
||||
uint8_t auth_type_{0}; // Store auth type to know which hasher to use
|
||||
#endif // USE_OTA_PASSWORD
|
||||
bool extended_proto_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -358,21 +358,29 @@ async def fan_turn_on_to_code(config, action_id, template_arg, args):
|
||||
(CONF_DIRECTION, "set_direction", FanDirection),
|
||||
)
|
||||
|
||||
# Normalize trigger args to `const std::remove_cvref_t<T> &` so the
|
||||
# apply lambda and any inner field lambdas (generated below via
|
||||
# `process_lambda`) share one parameter spelling that's well-formed for
|
||||
# any T (value, ref, or const-ref). Matches TurnOnAction::ApplyFn.
|
||||
normalized_args = [
|
||||
(cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n)
|
||||
for t, n in args
|
||||
]
|
||||
|
||||
fwd_args = ", ".join(name for _, name in args)
|
||||
body_lines: list[str] = []
|
||||
for conf_key, setter, type_ in FIELDS:
|
||||
if (value := config.get(conf_key)) is None:
|
||||
continue
|
||||
if isinstance(value, Lambda):
|
||||
inner = await cg.process_lambda(value, args, return_type=type_)
|
||||
inner = await cg.process_lambda(value, normalized_args, return_type=type_)
|
||||
body_lines.append(f"call.{setter}(({inner})({fwd_args}));")
|
||||
else:
|
||||
body_lines.append(f"call.{setter}({cg.safe_exp(value)});")
|
||||
|
||||
# Match TurnOnAction::ApplyFn signature: const Ts &... for trigger args.
|
||||
apply_args = [
|
||||
(FanCall.operator("ref"), "call"),
|
||||
*((t.operator("const").operator("ref"), n) for t, n in args),
|
||||
*normalized_args,
|
||||
]
|
||||
apply_lambda = LambdaExpression(
|
||||
["\n".join(body_lines)],
|
||||
|
||||
@@ -12,9 +12,16 @@ namespace fan {
|
||||
// plus one parent pointer, regardless of how many fields the user set.
|
||||
// Trigger args are forwarded to the apply function so user lambdas
|
||||
// (e.g. `speed: !lambda "return x;"`) keep working.
|
||||
//
|
||||
// Trigger args are normalized to `const std::remove_cvref_t<Ts> &...` so
|
||||
// the codegen can emit a matching parameter list for both the apply lambda
|
||||
// and any inner field lambdas without producing invalid C++ source text
|
||||
// (e.g. `const T & &` if Ts already carries a reference, or `const const
|
||||
// T &` if Ts already carries a const). This keeps trigger args no-copy
|
||||
// regardless of whether the trigger supplies `T`, `T &`, or `const T &`.
|
||||
template<typename... Ts> class TurnOnAction : public Action<Ts...> {
|
||||
public:
|
||||
using ApplyFn = void (*)(FanCall &, const Ts &...);
|
||||
using ApplyFn = void (*)(FanCall &, const std::remove_cvref_t<Ts> &...);
|
||||
TurnOnAction(Fan *state, ApplyFn apply) : state_(state), apply_(apply) {}
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
|
||||
@@ -1,74 +1,16 @@
|
||||
#ifdef USE_HOST
|
||||
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "preferences.h"
|
||||
|
||||
#include <csignal>
|
||||
#include <sched.h>
|
||||
#include <time.h>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace {
|
||||
volatile sig_atomic_t s_signal_received = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
void signal_handler(int signal) { s_signal_received = signal; }
|
||||
} // namespace
|
||||
|
||||
namespace esphome {
|
||||
|
||||
void HOT yield() { ::sched_yield(); }
|
||||
uint32_t IRAM_ATTR HOT millis() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
return static_cast<uint32_t>(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000);
|
||||
}
|
||||
uint64_t millis_64() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
return static_cast<uint64_t>(spec.tv_sec) * 1000ULL + static_cast<uint64_t>(spec.tv_nsec) / 1000000ULL;
|
||||
}
|
||||
void HOT delay(uint32_t ms) {
|
||||
struct timespec ts;
|
||||
ts.tv_sec = ms / 1000;
|
||||
ts.tv_nsec = (ms % 1000) * 1000000;
|
||||
int res;
|
||||
do {
|
||||
res = nanosleep(&ts, &ts);
|
||||
} while (res != 0 && errno == EINTR);
|
||||
}
|
||||
uint32_t IRAM_ATTR HOT micros() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
return static_cast<uint32_t>(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000);
|
||||
}
|
||||
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) {
|
||||
struct timespec ts;
|
||||
ts.tv_sec = us / 1000000U;
|
||||
ts.tv_nsec = (us % 1000000U) * 1000U;
|
||||
int res;
|
||||
do {
|
||||
res = nanosleep(&ts, &ts);
|
||||
} while (res != 0 && errno == EINTR);
|
||||
}
|
||||
void arch_restart() { exit(0); }
|
||||
void arch_init() {
|
||||
// pass
|
||||
}
|
||||
void HOT arch_feed_wdt() {
|
||||
// pass
|
||||
}
|
||||
|
||||
uint32_t arch_get_cpu_cycle_count() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
time_t seconds = spec.tv_sec;
|
||||
uint32_t us = spec.tv_nsec;
|
||||
return ((uint32_t) seconds) * 1000000000U + us;
|
||||
}
|
||||
uint32_t arch_get_cpu_freq_hz() { return 1000000000U; }
|
||||
|
||||
} // namespace esphome
|
||||
// HAL functions live in hal.cpp.
|
||||
|
||||
void setup();
|
||||
void loop();
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#ifdef USE_HOST
|
||||
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <time.h>
|
||||
#include <cerrno>
|
||||
#include <cstdlib>
|
||||
|
||||
// Empty host namespace block to satisfy ci-custom's lint_namespace check.
|
||||
// HAL functions live in namespace esphome (root) — they are not part of the
|
||||
// host component's API.
|
||||
namespace esphome::host {} // namespace esphome::host
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// yield(), arch_init(), arch_feed_wdt(), arch_get_cpu_freq_hz() inlined in
|
||||
// components/host/hal.h.
|
||||
|
||||
uint32_t IRAM_ATTR HOT millis() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
return static_cast<uint32_t>(spec.tv_sec * 1000ULL + spec.tv_nsec / 1000000);
|
||||
}
|
||||
uint64_t millis_64() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
return static_cast<uint64_t>(spec.tv_sec) * 1000ULL + static_cast<uint64_t>(spec.tv_nsec) / 1000000ULL;
|
||||
}
|
||||
void HOT delay(uint32_t ms) {
|
||||
struct timespec ts;
|
||||
ts.tv_sec = ms / 1000;
|
||||
ts.tv_nsec = (ms % 1000) * 1000000;
|
||||
int res;
|
||||
do {
|
||||
res = nanosleep(&ts, &ts);
|
||||
} while (res != 0 && errno == EINTR);
|
||||
}
|
||||
uint32_t IRAM_ATTR HOT micros() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
return static_cast<uint32_t>(spec.tv_sec * 1000000ULL + spec.tv_nsec / 1000);
|
||||
}
|
||||
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) {
|
||||
struct timespec ts;
|
||||
ts.tv_sec = us / 1000000U;
|
||||
ts.tv_nsec = (us % 1000000U) * 1000U;
|
||||
int res;
|
||||
do {
|
||||
res = nanosleep(&ts, &ts);
|
||||
} while (res != 0 && errno == EINTR);
|
||||
}
|
||||
void arch_restart() { exit(0); }
|
||||
|
||||
uint32_t arch_get_cpu_cycle_count() {
|
||||
struct timespec spec;
|
||||
clock_gettime(CLOCK_MONOTONIC, &spec);
|
||||
time_t seconds = spec.tv_sec;
|
||||
uint32_t ns = static_cast<uint32_t>(spec.tv_nsec);
|
||||
return static_cast<uint32_t>(seconds) * 1000000000U + ns;
|
||||
}
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_HOST
|
||||
@@ -3,26 +3,32 @@
|
||||
#ifdef USE_HOST
|
||||
|
||||
#include <cstdint>
|
||||
#include <sched.h>
|
||||
|
||||
#define IRAM_ATTR
|
||||
#define PROGMEM
|
||||
|
||||
namespace esphome::host {}
|
||||
|
||||
namespace esphome {
|
||||
|
||||
/// Returns true when executing inside an interrupt handler.
|
||||
/// Host has no ISR concept.
|
||||
__attribute__((always_inline)) inline bool in_isr_context() { return false; }
|
||||
|
||||
void yield();
|
||||
__attribute__((always_inline)) inline void yield() { ::sched_yield(); }
|
||||
|
||||
void delay(uint32_t ms);
|
||||
uint32_t micros();
|
||||
uint32_t millis();
|
||||
uint64_t millis_64();
|
||||
|
||||
void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming)
|
||||
void arch_feed_wdt();
|
||||
uint32_t arch_get_cpu_cycle_count();
|
||||
|
||||
__attribute__((always_inline)) inline void arch_init() {}
|
||||
__attribute__((always_inline)) inline void arch_feed_wdt() {}
|
||||
__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return 1000000000U; }
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_HOST
|
||||
@@ -70,12 +70,6 @@ std::shared_ptr<HttpContainer> HttpRequestArduino::perform(const std::string &ur
|
||||
stream_ptr = std::make_unique<WiFiClient>();
|
||||
#endif // USE_HTTP_REQUEST_ESP8266_HTTPS
|
||||
|
||||
#if USE_ARDUINO_VERSION_CODE >= VERSION_CODE(3, 1, 0) // && USE_ARDUINO_VERSION_CODE < VERSION_CODE(?, ?, ?)
|
||||
if (!secure) {
|
||||
ESP_LOGW(TAG, "Using HTTP on Arduino version >= 3.1 is **very** slow. Consider setting framework version to 3.0.2 "
|
||||
"in your YAML, or use HTTPS");
|
||||
}
|
||||
#endif // USE_ARDUINO_VERSION_CODE
|
||||
bool status = container->client_.begin(*stream_ptr, url.c_str());
|
||||
|
||||
#elif defined(USE_RP2040)
|
||||
|
||||
@@ -280,6 +280,9 @@ esp_err_t I2SAudioSpeaker::start_i2s_driver(audio::AudioStreamInfo &audio_stream
|
||||
}
|
||||
#else
|
||||
slot_cfg.slot_bit_width = this->slot_bit_width_;
|
||||
if (this->slot_bit_width_ != I2S_SLOT_BIT_WIDTH_AUTO) {
|
||||
slot_cfg.ws_width = static_cast<uint32_t>(this->slot_bit_width_);
|
||||
}
|
||||
#endif // USE_ESP32_VARIANT_ESP32
|
||||
slot_cfg.slot_mask = slot_mask;
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ from .const import (
|
||||
CONF_UART_PORT,
|
||||
FAMILIES,
|
||||
FAMILY_BK7231N,
|
||||
FAMILY_BK7238,
|
||||
FAMILY_COMPONENT,
|
||||
FAMILY_FRIENDLY,
|
||||
FAMILY_RTL8710B,
|
||||
@@ -56,19 +57,22 @@ CODEOWNERS = ["@kuba2k2"]
|
||||
AUTO_LOAD = ["preferences"]
|
||||
IS_TARGET_PLATFORM = True
|
||||
|
||||
# BK7231N SDK options to disable unused features.
|
||||
# BLE 5.x BK SDK options to disable unused features.
|
||||
# Disabling BLE saves ~21KB RAM and ~200KB Flash because BLE init code is
|
||||
# called unconditionally by the SDK. ESPHome doesn't use BLE on LibreTiny.
|
||||
#
|
||||
# This only works on BK7231N (BLE 5.x). Other BK72XX chips using BLE 4.2
|
||||
# (BK7231T, BK7231Q, BK7251; BK7252 boards use the BK7251 family) have a bug
|
||||
# where the BLE library still links and references undefined symbols when
|
||||
# CFG_SUPPORT_BLE=0.
|
||||
# This only works on BLE 5.x BK chips (BK7231N, BK7238). Other BK72XX chips
|
||||
# using BLE 4.2 (BK7231T, BK7231Q, BK7251; BK7252 boards use the BK7251 family)
|
||||
# have a bug where the BLE library still links and references undefined symbols
|
||||
# when CFG_SUPPORT_BLE=0.
|
||||
#
|
||||
# On BK7238 the SDK also hangs at WiFi STA enable when BLE init runs, so
|
||||
# disabling it is required for reliable boot, not just an optimization.
|
||||
#
|
||||
# Other options like CFG_TX_EVM_TEST, CFG_RX_SENSITIVITY_TEST, CFG_SUPPORT_BKREG,
|
||||
# CFG_SUPPORT_OTA_HTTP, and CFG_USE_SPI_SLAVE were evaluated but provide no # NOLINT
|
||||
# measurable benefit - the linker already strips unreferenced code via -gc-sections.
|
||||
_BK7231N_SYS_CONFIG_OPTIONS = [
|
||||
_BLE5_BK_SYS_CONFIG_OPTIONS = [
|
||||
"CFG_SUPPORT_BLE=0",
|
||||
]
|
||||
|
||||
@@ -152,6 +156,18 @@ def only_on_family(*, supported=None, unsupported=None):
|
||||
|
||||
|
||||
def get_download_types(storage_json: StorageJSON = None):
|
||||
"""Binary-download entries for a built LibreTiny firmware.
|
||||
|
||||
Used by:
|
||||
- esphome.dashboard (legacy "Download .bin" button)
|
||||
- device-builder (esphome/device-builder) — same dispatch via
|
||||
``importlib.import_module(f"esphome.components.{platform}")``
|
||||
then ``module.get_download_types(storage)``. The contract is
|
||||
"returns ``list[dict]`` with at least ``title`` /
|
||||
``description`` / ``file`` / ``download`` keys"; please keep
|
||||
the shape stable so the new dashboard's download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
types = [
|
||||
{
|
||||
"title": "UF2 package (recommended)",
|
||||
@@ -549,9 +565,9 @@ async def component_to_code(config):
|
||||
cg.add_platformio_option("custom_fw_version", __version__)
|
||||
|
||||
# Apply chip-specific SDK options to save RAM/Flash
|
||||
if config[CONF_FAMILY] == FAMILY_BK7231N:
|
||||
if config[CONF_FAMILY] in (FAMILY_BK7231N, FAMILY_BK7238):
|
||||
cg.add_platformio_option(
|
||||
"custom_options.sys_config#h", _BK7231N_SYS_CONFIG_OPTIONS
|
||||
"custom_options.sys_config#h", _BLE5_BK_SYS_CONFIG_OPTIONS
|
||||
)
|
||||
|
||||
# Tune lwIP for ESPHome's actual needs.
|
||||
|
||||
@@ -54,6 +54,14 @@ COMPONENT_LN882X = "ln882x"
|
||||
COMPONENT_RTL87XX = "rtl87xx"
|
||||
# COMPONENTS - end
|
||||
|
||||
# Note for ``generate_components.py`` maintainers: the
|
||||
# ``FAMILY_COMPONENT`` map below is also consumed externally —
|
||||
# device-builder (esphome/device-builder) derives the set of
|
||||
# ``target_platform`` values that should route to the ``libretiny``
|
||||
# component for the dashboard's ``get_download_types`` lookup from
|
||||
# ``FAMILY_COMPONENT.values()``. New chip families added by the
|
||||
# generator are picked up automatically; please don't repurpose
|
||||
# the public ``FAMILY_COMPONENT`` name without coordinating.
|
||||
# FAMILIES - auto-generated! Do not modify this block.
|
||||
FAMILY_BK7231N = "BK7231N"
|
||||
FAMILY_BK7231Q = "BK7231Q"
|
||||
|
||||
@@ -1,55 +1,6 @@
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "core.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "preferences.h"
|
||||
|
||||
#include <FreeRTOS.h>
|
||||
#include <task.h>
|
||||
|
||||
void setup();
|
||||
void loop();
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h.
|
||||
void IRAM_ATTR HOT delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); }
|
||||
|
||||
void arch_init() {
|
||||
libretiny::setup_preferences();
|
||||
lt_wdt_enable(10000L);
|
||||
#ifdef USE_BK72XX
|
||||
// BK72xx SDK creates the main Arduino task at priority 3, which is lower than
|
||||
// all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop
|
||||
// stalls whenever WiFi background processing runs, because the main task
|
||||
// cannot resume until every higher-priority task finishes.
|
||||
//
|
||||
// By contrast, RTL87xx creates the main task at osPriorityRealtime (highest).
|
||||
//
|
||||
// Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the
|
||||
// main loop, but below the TCP/IP thread (7) so packet processing keeps priority.
|
||||
// This is safe because ESPHome yields voluntarily via wakeable_delay() and
|
||||
// the Arduino mainTask yield() after each loop() iteration.
|
||||
static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6;
|
||||
static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES");
|
||||
vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY);
|
||||
#endif
|
||||
#if LT_GPIO_RECOVER
|
||||
lt_gpio_recover();
|
||||
#endif
|
||||
}
|
||||
|
||||
void arch_restart() {
|
||||
lt_reboot();
|
||||
while (1) {
|
||||
}
|
||||
}
|
||||
void HOT arch_feed_wdt() { lt_wdt_feed(); }
|
||||
uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); }
|
||||
uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); }
|
||||
|
||||
} // namespace esphome
|
||||
// HAL functions live in hal.cpp. core.cpp is intentionally empty for
|
||||
// libretiny — there is no extra component bootstrap to keep here.
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#ifdef USE_LIBRETINY
|
||||
|
||||
#include "core.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#include "preferences.h"
|
||||
|
||||
#include <FreeRTOS.h>
|
||||
#include <task.h>
|
||||
|
||||
// Empty libretiny namespace block to satisfy ci-custom's lint_namespace check.
|
||||
// HAL functions live in namespace esphome (root) — they are not part of the
|
||||
// libretiny component's API.
|
||||
namespace esphome::libretiny {} // namespace esphome::libretiny
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(),
|
||||
// arch_feed_wdt(), arch_get_cpu_cycle_count(), arch_get_cpu_freq_hz()
|
||||
// inlined in components/libretiny/hal.h.
|
||||
|
||||
void arch_init() {
|
||||
libretiny::setup_preferences();
|
||||
lt_wdt_enable(10000L);
|
||||
#ifdef USE_BK72XX
|
||||
// BK72xx SDK creates the main Arduino task at priority 3, which is lower than
|
||||
// all WiFi (4-5), LwIP (4), and TCP/IP (7) tasks. This causes ~100ms loop
|
||||
// stalls whenever WiFi background processing runs, because the main task
|
||||
// cannot resume until every higher-priority task finishes.
|
||||
//
|
||||
// By contrast, RTL87xx creates the main task at osPriorityRealtime (highest).
|
||||
//
|
||||
// Raise to priority 6: above WiFi/LwIP tasks (4-5) so they don't preempt the
|
||||
// main loop, but below the TCP/IP thread (7) so packet processing keeps priority.
|
||||
// This is safe because ESPHome yields voluntarily via wakeable_delay() and
|
||||
// the Arduino mainTask yield() after each loop() iteration.
|
||||
static constexpr UBaseType_t MAIN_TASK_PRIORITY = 6;
|
||||
static_assert(MAIN_TASK_PRIORITY < configMAX_PRIORITIES, "MAIN_TASK_PRIORITY must be less than configMAX_PRIORITIES");
|
||||
vTaskPrioritySet(nullptr, MAIN_TASK_PRIORITY);
|
||||
#endif
|
||||
#if LT_GPIO_RECOVER
|
||||
lt_gpio_recover();
|
||||
#endif
|
||||
}
|
||||
|
||||
void arch_restart() {
|
||||
lt_reboot();
|
||||
while (1) {
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_LIBRETINY
|
||||
@@ -51,8 +51,18 @@ extern "C" void yield(void);
|
||||
extern "C" void delay(unsigned long ms);
|
||||
extern "C" unsigned long micros(void);
|
||||
extern "C" unsigned long millis(void);
|
||||
extern "C" void delayMicroseconds(unsigned int us);
|
||||
// NOLINTEND(google-runtime-int,readability-identifier-naming,readability-redundant-declaration)
|
||||
|
||||
// Forward decls from libretiny's <lt_api.h> family for the inline arch_*
|
||||
// wrappers below. Pulling the full header would drag in the rest of the
|
||||
// LibreTiny C API.
|
||||
extern "C" void lt_wdt_feed(void);
|
||||
extern "C" uint32_t lt_cpu_get_cycle_count(void);
|
||||
extern "C" uint32_t lt_cpu_get_freq(void);
|
||||
|
||||
namespace esphome::libretiny {}
|
||||
|
||||
namespace esphome {
|
||||
|
||||
/// Returns true when executing inside an interrupt handler.
|
||||
@@ -88,9 +98,13 @@ __attribute__((always_inline)) inline uint32_t millis() { return static_cast<uin
|
||||
#endif
|
||||
__attribute__((always_inline)) inline uint64_t millis_64() { return Millis64Impl::compute(millis()); }
|
||||
|
||||
void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming)
|
||||
void arch_feed_wdt();
|
||||
uint32_t arch_get_cpu_cycle_count();
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { ::delayMicroseconds(us); }
|
||||
__attribute__((hot, always_inline)) inline void arch_feed_wdt() { lt_wdt_feed(); }
|
||||
__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() { return lt_cpu_get_cycle_count(); }
|
||||
__attribute__((always_inline)) inline uint32_t arch_get_cpu_freq_hz() { return lt_cpu_get_freq(); }
|
||||
|
||||
void arch_init();
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -36,9 +36,16 @@ template<bool HasTransitionLength, typename... Ts> class ToggleAction : public A
|
||||
// plus one parent pointer, regardless of how many fields the user set.
|
||||
// Trigger args are forwarded to the apply function so user lambdas
|
||||
// (e.g. `brightness: !lambda "return x;"`) keep working.
|
||||
//
|
||||
// Trigger args are normalized to `const std::remove_cvref_t<Ts> &...` so
|
||||
// the codegen can emit a matching parameter list for both the apply lambda
|
||||
// and any inner field lambdas without producing invalid C++ source text
|
||||
// (e.g. `const T & &` if Ts already carries a reference, or `const const
|
||||
// T &` if Ts already carries a const). This keeps trigger args no-copy
|
||||
// regardless of whether the trigger supplies `T`, `T &`, or `const T &`.
|
||||
template<typename... Ts> class LightControlAction : public Action<Ts...> {
|
||||
public:
|
||||
using ApplyFn = void (*)(LightState *, LightCall &, const Ts &...);
|
||||
using ApplyFn = void (*)(LightState *, LightCall &, const std::remove_cvref_t<Ts> &...);
|
||||
LightControlAction(LightState *parent, ApplyFn apply) : parent_(parent), apply_(apply) {}
|
||||
|
||||
void play(const Ts &...x) override {
|
||||
|
||||
@@ -200,6 +200,15 @@ async def light_control_to_code(config, action_id, template_arg, args):
|
||||
(CONF_WARM_WHITE, "set_warm_white", cg.float_),
|
||||
)
|
||||
|
||||
# Normalize trigger args to `const std::remove_cvref_t<T> &` so the
|
||||
# apply lambda and any inner field lambdas (generated below via
|
||||
# `process_lambda`) share one parameter spelling that's well-formed for
|
||||
# any T (value, ref, or const-ref). Matches LightControlAction::ApplyFn.
|
||||
normalized_args = [
|
||||
(cg.RawExpression(f"const std::remove_cvref_t<{cg.safe_exp(t)}> &"), n)
|
||||
for t, n in args
|
||||
]
|
||||
|
||||
fwd_args = ", ".join(name for _, name in args)
|
||||
body_lines: list[str] = []
|
||||
|
||||
@@ -208,7 +217,7 @@ async def light_control_to_code(config, action_id, template_arg, args):
|
||||
continue
|
||||
value = config[conf_key]
|
||||
if isinstance(value, Lambda):
|
||||
inner = await cg.process_lambda(value, args, return_type=type_)
|
||||
inner = await cg.process_lambda(value, normalized_args, return_type=type_)
|
||||
body_lines.append(f"call.{setter}(({inner})({fwd_args}));")
|
||||
else:
|
||||
body_lines.append(f"call.{setter}({cg.safe_exp(value)});")
|
||||
@@ -216,7 +225,7 @@ async def light_control_to_code(config, action_id, template_arg, args):
|
||||
if CONF_EFFECT in config:
|
||||
if isinstance(config[CONF_EFFECT], Lambda):
|
||||
inner_lambda = await cg.process_lambda(
|
||||
config[CONF_EFFECT], args, return_type=cg.std_string
|
||||
config[CONF_EFFECT], normalized_args, return_type=cg.std_string
|
||||
)
|
||||
body_lines.append(
|
||||
f"{{ auto __effect_s = ({inner_lambda})({fwd_args});\n"
|
||||
@@ -230,11 +239,10 @@ async def light_control_to_code(config, action_id, template_arg, args):
|
||||
f"call.set_effect(static_cast<uint32_t>({_resolve_effect_index(config)}));"
|
||||
)
|
||||
|
||||
# Match LightControlAction::ApplyFn signature: const Ts &... for trigger args.
|
||||
apply_args = [
|
||||
(LightState.operator("ptr"), "parent"),
|
||||
(LightCall.operator("ref"), "call"),
|
||||
*((t.operator("const").operator("ref"), n) for t, n in args),
|
||||
*normalized_args,
|
||||
]
|
||||
apply_lambda = LambdaExpression(
|
||||
["\n".join(body_lines)],
|
||||
|
||||
@@ -80,8 +80,8 @@ void Logger::pre_setup() {
|
||||
this->uart_dev_ = uart_dev;
|
||||
#if defined(USE_LOGGER_WAIT_FOR_CDC) && defined(USE_LOGGER_UART_SELECTION_USB_CDC)
|
||||
uint32_t dtr = 0;
|
||||
uint32_t count = (10 * 100); // wait 10 sec for USB CDC to have early logs
|
||||
while (dtr == 0 && count-- != 0) {
|
||||
int32_t count = (10 * 100); // wait 10 sec for USB CDC to have early logs
|
||||
while (dtr == 0 && count-- > 0) {
|
||||
uart_line_ctrl_get(this->uart_dev_, UART_LINE_CTRL_DTR, &dtr);
|
||||
delay(10);
|
||||
arch_feed_wdt();
|
||||
@@ -160,6 +160,11 @@ void Logger::dump_crash_() {
|
||||
#if defined(CONFIG_THREAD_NAME)
|
||||
ESP_LOGE(TAG, "Thread: %s", crash_buf.thread);
|
||||
#endif
|
||||
int32_t count = (2 * 100); // wait 2 sec to give a chance to print crash
|
||||
while (count-- > 0) {
|
||||
delay(10);
|
||||
arch_feed_wdt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ from .helpers import (
|
||||
lv_fonts_used,
|
||||
requires_component,
|
||||
)
|
||||
from .types import lv_gradient_t, lv_opa_t
|
||||
from .types import lv_coord_t, lv_gradient_t, lv_opa_t
|
||||
|
||||
LV_OPA = LvConstant("LV_OPA_", "TRANSP", "COVER")
|
||||
|
||||
@@ -277,7 +277,7 @@ def pixels_or_percent_validator(value):
|
||||
|
||||
pixels_or_percent = LValidator(
|
||||
pixels_or_percent_validator,
|
||||
uint32,
|
||||
lv_coord_t,
|
||||
retmapper=lambda x: x if isinstance(x, int) else literal(f"lv_pct({int(x * 100)})"),
|
||||
)
|
||||
|
||||
|
||||
@@ -454,10 +454,12 @@ void LVTouchListener::update(const touchscreen::TouchPoints_t &tpoints) {
|
||||
|
||||
#ifdef USE_LVGL_METER
|
||||
|
||||
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value) {
|
||||
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value) {
|
||||
auto *scale = lv_obj_get_parent(obj);
|
||||
auto min_value = lv_scale_get_range_min_value(scale);
|
||||
return ((value - min_value) * lv_scale_get_angle_range(scale) / (lv_scale_get_range_max_value(scale) - min_value) +
|
||||
auto max_value = lv_scale_get_range_max_value(scale);
|
||||
value = clamp(value, min_value, max_value);
|
||||
return ((value - min_value) * lv_scale_get_angle_range(scale) / (max_value - min_value) +
|
||||
lv_scale_get_rotation((scale))) %
|
||||
360;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ inline void lv_animimg_set_src(lv_obj_t *img, std::vector<image::Image *> images
|
||||
#endif // USE_LVGL_ANIMIMG
|
||||
|
||||
#ifdef USE_LVGL_METER
|
||||
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int value);
|
||||
int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
|
||||
#endif
|
||||
|
||||
#ifdef USE_LVGL_GRADIENT
|
||||
|
||||
@@ -123,8 +123,8 @@ ENCODER_SCHEMA = cv.Schema(
|
||||
|
||||
POINT_SCHEMA = cv.Schema(
|
||||
{
|
||||
cv.Required(CONF_X): cv.templatable(cv.int_),
|
||||
cv.Required(CONF_Y): cv.templatable(cv.int_),
|
||||
cv.Required(CONF_X): lvalid.pixels_or_percent,
|
||||
cv.Required(CONF_Y): lvalid.pixels_or_percent,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -137,9 +137,13 @@ def point_schema(value):
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return POINT_SCHEMA(value)
|
||||
if isinstance(value, list):
|
||||
if len(value) != 2:
|
||||
raise cv.Invalid("Invalid point format, should be <x_int>, <y_int>")
|
||||
return POINT_SCHEMA({CONF_X: value[0], CONF_Y: value[1]})
|
||||
try:
|
||||
x, y = map(int, value.split(","))
|
||||
return {CONF_X: x, CONF_Y: y}
|
||||
x, y = str(value).split(",")
|
||||
return POINT_SCHEMA({CONF_X: x, CONF_Y: y})
|
||||
except ValueError:
|
||||
pass
|
||||
# not raising this in the catch block because pylint doesn't like it
|
||||
|
||||
@@ -52,6 +52,7 @@ from ..lv_validation import (
|
||||
lv_text,
|
||||
opacity,
|
||||
pixels,
|
||||
pixels_or_percent,
|
||||
size,
|
||||
)
|
||||
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr
|
||||
@@ -59,7 +60,7 @@ from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property
|
||||
from ..types import LvType, ObjUpdateAction
|
||||
from . import Widget, WidgetType, get_widgets
|
||||
from .img import CONF_IMAGE
|
||||
from .line import lv_point_precise_t, process_coord
|
||||
from .line import lv_point_precise_t
|
||||
|
||||
CONF_CANVAS = "canvas"
|
||||
CONF_BUFFER_ID = "buffer_id"
|
||||
@@ -434,6 +435,13 @@ LINE_PROPS = {
|
||||
}
|
||||
|
||||
|
||||
def _validate_points(config):
|
||||
for index, point in enumerate(config[CONF_POINTS]):
|
||||
if not all(isinstance(p, int) for p in point.values()):
|
||||
raise cv.Invalid("Points must be integers", path=[CONF_POINTS, index])
|
||||
return config
|
||||
|
||||
|
||||
@automation.register_action(
|
||||
"lvgl.canvas.draw_line",
|
||||
ObjUpdateAction,
|
||||
@@ -444,12 +452,15 @@ LINE_PROPS = {
|
||||
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
|
||||
**{cv.Optional(prop): validator for prop, validator in LINE_PROPS.items()},
|
||||
}
|
||||
),
|
||||
).add_extra(_validate_points),
|
||||
synchronous=True,
|
||||
)
|
||||
async def canvas_draw_line(config, action_id, template_arg, args):
|
||||
points = [
|
||||
[await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])]
|
||||
[
|
||||
await pixels.process(p[CONF_X]),
|
||||
await pixels.process(p[CONF_Y]),
|
||||
]
|
||||
for p in config[CONF_POINTS]
|
||||
]
|
||||
|
||||
@@ -470,12 +481,15 @@ async def canvas_draw_line(config, action_id, template_arg, args):
|
||||
cv.Required(CONF_POINTS): cv.ensure_list(point_schema),
|
||||
**{cv.Optional(prop): STYLE_PROPS[prop] for prop in RECT_PROPS},
|
||||
},
|
||||
),
|
||||
).add_extra(_validate_points),
|
||||
synchronous=True,
|
||||
)
|
||||
async def canvas_draw_polygon(config, action_id, template_arg, args):
|
||||
points = [
|
||||
[await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])]
|
||||
[
|
||||
await pixels_or_percent.process(p[CONF_X]),
|
||||
await pixels_or_percent.process(p[CONF_Y]),
|
||||
]
|
||||
for p in config[CONF_POINTS]
|
||||
]
|
||||
# Close the polygon
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import esphome.codegen as cg
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_X, CONF_Y
|
||||
from esphome.core import Lambda
|
||||
|
||||
from ..defines import CONF_MAIN, call_lambda
|
||||
from ..defines import CONF_MAIN
|
||||
from ..lv_validation import pixels_or_percent
|
||||
from ..lvcode import lv_add
|
||||
from ..schemas import point_schema
|
||||
from ..types import LvCompound, LvType, lv_coord_t
|
||||
from ..types import LvCompound, LvType
|
||||
from . import Widget, WidgetType
|
||||
|
||||
CONF_LINE = "line"
|
||||
@@ -17,12 +17,6 @@ lv_point_t = cg.global_ns.struct("lv_point_t")
|
||||
lv_point_precise_t = cg.global_ns.struct("lv_point_precise_t")
|
||||
|
||||
|
||||
async def process_coord(coord):
|
||||
if isinstance(coord, Lambda):
|
||||
return call_lambda(await cg.process_lambda(coord, [], return_type=lv_coord_t))
|
||||
return cg.safe_exp(coord)
|
||||
|
||||
|
||||
class LineType(WidgetType):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
@@ -36,7 +30,10 @@ class LineType(WidgetType):
|
||||
async def to_code(self, w: Widget, config):
|
||||
if CONF_POINTS in config:
|
||||
points = [
|
||||
[await process_coord(p[CONF_X]), await process_coord(p[CONF_Y])]
|
||||
[
|
||||
await pixels_or_percent.process(p[CONF_X]),
|
||||
await pixels_or_percent.process(p[CONF_Y]),
|
||||
]
|
||||
for p in config[CONF_POINTS]
|
||||
]
|
||||
lv_add(w.var.set_points(points))
|
||||
|
||||
@@ -39,7 +39,39 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp");
|
||||
// Wrap build-time defines into flash storage
|
||||
MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION);
|
||||
|
||||
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf) {
|
||||
void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) {
|
||||
#ifdef USE_MDNS_STORE_SERVICES
|
||||
auto &services = this->services_;
|
||||
#else
|
||||
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services_storage;
|
||||
auto &services = services_storage;
|
||||
#endif
|
||||
|
||||
#ifdef USE_API
|
||||
#ifdef USE_MDNS_STORE_SERVICES
|
||||
get_mac_address_into_buffer(this->mac_address_);
|
||||
char *mac_ptr = this->mac_address_;
|
||||
format_hex_to(this->config_hash_str_, App.get_config_hash());
|
||||
char *cfg_ptr = this->config_hash_str_;
|
||||
#else
|
||||
char mac_address[MAC_ADDRESS_BUFFER_SIZE];
|
||||
char config_hash_str[CONFIG_HASH_STR_SIZE];
|
||||
get_mac_address_into_buffer(mac_address);
|
||||
format_hex_to(config_hash_str, App.get_config_hash());
|
||||
char *mac_ptr = mac_address;
|
||||
char *cfg_ptr = config_hash_str;
|
||||
#endif
|
||||
#else
|
||||
char *mac_ptr = nullptr;
|
||||
char *cfg_ptr = nullptr;
|
||||
#endif
|
||||
|
||||
this->compile_records_(services, mac_ptr, cfg_ptr);
|
||||
platform_register(this, services);
|
||||
}
|
||||
|
||||
void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf,
|
||||
char *config_hash_buf) {
|
||||
// IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES
|
||||
// in mdns/__init__.py. If you add a new service here, update both locations.
|
||||
|
||||
@@ -47,6 +79,7 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
|
||||
MDNS_STATIC_CONST_CHAR(SERVICE_ESPHOMELIB, "_esphomelib");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_FRIENDLY_NAME, "friendly_name");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_VERSION, "version");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_CONFIG_HASH, "config_hash");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_MAC, "mac");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_PLATFORM, "platform");
|
||||
MDNS_STATIC_CONST_CHAR(TXT_BOARD, "board");
|
||||
@@ -63,7 +96,7 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
|
||||
bool friendly_name_empty = friendly_name.empty();
|
||||
|
||||
// Calculate exact capacity for txt_records
|
||||
size_t txt_count = 3; // version, mac, board (always present)
|
||||
size_t txt_count = 4; // version, config_hash, mac, board (always present)
|
||||
if (!friendly_name_empty) {
|
||||
txt_count++; // friendly_name
|
||||
}
|
||||
@@ -91,6 +124,9 @@ void MDNSComponent::compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUN
|
||||
}
|
||||
txt_records.push_back({MDNS_STR(TXT_VERSION), MDNS_STR(VALUE_VERSION)});
|
||||
|
||||
// Config hash: passed from caller (either member buffer or stack buffer depending on USE_MDNS_STORE_SERVICES)
|
||||
txt_records.push_back({MDNS_STR(TXT_CONFIG_HASH), MDNS_STR(config_hash_buf)});
|
||||
|
||||
// MAC address: passed from caller (either member buffer or stack buffer depending on USE_MDNS_STORE_SERVICES)
|
||||
txt_records.push_back({MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)});
|
||||
|
||||
|
||||
@@ -70,6 +70,9 @@ class MDNSComponent final : public Component
|
||||
void setup() override;
|
||||
void dump_config() override;
|
||||
|
||||
/// Size of buffer required for config hash hex string (8 hex chars + null terminator)
|
||||
static constexpr size_t CONFIG_HASH_STR_SIZE = format_hex_size(sizeof(uint32_t));
|
||||
|
||||
#ifdef USE_MDNS_EVENT_DRIVEN_POLLING
|
||||
// LEAmDNS has meaningful work only during the probe+announce phase (3×250ms probes +
|
||||
// 8×1000ms announces, ~9s). Afterwards every internal timer is resetToNeverExpires()
|
||||
@@ -124,30 +127,7 @@ class MDNSComponent final : public Component
|
||||
/// Helper to set up services and MAC buffers, then call platform-specific registration
|
||||
using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector<MDNSService, MDNS_SERVICE_COUNT> &);
|
||||
|
||||
void setup_buffers_and_register_(PlatformRegisterFn platform_register) {
|
||||
#ifdef USE_MDNS_STORE_SERVICES
|
||||
auto &services = this->services_;
|
||||
#else
|
||||
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services_storage;
|
||||
auto &services = services_storage;
|
||||
#endif
|
||||
|
||||
#ifdef USE_API
|
||||
#ifdef USE_MDNS_STORE_SERVICES
|
||||
get_mac_address_into_buffer(this->mac_address_);
|
||||
char *mac_ptr = this->mac_address_;
|
||||
#else
|
||||
char mac_address[MAC_ADDRESS_BUFFER_SIZE];
|
||||
get_mac_address_into_buffer(mac_address);
|
||||
char *mac_ptr = mac_address;
|
||||
#endif
|
||||
#else
|
||||
char *mac_ptr = nullptr;
|
||||
#endif
|
||||
|
||||
this->compile_records_(services, mac_ptr);
|
||||
platform_register(this, services);
|
||||
}
|
||||
void setup_buffers_and_register_(PlatformRegisterFn platform_register);
|
||||
|
||||
#ifdef USE_MDNS_DYNAMIC_TXT
|
||||
/// Storage for runtime-generated TXT values from user lambdas
|
||||
@@ -159,6 +139,8 @@ class MDNSComponent final : public Component
|
||||
#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES)
|
||||
/// Fixed buffer for MAC address (only needed when services are stored)
|
||||
char mac_address_[MAC_ADDRESS_BUFFER_SIZE];
|
||||
/// Fixed buffer for config hash hex string (only needed when services are stored)
|
||||
char config_hash_str_[CONFIG_HASH_STR_SIZE];
|
||||
#endif
|
||||
#ifdef USE_MDNS_STORE_SERVICES
|
||||
StaticVector<MDNSService, MDNS_SERVICE_COUNT> services_{};
|
||||
@@ -167,7 +149,8 @@ class MDNSComponent final : public Component
|
||||
// RP2040 defers MDNS.begin() until the first IP-up event; this tracks that.
|
||||
bool initialized_{false};
|
||||
#endif
|
||||
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf);
|
||||
void compile_records_(StaticVector<MDNSService, MDNS_SERVICE_COUNT> &services, char *mac_address_buf,
|
||||
char *config_hash_buf);
|
||||
};
|
||||
|
||||
} // namespace esphome::mdns
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
|
||||
#include "esphome/components/network/ip_address.h"
|
||||
#include "esphome/components/network/util.h"
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include "mdns_component.h"
|
||||
|
||||
@@ -13,10 +15,13 @@ void MDNSComponent::setup() {
|
||||
#ifdef USE_API
|
||||
get_mac_address_into_buffer(this->mac_address_);
|
||||
char *mac_ptr = this->mac_address_;
|
||||
format_hex_to(this->config_hash_str_, App.get_config_hash());
|
||||
char *cfg_ptr = this->config_hash_str_;
|
||||
#else
|
||||
char *mac_ptr = nullptr;
|
||||
char *cfg_ptr = nullptr;
|
||||
#endif
|
||||
this->compile_records_(this->services_, mac_ptr);
|
||||
this->compile_records_(this->services_, mac_ptr, cfg_ptr);
|
||||
#endif
|
||||
// Host platform doesn't have actual mDNS implementation
|
||||
}
|
||||
|
||||
@@ -133,6 +133,7 @@ def request_codecs_for_format_configs(
|
||||
audio.request_flac_support()
|
||||
audio.request_mp3_support()
|
||||
audio.request_opus_support()
|
||||
audio.request_wav_support()
|
||||
else:
|
||||
if "FLAC" in needed_formats:
|
||||
audio.request_flac_support()
|
||||
@@ -140,6 +141,8 @@ def request_codecs_for_format_configs(
|
||||
audio.request_mp3_support()
|
||||
if "OPUS" in needed_formats:
|
||||
audio.request_opus_support()
|
||||
if "WAV" in needed_formats:
|
||||
audio.request_wav_support()
|
||||
|
||||
|
||||
# Local config key constants
|
||||
|
||||
@@ -88,6 +88,33 @@ int Nextion::upload_by_chunks_(HTTPClient &http_client, uint32_t &range_start) {
|
||||
this->write_array(buffer, buffer_size);
|
||||
App.feed_wdt();
|
||||
this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true);
|
||||
|
||||
// Some Nextion firmware variants (notably bootloader/recovery mode on panels
|
||||
// with no installed TFT) emit the 5-byte 0x08+position fast-mode ack with a
|
||||
// multi-second gap between the leading 0x08 byte and the 4 trailing position
|
||||
// bytes. recv_ret_string_ returns after the first byte; manually drain the
|
||||
// trailing bytes from the UART before continuing.
|
||||
if (!recv_string.empty() && recv_string[0] == 0x08 && recv_string.size() < 5) {
|
||||
const uint32_t deadline = millis() + NEXTION_UPLOAD_ACK_TIMEOUT_MS;
|
||||
while (recv_string.size() < 5 && millis() < deadline) {
|
||||
if (this->available()) {
|
||||
uint8_t b = 0;
|
||||
if (this->read_byte(&b)) {
|
||||
recv_string.push_back(static_cast<char>(b));
|
||||
}
|
||||
} else {
|
||||
delay(5); // NOLINT
|
||||
App.feed_wdt();
|
||||
}
|
||||
}
|
||||
if (recv_string.size() < 5) {
|
||||
ESP_LOGE(TAG, "Truncated 0x08 response: got %zu bytes within %" PRIu32 "ms", recv_string.size(),
|
||||
NEXTION_UPLOAD_ACK_TIMEOUT_MS);
|
||||
allocator.deallocate(buffer, 4096);
|
||||
buffer = nullptr;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
this->content_length_ -= read_len;
|
||||
const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_;
|
||||
ESP_LOGD(TAG, "Upload: %0.2f%% (%" PRIu32 " left, heap: %" PRIu32 ")", upload_percentage, this->content_length_,
|
||||
|
||||
@@ -104,6 +104,33 @@ int Nextion::upload_by_chunks_(esp_http_client_handle_t http_client, uint32_t &r
|
||||
this->write_array(buffer, buffer_size);
|
||||
App.feed_wdt();
|
||||
this->recv_ret_string_(recv_string, NEXTION_UPLOAD_ACK_TIMEOUT_MS, true);
|
||||
|
||||
// Some Nextion firmware variants (notably bootloader/recovery mode on panels
|
||||
// with no installed TFT) emit the 5-byte 0x08+position fast-mode ack with a
|
||||
// multi-second gap between the leading 0x08 byte and the 4 trailing position
|
||||
// bytes. recv_ret_string_ returns after the first byte; manually drain the
|
||||
// trailing bytes from the UART before continuing.
|
||||
if (!recv_string.empty() && recv_string[0] == 0x08 && recv_string.size() < 5) {
|
||||
const uint32_t deadline = millis() + NEXTION_UPLOAD_ACK_TIMEOUT_MS;
|
||||
while (recv_string.size() < 5 && millis() < deadline) {
|
||||
if (this->available()) {
|
||||
uint8_t b = 0;
|
||||
if (this->read_byte(&b)) {
|
||||
recv_string.push_back(static_cast<char>(b));
|
||||
}
|
||||
} else {
|
||||
vTaskDelay(pdMS_TO_TICKS(5)); // NOLINT
|
||||
App.feed_wdt();
|
||||
}
|
||||
}
|
||||
if (recv_string.size() < 5) {
|
||||
ESP_LOGE(TAG, "Truncated 0x08 response: got %zu bytes within %" PRIu32 "ms", recv_string.size(),
|
||||
NEXTION_UPLOAD_ACK_TIMEOUT_MS);
|
||||
allocator.deallocate(buffer, 4096);
|
||||
buffer = nullptr;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
this->content_length_ -= read_len;
|
||||
const float upload_percentage = 100.0f * (this->tft_size_ - this->content_length_) / this->tft_size_;
|
||||
#ifdef USE_PSRAM
|
||||
|
||||
@@ -31,12 +31,15 @@ BOARDS_ZEPHYR = {
|
||||
# https://learn.adafruit.com/introducing-the-adafruit-nrf52840-feather?view=all#hathach-memory-map
|
||||
BOOTLOADER_CONFIG = {
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD132: [
|
||||
Section("empty_app_offset", 0x0, 0x26000, "flash_primary"),
|
||||
Section("SoftDevice", 0x0, 0x26000, "flash_primary"),
|
||||
Section("Adafruit_nRF52_Bootloader", 0xF4000, 0xC000, "flash_primary"),
|
||||
],
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD140_V6: [
|
||||
Section("empty_app_offset", 0x0, 0x26000, "flash_primary"),
|
||||
Section("SoftDevice", 0x0, 0x26000, "flash_primary"),
|
||||
Section("Adafruit_nRF52_Bootloader", 0xF4000, 0xC000, "flash_primary"),
|
||||
],
|
||||
BOOTLOADER_ADAFRUIT_NRF52_SD140_V7: [
|
||||
Section("empty_app_offset", 0x0, 0x27000, "flash_primary"),
|
||||
Section("SoftDevice", 0x0, 0x27000, "flash_primary"),
|
||||
Section("Adafruit_nRF52_Bootloader", 0xF4000, 0xC000, "flash_primary"),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
#ifdef USE_OTA_STATE_LISTENER
|
||||
#include <vector>
|
||||
#endif
|
||||
@@ -23,6 +25,7 @@ enum OTAResponseTypes {
|
||||
OTA_RESPONSE_UPDATE_END_OK = 0x45,
|
||||
OTA_RESPONSE_SUPPORTS_COMPRESSION = 0x46,
|
||||
OTA_RESPONSE_CHUNK_OK = 0x47,
|
||||
OTA_RESPONSE_FEATURE_FLAGS = 0x48,
|
||||
|
||||
OTA_RESPONSE_ERROR_MAGIC = 0x80,
|
||||
OTA_RESPONSE_ERROR_UPDATE_PREPARE = 0x81,
|
||||
@@ -38,6 +41,9 @@ enum OTAResponseTypes {
|
||||
OTA_RESPONSE_ERROR_MD5_MISMATCH = 0x8B,
|
||||
OTA_RESPONSE_ERROR_RP2040_NOT_ENOUGH_SPACE = 0x8C,
|
||||
OTA_RESPONSE_ERROR_SIGNATURE_INVALID = 0x8D,
|
||||
OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE = 0x8E,
|
||||
OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY = 0x8F,
|
||||
OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE = 0x90,
|
||||
OTA_RESPONSE_ERROR_UNKNOWN = 0xFF,
|
||||
};
|
||||
|
||||
@@ -49,6 +55,11 @@ enum OTAState {
|
||||
OTA_ERROR,
|
||||
};
|
||||
|
||||
enum OTAType : uint8_t {
|
||||
OTA_TYPE_UPDATE_APP = 0x00,
|
||||
OTA_TYPE_UPDATE_PARTITION_TABLE = 0x01,
|
||||
};
|
||||
|
||||
/** Listener interface for OTA state changes.
|
||||
*
|
||||
* Components can implement this interface to receive OTA state updates
|
||||
|
||||
@@ -13,7 +13,10 @@ static const char *const TAG = "ota.arduino_libretiny";
|
||||
|
||||
std::unique_ptr<ArduinoLibreTinyOTABackend> make_ota_backend() { return make_unique<ArduinoLibreTinyOTABackend>(); }
|
||||
|
||||
OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size) {
|
||||
OTAResponseTypes ArduinoLibreTinyOTABackend::begin(size_t image_size, OTAType ota_type) {
|
||||
if (ota_type != OTA_TYPE_UPDATE_APP) {
|
||||
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||
}
|
||||
// Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA
|
||||
// where the exact firmware size is unknown due to multipart encoding
|
||||
if (image_size == 0) {
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace esphome::ota {
|
||||
|
||||
class ArduinoLibreTinyOTABackend final {
|
||||
public:
|
||||
OTAResponseTypes begin(size_t image_size);
|
||||
OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP);
|
||||
void set_update_md5(const char *md5);
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
|
||||
@@ -15,7 +15,10 @@ static const char *const TAG = "ota.arduino_rp2040";
|
||||
|
||||
std::unique_ptr<ArduinoRP2040OTABackend> make_ota_backend() { return make_unique<ArduinoRP2040OTABackend>(); }
|
||||
|
||||
OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size) {
|
||||
OTAResponseTypes ArduinoRP2040OTABackend::begin(size_t image_size, OTAType ota_type) {
|
||||
if (ota_type != OTA_TYPE_UPDATE_APP) {
|
||||
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||
}
|
||||
// OTA size of 0 is not currently handled, but
|
||||
// web_server is not supported for RP2040, so this is not an issue.
|
||||
bool ret = Update.begin(image_size, U_FLASH);
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace esphome::ota {
|
||||
|
||||
class ArduinoRP2040OTABackend final {
|
||||
public:
|
||||
OTAResponseTypes begin(size_t image_size);
|
||||
OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP);
|
||||
void set_update_md5(const char *md5);
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
|
||||
@@ -50,7 +50,10 @@ static const char *const TAG = "ota.esp8266";
|
||||
|
||||
std::unique_ptr<ESP8266OTABackend> make_ota_backend() { return make_unique<ESP8266OTABackend>(); }
|
||||
|
||||
OTAResponseTypes ESP8266OTABackend::begin(size_t image_size) {
|
||||
OTAResponseTypes ESP8266OTABackend::begin(size_t image_size, OTAType ota_type) {
|
||||
if (ota_type != OTA_TYPE_UPDATE_APP) {
|
||||
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||
}
|
||||
// Handle UPDATE_SIZE_UNKNOWN (0) by calculating available space
|
||||
if (image_size == 0) {
|
||||
// Round down to sector boundary: subtract one sector, then mask to sector alignment
|
||||
|
||||
@@ -14,7 +14,7 @@ namespace esphome::ota {
|
||||
/// by not having a global Update object in .bss.
|
||||
class ESP8266OTABackend final {
|
||||
public:
|
||||
OTAResponseTypes begin(size_t image_size);
|
||||
OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP);
|
||||
void set_update_md5(const char *md5);
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
|
||||
@@ -16,7 +16,30 @@ static const char *const TAG = "ota.idf";
|
||||
|
||||
std::unique_ptr<IDFOTABackend> make_ota_backend() { return make_unique<IDFOTABackend>(); }
|
||||
|
||||
OTAResponseTypes IDFOTABackend::begin(size_t image_size) {
|
||||
OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type) {
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
this->ota_type_ = ota_type;
|
||||
if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) {
|
||||
// Reject any size other than ESP_PARTITION_TABLE_MAX_LEN: under- leaves stale bytes from the
|
||||
// previous table; over- can't fit the reserved region.
|
||||
if (image_size != ESP_PARTITION_TABLE_MAX_LEN) {
|
||||
ESP_LOGE(TAG, "Wrong partition table size: expected %u bytes, got %zu", ESP_PARTITION_TABLE_MAX_LEN, image_size);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
memset(this->buf_, 0xFF, sizeof this->buf_);
|
||||
this->buf_written_ = 0;
|
||||
this->image_size_ = image_size;
|
||||
this->md5_.init();
|
||||
return OTA_RESPONSE_OK;
|
||||
}
|
||||
if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) {
|
||||
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||
}
|
||||
#else
|
||||
if (ota_type != ota::OTA_TYPE_UPDATE_APP) {
|
||||
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_OTA_ROLLBACK
|
||||
// If we're starting an OTA, the current boot is good enough - mark it valid
|
||||
// to prevent rollback and allow the OTA to proceed even if the safe mode
|
||||
@@ -52,6 +75,21 @@ void IDFOTABackend::set_update_md5(const char *expected_md5) {
|
||||
}
|
||||
|
||||
OTAResponseTypes IDFOTABackend::write(uint8_t *data, size_t len) {
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) {
|
||||
if (len > PARTITION_TABLE_BUFFER_SIZE - this->buf_written_) {
|
||||
ESP_LOGE(TAG, "Wrong partition table size");
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
memcpy(this->buf_ + this->buf_written_, data, len);
|
||||
this->buf_written_ += len;
|
||||
this->md5_.add(data, len);
|
||||
return OTA_RESPONSE_OK;
|
||||
}
|
||||
if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) {
|
||||
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||
}
|
||||
#endif
|
||||
esp_err_t err = esp_ota_write(this->update_handle_, data, len);
|
||||
this->md5_.add(data, len);
|
||||
if (err != ESP_OK) {
|
||||
@@ -73,6 +111,14 @@ OTAResponseTypes IDFOTABackend::end() {
|
||||
return OTA_RESPONSE_ERROR_MD5_MISMATCH;
|
||||
}
|
||||
}
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
if (this->ota_type_ == ota::OTA_TYPE_UPDATE_PARTITION_TABLE) {
|
||||
return this->update_partition_table();
|
||||
}
|
||||
if (this->ota_type_ != ota::OTA_TYPE_UPDATE_APP) {
|
||||
return OTA_RESPONSE_ERROR_UNSUPPORTED_OTA_TYPE;
|
||||
}
|
||||
#endif
|
||||
esp_err_t err = esp_ota_end(this->update_handle_);
|
||||
this->update_handle_ = 0;
|
||||
if (err == ESP_OK) {
|
||||
@@ -96,6 +142,14 @@ OTAResponseTypes IDFOTABackend::end() {
|
||||
}
|
||||
|
||||
void IDFOTABackend::abort() {
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
if (this->partition_table_part_ != nullptr) {
|
||||
esp_partition_deregister_external(this->partition_table_part_);
|
||||
this->partition_table_part_ = nullptr;
|
||||
}
|
||||
#endif
|
||||
// esp_ota_abort with handle 0 returns ESP_ERR_INVALID_ARG harmlessly, so this is safe whether
|
||||
// or not an update is in flight.
|
||||
esp_ota_abort(this->update_handle_);
|
||||
this->update_handle_ = 0;
|
||||
}
|
||||
|
||||
@@ -9,21 +9,54 @@
|
||||
|
||||
namespace esphome::ota {
|
||||
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
// Staging buffer holds the entire partition table for verification before any flash op.
|
||||
static constexpr size_t PARTITION_TABLE_BUFFER_SIZE = ESP_PARTITION_TABLE_MAX_LEN; // 0xC00
|
||||
|
||||
void get_running_app_position(uint32_t &offset, size_t &size);
|
||||
#endif
|
||||
|
||||
class IDFOTABackend final {
|
||||
public:
|
||||
OTAResponseTypes begin(size_t image_size);
|
||||
OTAResponseTypes begin(size_t image_size, ota::OTAType ota_type = ota::OTA_TYPE_UPDATE_APP);
|
||||
void set_update_md5(const char *md5);
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
void abort();
|
||||
bool supports_compression() { return false; }
|
||||
|
||||
protected:
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
// copy_dest_part non-null means the running app must be copied INTO this slot of the current
|
||||
// table before the new partition table is committed. The destination is in the current table
|
||||
// because that's where esp_partition_copy can write; once the new table replaces it, the same
|
||||
// flash region becomes target_app_index in the new table.
|
||||
struct PartitionTablePlan {
|
||||
int target_app_index{-1};
|
||||
const esp_partition_t *copy_dest_part{nullptr};
|
||||
};
|
||||
|
||||
OTAResponseTypes validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size,
|
||||
PartitionTablePlan &plan);
|
||||
OTAResponseTypes update_partition_table();
|
||||
#endif
|
||||
|
||||
private:
|
||||
esp_ota_handle_t update_handle_{0};
|
||||
const esp_partition_t *partition_;
|
||||
md5::MD5Digest md5_{};
|
||||
char expected_bin_md5_[32];
|
||||
bool md5_set_{false};
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
// Buffer first so it packs tightly after the preceding `bool md5_set_` with no alignment
|
||||
// padding. Only resident during an active OTA: the backend is constructed per connection and
|
||||
// destroyed on cleanup_connection_().
|
||||
uint8_t buf_[PARTITION_TABLE_BUFFER_SIZE];
|
||||
size_t buf_written_{0};
|
||||
size_t image_size_{0};
|
||||
const esp_partition_t *partition_table_part_{nullptr};
|
||||
ota::OTAType ota_type_{ota::OTA_TYPE_UPDATE_APP};
|
||||
#endif
|
||||
};
|
||||
|
||||
std::unique_ptr<IDFOTABackend> make_ota_backend();
|
||||
|
||||
@@ -10,7 +10,9 @@ namespace esphome::ota {
|
||||
|
||||
std::unique_ptr<HostOTABackend> make_ota_backend() { return make_unique<HostOTABackend>(); }
|
||||
|
||||
OTAResponseTypes HostOTABackend::begin(size_t image_size) { return OTA_RESPONSE_ERROR_UPDATE_PREPARE; }
|
||||
OTAResponseTypes HostOTABackend::begin(size_t image_size, OTAType ota_type) {
|
||||
return OTA_RESPONSE_ERROR_UPDATE_PREPARE;
|
||||
}
|
||||
|
||||
void HostOTABackend::set_update_md5(const char *expected_md5) {}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace esphome::ota {
|
||||
/// OTA triggers to compile for host platform during development.
|
||||
class HostOTABackend final {
|
||||
public:
|
||||
OTAResponseTypes begin(size_t image_size);
|
||||
OTAResponseTypes begin(size_t image_size, OTAType ota_type = OTA_TYPE_UPDATE_APP);
|
||||
void set_update_md5(const char *md5);
|
||||
OTAResponseTypes write(uint8_t *data, size_t len);
|
||||
OTAResponseTypes end();
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
#ifdef USE_ESP32
|
||||
#include "ota_backend_esp_idf.h"
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#ifdef USE_OTA_PARTITIONS
|
||||
#include "esphome/components/watchdog/watchdog.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <esp_image_format.h>
|
||||
#include <esp_ota_ops.h>
|
||||
#include <nvs_flash.h>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::ota {
|
||||
|
||||
static const char *const TAG = "ota.idf";
|
||||
|
||||
static inline bool check_overlap(uint32_t a_offset, size_t a_size, uint32_t b_offset, size_t b_size) {
|
||||
return (a_offset + a_size > b_offset && b_offset + b_size > a_offset);
|
||||
}
|
||||
|
||||
// Wraps esp_partition_find/_get/_next/_release. Returns nullptr if no APP partition at `address`
|
||||
// is at least `min_size` bytes.
|
||||
static const esp_partition_t *find_app_partition_at(uint32_t address, size_t min_size) {
|
||||
const esp_partition_t *found = nullptr;
|
||||
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr);
|
||||
while (it != nullptr) {
|
||||
const esp_partition_t *p = esp_partition_get(it);
|
||||
if (p->address == address && p->size >= min_size) {
|
||||
found = p;
|
||||
break;
|
||||
}
|
||||
it = esp_partition_next(it);
|
||||
}
|
||||
esp_partition_iterator_release(it);
|
||||
return found;
|
||||
}
|
||||
|
||||
// Validates the staged partition table and picks the post-update boot slot. All non-destructive
|
||||
// checks live here; the destructive write is in update_partition_table().
|
||||
// Side effect: registers the live partition-table region as partition_table_part_ so the caller
|
||||
// can write to it; abort() releases it on error.
|
||||
OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_app_offset, size_t running_app_size,
|
||||
PartitionTablePlan &plan) {
|
||||
esp_err_t err = esp_partition_register_external(
|
||||
nullptr, ESP_PRIMARY_PARTITION_TABLE_OFFSET, ESP_PARTITION_TABLE_SIZE, "PrimaryPrtTable",
|
||||
ESP_PARTITION_TYPE_PARTITION_TABLE, ESP_PARTITION_SUBTYPE_PARTITION_TABLE_PRIMARY, &this->partition_table_part_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_partition_register_external failed (err=0x%X)", err);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
|
||||
int num_partitions = 0;
|
||||
const esp_partition_info_t *existing_partition_table = nullptr;
|
||||
esp_partition_mmap_handle_t partition_table_map;
|
||||
err = esp_partition_mmap(this->partition_table_part_, 0, ESP_PARTITION_TABLE_MAX_LEN, ESP_PARTITION_MMAP_DATA,
|
||||
reinterpret_cast<const void **>(&existing_partition_table), &partition_table_map);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_partition_mmap failed (err=0x%X)", err);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
err = esp_partition_table_verify(existing_partition_table, true, &num_partitions);
|
||||
esp_partition_munmap(partition_table_map);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_partition_table_verify failed (existing partition table) (err=0x%X)", err);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
|
||||
const esp_partition_info_t *new_partition_table = reinterpret_cast<const esp_partition_info_t *>(this->buf_);
|
||||
err = esp_partition_table_verify(new_partition_table, true, &num_partitions);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_partition_table_verify failed (new partition table) (err=0x%X)", err);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
|
||||
// esp_partition_table_verify does not catch a missing MD5 entry, but the bootloader refuses
|
||||
// to boot from a table without one.
|
||||
bool checksum_found = false;
|
||||
for (size_t i = 0; i < ESP_PARTITION_TABLE_MAX_ENTRIES; i++) {
|
||||
if (new_partition_table[i].magic == ESP_PARTITION_MAGIC_MD5) {
|
||||
checksum_found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!checksum_found) {
|
||||
ESP_LOGE(TAG, "New partition table has no checksum");
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
|
||||
// Slot-selection policy when multiple slots can host the running app: pick the FIRST eligible
|
||||
// slot in table order, preferring the no-copy path (matching offset) over the copy path.
|
||||
// Deterministic and table-ordering-stable.
|
||||
int app_partitions_found = 0;
|
||||
int new_app_part_index = -1;
|
||||
int new_app_part_index_with_copy = -1;
|
||||
const esp_partition_t *app_copy_dest_part = nullptr;
|
||||
bool otadata_partition_found = false;
|
||||
bool otadata_overlap = false;
|
||||
bool nvs_partition_found = false;
|
||||
for (int i = 0; i < num_partitions; i++) {
|
||||
const esp_partition_info_t *new_part = &new_partition_table[i];
|
||||
if (new_part->type == ESP_PARTITION_TYPE_APP) {
|
||||
app_partitions_found++;
|
||||
if (new_part->pos.size >= running_app_size) {
|
||||
if (new_part->pos.offset == running_app_offset) {
|
||||
if (new_app_part_index == -1) {
|
||||
new_app_part_index = i;
|
||||
}
|
||||
} else if (new_app_part_index_with_copy == -1 &&
|
||||
!check_overlap(running_app_offset, running_app_size, new_part->pos.offset, running_app_size)) {
|
||||
// esp_partition_copy writes into a registered partition; need one at this offset in the
|
||||
// current table.
|
||||
const esp_partition_t *p = find_app_partition_at(new_part->pos.offset, running_app_size);
|
||||
if (p != nullptr) {
|
||||
new_app_part_index_with_copy = i;
|
||||
app_copy_dest_part = p;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (new_part->type == ESP_PARTITION_TYPE_DATA) {
|
||||
if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_OTA) {
|
||||
otadata_partition_found = true;
|
||||
otadata_overlap = check_overlap(running_app_offset, running_app_size, new_part->pos.offset, new_part->pos.size);
|
||||
} else if (new_part->subtype == ESP_PARTITION_SUBTYPE_DATA_NVS &&
|
||||
strncmp(reinterpret_cast<const char *>(new_part->label), "nvs", sizeof(new_part->label)) == 0) {
|
||||
nvs_partition_found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (new_app_part_index == -1 && new_app_part_index_with_copy == -1) {
|
||||
// Most likely cause: the user picked the wrong migration .bin for their running app's size.
|
||||
// Rejecting here is non-destructive (no flash op has run yet); the user can safely retry with
|
||||
// a different .bin. Log enough info that they can pick the right method without guessing.
|
||||
ESP_LOGE(TAG,
|
||||
"Running app at 0x%X (%u bytes used) does not fit any compatible slot in the new "
|
||||
"partition table. Pick a migration method whose size limit is at least %u bytes and "
|
||||
"retry; no flash content was modified.",
|
||||
running_app_offset, running_app_size, running_app_size);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
if (app_partitions_found < 2) {
|
||||
ESP_LOGE(TAG, "New partition table needs at least 2 app partitions, found %d", app_partitions_found);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
if (!otadata_partition_found) {
|
||||
ESP_LOGE(TAG, "New partition table is missing the required otadata partition");
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
if (!nvs_partition_found) {
|
||||
ESP_LOGE(TAG, "New partition table is missing the required nvs partition");
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
if (otadata_overlap) {
|
||||
ESP_LOGE(TAG,
|
||||
"New otadata partition overlaps with the running app at 0x%X (size %u). The chosen "
|
||||
"partition table is not compatible with this device's current flash layout; pick a "
|
||||
"different migration method.",
|
||||
running_app_offset, running_app_size);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
|
||||
if (new_app_part_index != -1) {
|
||||
plan.target_app_index = new_app_part_index;
|
||||
plan.copy_dest_part = nullptr;
|
||||
} else {
|
||||
plan.target_app_index = new_app_part_index_with_copy;
|
||||
plan.copy_dest_part = app_copy_dest_part;
|
||||
}
|
||||
return OTA_RESPONSE_OK;
|
||||
}
|
||||
|
||||
OTAResponseTypes IDFOTABackend::update_partition_table() {
|
||||
if (this->buf_written_ == 0 || this->image_size_ != this->buf_written_) {
|
||||
ESP_LOGE(TAG, "Not enough data received");
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
|
||||
// Without a valid running-app size we cannot compute overlap or copy bounds. zero indicates
|
||||
// esp_ota_get_running_partition() failed (e.g. cache unloaded by a previous aborted OTA).
|
||||
uint32_t running_app_offset;
|
||||
size_t running_app_size;
|
||||
get_running_app_position(running_app_offset, running_app_size);
|
||||
if (running_app_size == 0) {
|
||||
ESP_LOGE(TAG, "Failed to determine running app position");
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
|
||||
}
|
||||
|
||||
PartitionTablePlan plan;
|
||||
OTAResponseTypes validate_result = this->validate_new_partition_table_(running_app_offset, running_app_size, plan);
|
||||
if (validate_result != OTA_RESPONSE_OK) {
|
||||
return validate_result;
|
||||
}
|
||||
|
||||
// ERROR severity so the warning shows up in default log filters; any failure past this point
|
||||
// can leave the device unbootable until it is recovered with a serial flash.
|
||||
ESP_LOGE(TAG, "Starting partition table update.\n"
|
||||
" DO NOT REMOVE POWER until the device reboots successfully.\n"
|
||||
" Loss of power during this operation may render the device unable to boot until\n"
|
||||
" it is recovered via a serial flash.");
|
||||
|
||||
// One guard over the whole critical section in case an IDF call takes longer than expected on
|
||||
// some chip variant.
|
||||
watchdog::WatchdogManager watchdog(15000);
|
||||
|
||||
esp_err_t err;
|
||||
const esp_partition_info_t *new_partition_table = reinterpret_cast<const esp_partition_info_t *>(this->buf_);
|
||||
|
||||
if (plan.copy_dest_part != nullptr) {
|
||||
// Resolve the source via running_app_offset rather than esp_ota_get_running_partition() in
|
||||
// case a prior aborted partition-table OTA called esp_partition_unload_all() in this boot,
|
||||
// which leaves esp_ota_get_running_partition() returning nullptr.
|
||||
const esp_partition_t *running_app_part = find_app_partition_at(running_app_offset, running_app_size);
|
||||
if (running_app_part == nullptr) {
|
||||
ESP_LOGE(TAG, "Cannot resolve running app partition at offset 0x%X", running_app_offset);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
|
||||
}
|
||||
ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address,
|
||||
plan.copy_dest_part->address, running_app_size);
|
||||
err = esp_partition_copy(plan.copy_dest_part, 0, running_app_part, 0, running_app_size);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_partition_copy failed (err=0x%X)", err);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
|
||||
}
|
||||
}
|
||||
|
||||
// Deinit NVS only just before the first destructive write so verify/copy failure paths return
|
||||
// with NVS still functional. From this point on, components that hold open NVS handles
|
||||
// (e.g. preferences) will fail with ESP_ERR_NVS_INVALID_HANDLE on success or failure;
|
||||
// nvs_flash_init() can re-init the subsystem but cannot revive existing handles. On the
|
||||
// success path the device reboots immediately afterwards so this doesn't matter; on the
|
||||
// failure path the user must reboot the device before retrying.
|
||||
nvs_flash_deinit();
|
||||
|
||||
// Update the partition table
|
||||
err = esp_ota_begin(this->partition_table_part_, ESP_PARTITION_TABLE_MAX_LEN, &this->update_handle_);
|
||||
if (err != ESP_OK) {
|
||||
esp_ota_abort(this->update_handle_);
|
||||
this->update_handle_ = 0;
|
||||
ESP_LOGE(TAG, "esp_ota_begin failed (err=0x%X)", err);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
|
||||
}
|
||||
err = esp_ota_write(this->update_handle_, this->buf_, ESP_PARTITION_TABLE_MAX_LEN);
|
||||
if (err != ESP_OK) {
|
||||
esp_ota_abort(this->update_handle_);
|
||||
this->update_handle_ = 0;
|
||||
ESP_LOGE(TAG, "esp_ota_write failed (err=0x%X)", err);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
|
||||
}
|
||||
err = esp_ota_end(this->update_handle_);
|
||||
this->update_handle_ = 0; // esp_ota_end releases the handle internally
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_end failed (err=0x%X)", err);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
|
||||
}
|
||||
// unload first, then null the member pointer; if abort() ran between the two steps it would
|
||||
// see a freed pointer. esp_partition_unload_all() invalidates partition_table_part_ too, so
|
||||
// an explicit deregister would be redundant.
|
||||
esp_partition_unload_all();
|
||||
this->partition_table_part_ = nullptr;
|
||||
|
||||
// Write otadata to set the new boot partition
|
||||
const esp_partition_info_t *new_part = &new_partition_table[plan.target_app_index];
|
||||
const esp_partition_t *new_boot_partition = find_app_partition_at(new_part->pos.offset, 0);
|
||||
if (new_boot_partition == nullptr) {
|
||||
ESP_LOGE(TAG, "Selected app partition not found after partition table update");
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
|
||||
}
|
||||
ESP_LOGD(TAG, "Setting next boot partition to 0x%X", new_boot_partition->address);
|
||||
err = esp_ota_set_boot_partition(new_boot_partition);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_ota_set_boot_partition failed (err=0x%X)", err);
|
||||
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
|
||||
}
|
||||
return OTA_RESPONSE_OK;
|
||||
}
|
||||
|
||||
// Process-scoped cache. Cannot be a backend member: backends are per-connection but the cache
|
||||
// must outlive a connection that called esp_partition_unload_all(), after which
|
||||
// esp_ota_get_running_partition() no longer returns valid data.
|
||||
static bool s_running_app_initialized = false;
|
||||
static uint32_t s_running_app_cached_offset = 0;
|
||||
static size_t s_running_app_cached_size = 0;
|
||||
|
||||
// Flag-gated rather than size==0 so a failed first call doesn't poison the cache.
|
||||
void get_running_app_position(uint32_t &offset, size_t &size) {
|
||||
if (!s_running_app_initialized) {
|
||||
const esp_partition_t *running_app_part = esp_ota_get_running_partition();
|
||||
if (running_app_part == nullptr || running_app_part->erase_size == 0) {
|
||||
// Surface zeros without committing to the cache so a later call has a chance to succeed.
|
||||
offset = 0;
|
||||
size = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t pending_offset = running_app_part->address;
|
||||
size_t pending_size = running_app_part->size;
|
||||
|
||||
const esp_partition_pos_t running_app_pos = {
|
||||
.offset = running_app_part->address,
|
||||
.size = running_app_part->size,
|
||||
};
|
||||
esp_image_metadata_t image_metadata = {};
|
||||
image_metadata.start_addr = running_app_part->address;
|
||||
if (esp_image_verify(ESP_IMAGE_VERIFY_SILENT, &running_app_pos, &image_metadata) == ESP_OK &&
|
||||
image_metadata.image_len < running_app_part->size) {
|
||||
pending_size = image_metadata.image_len;
|
||||
}
|
||||
// Round up to a full flash sector so the copy spans complete erase blocks.
|
||||
pending_size = ((pending_size + running_app_part->erase_size - 1) / running_app_part->erase_size) *
|
||||
running_app_part->erase_size;
|
||||
|
||||
s_running_app_cached_offset = pending_offset;
|
||||
s_running_app_cached_size = pending_size;
|
||||
s_running_app_initialized = true;
|
||||
}
|
||||
|
||||
offset = s_running_app_cached_offset;
|
||||
size = s_running_app_cached_size;
|
||||
}
|
||||
|
||||
} // namespace esphome::ota
|
||||
|
||||
#endif // USE_OTA_PARTITIONS
|
||||
#endif // USE_ESP32
|
||||
@@ -611,3 +611,62 @@ def merge_packages(config: dict) -> dict:
|
||||
config = reduce(lambda new, old: merge_config(old, new), merge_list, config)
|
||||
del config[CONF_PACKAGES]
|
||||
return config
|
||||
|
||||
|
||||
def resolve_packages(
|
||||
config: dict[str, Any],
|
||||
*,
|
||||
command_line_substitutions: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Load and merge ``packages:`` in one call; return the flattened config.
|
||||
|
||||
Convenience wrapper around :func:`do_packages_pass` followed by
|
||||
:func:`merge_packages`. External tools that want the package-
|
||||
merged dict (without going through full schema validation via
|
||||
:func:`esphome.config.read_config`) get one stable seam to call
|
||||
instead of having to chain the two functions and stay in sync
|
||||
with the pipeline order.
|
||||
|
||||
Note: the full :func:`esphome.config.validate_config` pipeline
|
||||
runs two extra passes around the merge that this wrapper
|
||||
deliberately skips:
|
||||
|
||||
1. :func:`esphome.components.substitutions.do_substitution_pass`
|
||||
runs BETWEEN :func:`do_packages_pass` and
|
||||
:func:`merge_packages`, so ``${var}`` placeholders inside
|
||||
package content are NOT resolved here. Callers that need
|
||||
substitution should invoke ``do_substitution_pass``
|
||||
themselves between calls, or go through the full
|
||||
``validate_config``.
|
||||
2. :func:`esphome.config.resolve_extend_remove` runs AFTER
|
||||
:func:`merge_packages`, so top-level ``!remove`` / ``!extend``
|
||||
markers are NOT applied here. A package-contributed block
|
||||
paired with a top-level ``key: !remove`` will still appear
|
||||
in the returned dict (the marker just sits next to it).
|
||||
|
||||
The wrapper exists for the "what blocks did packages
|
||||
contribute?" question — metadata callers that just need to
|
||||
see merged top-level keys. It is NOT a stand-in for
|
||||
:func:`esphome.config.validate_config` and the two passes
|
||||
above are the reasons why.
|
||||
|
||||
Used by:
|
||||
|
||||
- ``esphome/device-builder`` — the new WebSocket dashboard
|
||||
backend reads device metadata (api / wifi / target-platform
|
||||
flags) off the merged config so packages contribute the same
|
||||
blocks the compiler sees, not just whatever sits at the top
|
||||
of the user's YAML. See
|
||||
https://github.com/esphome/device-builder/issues/288 for the
|
||||
bug this fixes.
|
||||
|
||||
Returns *config* unchanged when ``packages:`` isn't present, so
|
||||
callers can apply this unconditionally without having to peek
|
||||
at the config first.
|
||||
"""
|
||||
if CONF_PACKAGES not in config:
|
||||
return config
|
||||
config = do_packages_pass(
|
||||
config, command_line_substitutions=command_line_substitutions
|
||||
)
|
||||
return merge_packages(config)
|
||||
|
||||
@@ -69,6 +69,18 @@ def set_core_data(config):
|
||||
|
||||
|
||||
def get_download_types(storage_json):
|
||||
"""Binary-download entries for a built RP2040 firmware.
|
||||
|
||||
Used by:
|
||||
- esphome.dashboard (legacy "Download .bin" button)
|
||||
- device-builder (esphome/device-builder) — same dispatch via
|
||||
``importlib.import_module(f"esphome.components.{platform}")``
|
||||
then ``module.get_download_types(storage)``. The contract is
|
||||
"returns ``list[dict]`` with at least ``title`` /
|
||||
``description`` / ``file`` / ``download`` keys"; please keep
|
||||
the shape stable so the new dashboard's download panel
|
||||
doesn't have to special-case per-platform schemas.
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"title": "UF2 factory format",
|
||||
|
||||
@@ -1,41 +1,6 @@
|
||||
#ifdef USE_RP2040
|
||||
|
||||
#include "core.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#ifdef USE_RP2040_CRASH_HANDLER
|
||||
#include "crash_handler.h"
|
||||
#endif
|
||||
#include "esphome/core/hal.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
#include "hardware/timer.h"
|
||||
#include "hardware/watchdog.h"
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// yield(), delay(), micros(), millis(), millis_64() inlined in hal.h.
|
||||
void HOT delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
|
||||
void arch_restart() {
|
||||
watchdog_reboot(0, 0, 10);
|
||||
while (1) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
void arch_init() {
|
||||
#ifdef USE_RP2040_CRASH_HANDLER
|
||||
rp2040::crash_handler_read_and_clear();
|
||||
#endif
|
||||
#if USE_RP2040_WATCHDOG_TIMEOUT > 0
|
||||
watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false);
|
||||
#endif
|
||||
}
|
||||
|
||||
void HOT arch_feed_wdt() { watchdog_update(); }
|
||||
|
||||
uint32_t HOT arch_get_cpu_cycle_count() { return ulMainGetRunTimeCounterValue(); }
|
||||
uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); }
|
||||
|
||||
} // namespace esphome
|
||||
// HAL functions live in hal.cpp. core.cpp is intentionally empty for
|
||||
// rp2040 — there is no extra component bootstrap to keep here.
|
||||
|
||||
#endif // USE_RP2040
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#ifdef USE_RP2040
|
||||
|
||||
#include "core.h"
|
||||
#include "esphome/core/defines.h"
|
||||
#include "esphome/core/hal.h"
|
||||
#ifdef USE_RP2040_CRASH_HANDLER
|
||||
#include "crash_handler.h"
|
||||
#endif
|
||||
|
||||
#include "hardware/watchdog.h"
|
||||
|
||||
// Empty rp2040 namespace block to satisfy ci-custom's lint_namespace check.
|
||||
// HAL functions live in namespace esphome (root) — they are not part of the
|
||||
// rp2040 component's API.
|
||||
namespace esphome::rp2040 {} // namespace esphome::rp2040
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// yield(), delay(), micros(), millis(), millis_64(), delayMicroseconds(),
|
||||
// arch_feed_wdt(), arch_get_cpu_cycle_count() inlined in components/rp2040/hal.h.
|
||||
void arch_restart() {
|
||||
watchdog_reboot(0, 0, 10);
|
||||
while (1) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
void arch_init() {
|
||||
#ifdef USE_RP2040_CRASH_HANDLER
|
||||
rp2040::crash_handler_read_and_clear();
|
||||
#endif
|
||||
#if USE_RP2040_WATCHDOG_TIMEOUT > 0
|
||||
watchdog_enable(USE_RP2040_WATCHDOG_TIMEOUT, false);
|
||||
#endif
|
||||
}
|
||||
|
||||
uint32_t arch_get_cpu_freq_hz() { return RP2040::f_cpu(); }
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
#endif // USE_RP2040
|
||||
@@ -20,8 +20,19 @@ extern "C" unsigned long millis(void);
|
||||
// Forward decl from <pico/time.h>.
|
||||
extern "C" uint64_t time_us_64(void);
|
||||
|
||||
// Forward decls from pico-sdk / FreeRTOS port for the inline arch_*
|
||||
// wrappers below.
|
||||
extern "C" void watchdog_update(void);
|
||||
extern "C" unsigned long ulMainGetRunTimeCounterValue(void);
|
||||
|
||||
namespace esphome::rp2040 {}
|
||||
|
||||
namespace esphome {
|
||||
|
||||
// Forward decl from helpers.h.
|
||||
// NOLINTNEXTLINE(readability-redundant-declaration)
|
||||
void delay_microseconds_safe(uint32_t us);
|
||||
|
||||
/// Returns true when executing inside an interrupt handler.
|
||||
__attribute__((always_inline)) inline bool in_isr_context() {
|
||||
uint32_t ipsr;
|
||||
@@ -35,9 +46,15 @@ __attribute__((always_inline)) inline uint32_t micros() { return static_cast<uin
|
||||
__attribute__((always_inline)) inline uint32_t millis() { return micros_to_millis(::time_us_64()); }
|
||||
__attribute__((always_inline)) inline uint64_t millis_64() { return micros_to_millis<uint64_t>(::time_us_64()); }
|
||||
|
||||
void delayMicroseconds(uint32_t us); // NOLINT(readability-identifier-naming)
|
||||
void arch_feed_wdt();
|
||||
uint32_t arch_get_cpu_cycle_count();
|
||||
// NOLINTNEXTLINE(readability-identifier-naming)
|
||||
__attribute__((always_inline)) inline void delayMicroseconds(uint32_t us) { delay_microseconds_safe(us); }
|
||||
__attribute__((always_inline)) inline void arch_feed_wdt() { watchdog_update(); }
|
||||
__attribute__((always_inline)) inline uint32_t arch_get_cpu_cycle_count() {
|
||||
return static_cast<uint32_t>(ulMainGetRunTimeCounterValue());
|
||||
}
|
||||
|
||||
void arch_init();
|
||||
uint32_t arch_get_cpu_freq_hz();
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -24,6 +24,7 @@ CONF_SENDSPIN_ID = "sendspin_id"
|
||||
|
||||
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
|
||||
CONF_FIXED_DELAY = "fixed_delay"
|
||||
CONF_DECODE_MEMORY = "decode_memory"
|
||||
|
||||
# sendspin-cpp library lives in the global `sendspin` namespace.
|
||||
sendspin_library_ns = cg.global_ns.namespace("sendspin")
|
||||
@@ -39,6 +40,18 @@ CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
|
||||
AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject")
|
||||
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
|
||||
|
||||
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
|
||||
# preference for the player role's transfer buffers.
|
||||
SendspinMemoryLocation = sendspin_library_ns.enum("MemoryLocation", is_class=True)
|
||||
|
||||
MEMORY_PSRAM = "psram"
|
||||
MEMORY_INTERNAL = "internal"
|
||||
MEMORY_LOCATIONS = [MEMORY_PSRAM, MEMORY_INTERNAL]
|
||||
MEMORY_LOCATION_ENUM = {
|
||||
MEMORY_PSRAM: SendspinMemoryLocation.PREFER_EXTERNAL,
|
||||
MEMORY_INTERNAL: SendspinMemoryLocation.PREFER_INTERNAL,
|
||||
}
|
||||
|
||||
# Trailing underscore avoids clashing with sendspin-cpp's global `sendspin` namespace.
|
||||
# Analysis tools strip the trailing underscore (same pattern as `template_`).
|
||||
sendspin_ns = cg.esphome_ns.namespace("sendspin_")
|
||||
@@ -193,7 +206,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
)
|
||||
|
||||
# sendspin-cpp library
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.3.1")
|
||||
esp32.add_idf_component(name="sendspin/sendspin-cpp", ref="0.4.0")
|
||||
|
||||
cg.add_define("USE_SENDSPIN", True) # for MDNS
|
||||
|
||||
@@ -249,14 +262,23 @@ async def to_code(config: ConfigType) -> None:
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
|
||||
player_config_struct = cg.StructInitializer(
|
||||
PlayerRoleConfig,
|
||||
# Library defaults: priority 18 (one above httpd_priority 17 so the decoder is not
|
||||
# starved by the HTTP server during the initial encoded-audio burst at stream start),
|
||||
# interpolation/decode buffer locations PREFER_EXTERNAL.
|
||||
player_struct_fields = [
|
||||
("audio_formats", audio_format_structs),
|
||||
("audio_buffer_capacity", player_cfg[CONF_BUFFER_SIZE]),
|
||||
("fixed_delay_us", player_cfg[CONF_FIXED_DELAY]),
|
||||
("initial_static_delay_ms", player_cfg[CONF_INITIAL_STATIC_DELAY]),
|
||||
("psram_stack", psram_stack),
|
||||
("priority", 2),
|
||||
]
|
||||
if (decode_memory := player_cfg.get(CONF_DECODE_MEMORY)) is not None:
|
||||
player_struct_fields.append(
|
||||
("decode_buffer_location", MEMORY_LOCATION_ENUM[decode_memory])
|
||||
)
|
||||
player_config_struct = cg.StructInitializer(
|
||||
PlayerRoleConfig,
|
||||
*player_struct_fields,
|
||||
)
|
||||
cg.add(var.set_player_config(player_config_struct))
|
||||
else:
|
||||
|
||||
@@ -13,9 +13,11 @@ from esphome.cpp_generator import MockObj, TemplateArgsType
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_DECODE_MEMORY,
|
||||
CONF_FIXED_DELAY,
|
||||
CONF_INITIAL_STATIC_DELAY,
|
||||
CONF_SENDSPIN_ID,
|
||||
MEMORY_LOCATIONS,
|
||||
SendspinHub,
|
||||
_validate_task_stack_in_psram,
|
||||
register_player_config,
|
||||
@@ -57,6 +59,7 @@ def _register(config: ConfigType) -> ConfigType:
|
||||
CONF_INITIAL_STATIC_DELAY: config[CONF_INITIAL_STATIC_DELAY],
|
||||
CONF_FIXED_DELAY: config[CONF_FIXED_DELAY],
|
||||
CONF_TASK_STACK_IN_PSRAM: config.get(CONF_TASK_STACK_IN_PSRAM, False),
|
||||
CONF_DECODE_MEMORY: config.get(CONF_DECODE_MEMORY),
|
||||
}
|
||||
)
|
||||
return config
|
||||
@@ -82,6 +85,7 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_SAMPLE_RATE, default=48000): cv.int_range(
|
||||
min=16000, max=96000
|
||||
),
|
||||
cv.Optional(CONF_DECODE_MEMORY): cv.one_of(*MEMORY_LOCATIONS, lower=True),
|
||||
}
|
||||
),
|
||||
cv.only_on_esp32,
|
||||
|
||||
@@ -266,7 +266,7 @@ StreamingMovingAverageFilter = sensor_ns.class_("StreamingMovingAverageFilter",
|
||||
ExponentialMovingAverageFilter = sensor_ns.class_(
|
||||
"ExponentialMovingAverageFilter", Filter
|
||||
)
|
||||
ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter, cg.Component)
|
||||
ThrottleAverageFilter = sensor_ns.class_("ThrottleAverageFilter", Filter)
|
||||
LambdaFilter = sensor_ns.class_("LambdaFilter", Filter)
|
||||
StatelessLambdaFilter = sensor_ns.class_("StatelessLambdaFilter", Filter)
|
||||
OffsetFilter = sensor_ns.class_("OffsetFilter", Filter)
|
||||
@@ -283,8 +283,8 @@ ThrottleWithPriorityNanFilter = sensor_ns.class_(
|
||||
TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component)
|
||||
TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase)
|
||||
TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase)
|
||||
DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component)
|
||||
HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component)
|
||||
DebounceFilter = sensor_ns.class_("DebounceFilter", Filter)
|
||||
HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter)
|
||||
DeltaFilter = sensor_ns.class_("DeltaFilter", Filter)
|
||||
OrFilter = sensor_ns.class_("OrFilter", Filter)
|
||||
CalibrateLinearFilter = sensor_ns.class_("CalibrateLinearFilter", Filter)
|
||||
@@ -564,12 +564,15 @@ async def exponential_moving_average_filter_to_code(config, filter_id):
|
||||
|
||||
|
||||
@FILTER_REGISTRY.register(
|
||||
"throttle_average", ThrottleAverageFilter, cv.positive_time_period_milliseconds
|
||||
"throttle_average",
|
||||
ThrottleAverageFilter,
|
||||
cv.All(
|
||||
cv.positive_time_period_milliseconds,
|
||||
cv.Range(max=cv.TimePeriod(hours=24)),
|
||||
),
|
||||
)
|
||||
async def throttle_average_filter_to_code(config, filter_id):
|
||||
var = cg.new_Pvariable(filter_id, config)
|
||||
await cg.register_component(var, {})
|
||||
return var
|
||||
return cg.new_Pvariable(filter_id, config)
|
||||
|
||||
|
||||
@FILTER_REGISTRY.register("lambda", LambdaFilter, cv.returning_lambda)
|
||||
@@ -698,13 +701,10 @@ HEARTBEAT_SCHEMA = cv.Schema(
|
||||
async def heartbeat_filter_to_code(config, filter_id):
|
||||
if isinstance(config, dict):
|
||||
var = cg.new_Pvariable(filter_id, config[CONF_PERIOD])
|
||||
await cg.register_component(var, {})
|
||||
cg.add(var.set_optimistic(config[CONF_OPTIMISTIC]))
|
||||
return var
|
||||
|
||||
var = cg.new_Pvariable(filter_id, config)
|
||||
await cg.register_component(var, {})
|
||||
return var
|
||||
return cg.new_Pvariable(filter_id, config)
|
||||
|
||||
|
||||
TIMEOUT_SCHEMA = cv.maybe_simple_value(
|
||||
@@ -738,9 +738,7 @@ async def timeout_filter_to_code(config, filter_id):
|
||||
"debounce", DebounceFilter, cv.positive_time_period_milliseconds
|
||||
)
|
||||
async def debounce_filter_to_code(config, filter_id):
|
||||
var = cg.new_Pvariable(filter_id, config)
|
||||
await cg.register_component(var, {})
|
||||
return var
|
||||
return cg.new_Pvariable(filter_id, config)
|
||||
|
||||
|
||||
CONF_DATAPOINTS = "datapoints"
|
||||
|
||||
@@ -13,11 +13,6 @@ namespace esphome::sensor {
|
||||
|
||||
static const char *const TAG = "sensor.filter";
|
||||
|
||||
// Filter scheduler IDs.
|
||||
// Each filter is its own Component instance, so the scheduler scopes
|
||||
// IDs by component pointer — no risk of collisions between instances.
|
||||
constexpr uint32_t FILTER_ID = 0;
|
||||
|
||||
// Filter
|
||||
void Filter::input(float value) {
|
||||
ESP_LOGVV(TAG, "Filter(%p)::input(%f)", this, value);
|
||||
@@ -185,8 +180,9 @@ optional<float> ThrottleAverageFilter::new_value(float value) {
|
||||
}
|
||||
return {};
|
||||
}
|
||||
void ThrottleAverageFilter::setup() {
|
||||
this->set_interval(FILTER_ID, this->time_period_, [this]() {
|
||||
void ThrottleAverageFilter::initialize(Sensor *parent, Filter *next) {
|
||||
Filter::initialize(parent, next);
|
||||
App.scheduler.set_interval(this, this->time_period_, [this]() {
|
||||
ESP_LOGVV(TAG, "ThrottleAverageFilter(%p)::interval(sum=%f, n=%i)", this, this->sum_, this->n_);
|
||||
if (this->n_ == 0) {
|
||||
if (this->have_nan_)
|
||||
@@ -199,7 +195,6 @@ void ThrottleAverageFilter::setup() {
|
||||
this->have_nan_ = false;
|
||||
});
|
||||
}
|
||||
float ThrottleAverageFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
// LambdaFilter
|
||||
LambdaFilter::LambdaFilter(lambda_filter_t lambda_filter) : lambda_filter_(std::move(lambda_filter)) {}
|
||||
@@ -362,13 +357,12 @@ optional<float> TimeoutFilterConfigured::new_value(float value) {
|
||||
|
||||
// DebounceFilter
|
||||
optional<float> DebounceFilter::new_value(float value) {
|
||||
this->set_timeout(FILTER_ID, this->time_period_, [this, value]() { this->output(value); });
|
||||
App.scheduler.set_timeout(this, this->time_period_, [this, value]() { this->output(value); });
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
DebounceFilter::DebounceFilter(uint32_t time_period) : time_period_(time_period) {}
|
||||
float DebounceFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
// HeartbeatFilter
|
||||
HeartbeatFilter::HeartbeatFilter(uint32_t time_period) : time_period_(time_period), last_input_(NAN) {}
|
||||
@@ -384,8 +378,9 @@ optional<float> HeartbeatFilter::new_value(float value) {
|
||||
return {};
|
||||
}
|
||||
|
||||
void HeartbeatFilter::setup() {
|
||||
this->set_interval(FILTER_ID, this->time_period_, [this]() {
|
||||
void HeartbeatFilter::initialize(Sensor *parent, Filter *next) {
|
||||
Filter::initialize(parent, next);
|
||||
App.scheduler.set_interval(this, this->time_period_, [this]() {
|
||||
ESP_LOGVV(TAG, "HeartbeatFilter(%p)::interval(has_value=%s, last_input=%f)", this, YESNO(this->has_value_),
|
||||
this->last_input_);
|
||||
if (!this->has_value_)
|
||||
@@ -395,8 +390,6 @@ void HeartbeatFilter::setup() {
|
||||
});
|
||||
}
|
||||
|
||||
float HeartbeatFilter::get_setup_priority() const { return setup_priority::HARDWARE; }
|
||||
|
||||
optional<float> calibrate_linear_compute(const std::array<float, 3> *functions, size_t count, float value) {
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
if (!std::isfinite(functions[i][2]) || value < functions[i][2])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user