Merge remote-tracking branch 'upstream/dev' into integration

# Conflicts:
#	esphome/__main__.py
#	esphome/components/api/client.py
#	esphome/components/bl0942/bl0942.cpp
#	esphome/components/cover/__init__.py
#	esphome/components/cover/automation.h
#	esphome/components/esphome/ota/ota_esphome.cpp
#	esphome/components/ota/ota_backend_esp_idf.cpp
#	esphome/components/ota/ota_partitions_esp_idf.cpp
#	esphome/espota2.py
#	tests/components/template/common-base.yaml
#	tests/unit_tests/components/api/test_client.py
#	tests/unit_tests/test_main.py
This commit is contained in:
J. Nick Koston
2026-05-06 16:47:31 -05:00
125 changed files with 4761 additions and 1254 deletions
+51 -1
View File
@@ -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
@@ -366,7 +415,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@c381be0bfd20e844fb45594f6aa182ffcd94545c # v4.15.0
uses: CodSpeedHQ/action@3194d9a39c4d46684cb44bf7207fc56626aad8fd # v4.15.1
with:
run: ${{ steps.build.outputs.binary }}
mode: simulation
@@ -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
+2 -2
View File
@@ -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
+166 -31
View File
@@ -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
@@ -844,22 +878,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():
@@ -1120,25 +1152,83 @@ 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)
# Resolve MQTT magic strings to actual IP addresses
network_devices = _resolve_network_devices(devices, config, args)
binary = CORE.firmware_bin
ota_type = espota2.OTA_TYPE_UPDATE_APP
if getattr(args, "partition_table", False):
@@ -1162,6 +1252,28 @@ def upload_program(
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
@@ -1882,6 +1994,17 @@ 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).",
@@ -1956,6 +2079,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",
@@ -2168,8 +2302,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
View File
@@ -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]:
+10 -8
View File
@@ -19,7 +19,7 @@ import contextlib
from esphome.const import CONF_KEY, CONF_PORT, __version__
from esphome.core import CORE, EsphomeError
from esphome.platformio_api import process_stacktrace
from esphome.util import safe_print
from . import CONF_ENCRYPTION
@@ -61,10 +61,6 @@ class _LogLineProcessor:
self.backtrace_state = self._platform_handler(
self._config, raw_line, self.backtrace_state
)
else:
self.backtrace_state = process_stacktrace(
self._config, raw_line, backtrace_state=self.backtrace_state
)
except EsphomeError as exc:
self._decode_enabled = False
self.backtrace_state = False
@@ -106,7 +102,6 @@ async def async_run_logs(
noise_psk=noise_psk,
addresses=addresses, # Pass all addresses for automatic retry
)
dashboard = CORE.dashboard
# Try platform-specific stacktrace handler first, fall back to generic
platform_process_stacktrace = None
@@ -114,7 +109,10 @@ 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)
@@ -128,7 +126,11 @@ 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():
processor.process_line(raw_line)
+101 -38
View File
@@ -1,6 +1,7 @@
#include "atm90e32.h"
#include <cinttypes>
#include <cmath>
#include <cstring>
#include <numbers>
#include "esphome/core/log.h"
@@ -8,6 +9,25 @@ namespace esphome {
namespace atm90e32 {
static const char *const TAG = "atm90e32";
static uint32_t pref_hash(const char *prefix, const char *name_space) {
auto hash = fnv1_hash(prefix);
return fnv1_hash_extend(hash, name_space);
}
template<typename T>
static int migrate_legacy_pref_if_needed(ESPPreferenceObject &current_pref, ESPPreferenceObject &legacy_pref,
T *scratch) {
T current{};
if (current_pref.load(&current)) {
return 0;
}
if (!legacy_pref.load(scratch)) {
return 0;
}
return current_pref.save(scratch) ? 1 : -1;
}
void ATM90E32Component::loop() {
if (this->get_publish_interval_flag_()) {
this->set_publish_interval_flag_(false);
@@ -112,10 +132,14 @@ void ATM90E32Component::get_cs_summary_(std::span<char, GPIO_SUMMARY_MAX_LEN> bu
this->cs_->dump_summary(buffer.data(), buffer.size());
}
const char *ATM90E32Component::get_calibration_id_() { return this->instance_id_; }
void ATM90E32Component::setup() {
this->spi_setup();
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
char legacy_cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(legacy_cs);
const bool has_distinct_legacy_namespace = strcmp(cs, legacy_cs) != 0;
uint16_t mmode0 = 0x87; // 3P4W 50Hz
uint16_t high_thresh = 0;
@@ -162,15 +186,46 @@ void ATM90E32Component::setup() {
if (this->enable_offset_calibration_) {
// Initialize flash storage for offset calibrations
uint32_t o_hash = fnv1_hash("_offset_calibration_");
o_hash = fnv1_hash_extend(o_hash, cs);
uint32_t o_hash = pref_hash("_offset_calibration_", cs);
this->offset_pref_ = global_preferences->make_preference<OffsetCalibration[3]>(o_hash, true);
this->restore_offset_calibrations_();
bool migrated_offset = false;
if (has_distinct_legacy_namespace) {
uint32_t legacy_o_hash = pref_hash("_offset_calibration_", legacy_cs);
auto legacy_offset_pref = global_preferences->make_preference<OffsetCalibration[3]>(legacy_o_hash, true);
OffsetCalibration offset_data[3]{};
int migration_status = migrate_legacy_pref_if_needed(this->offset_pref_, legacy_offset_pref, &offset_data);
migrated_offset = migration_status > 0;
if (migration_status > 0) {
ESP_LOGI(TAG, "[CALIBRATION][%s] Migrated offset calibrations from legacy storage.", cs);
} else if (migration_status < 0) {
ESP_LOGW(TAG, "[CALIBRATION][%s] Failed to migrate offset calibrations from legacy storage.", cs);
}
}
// Initialize flash storage for power offset calibrations
uint32_t po_hash = fnv1_hash("_power_offset_calibration_");
po_hash = fnv1_hash_extend(po_hash, cs);
uint32_t po_hash = pref_hash("_power_offset_calibration_", cs);
this->power_offset_pref_ = global_preferences->make_preference<PowerOffsetCalibration[3]>(po_hash, true);
bool migrated_power_offset = false;
if (has_distinct_legacy_namespace) {
uint32_t legacy_po_hash = pref_hash("_power_offset_calibration_", legacy_cs);
auto legacy_power_offset_pref =
global_preferences->make_preference<PowerOffsetCalibration[3]>(legacy_po_hash, true);
PowerOffsetCalibration power_offset_data[3]{};
int migration_status =
migrate_legacy_pref_if_needed(this->power_offset_pref_, legacy_power_offset_pref, &power_offset_data);
migrated_power_offset = migration_status > 0;
if (migration_status > 0) {
ESP_LOGI(TAG, "[CALIBRATION][%s] Migrated power offset calibrations from legacy storage.", cs);
} else if (migration_status < 0) {
ESP_LOGW(TAG, "[CALIBRATION][%s] Failed to migrate power offset calibrations from legacy storage.", cs);
}
}
if (migrated_offset || migrated_power_offset) {
global_preferences->sync();
}
this->restore_offset_calibrations_();
this->restore_power_offset_calibrations_();
} else {
ESP_LOGI(TAG, "[CALIBRATION][%s] Power & Voltage/Current offset calibration is disabled. Using config file values.",
@@ -189,9 +244,27 @@ void ATM90E32Component::setup() {
if (this->enable_gain_calibration_) {
// Initialize flash storage for gain calibration
uint32_t g_hash = fnv1_hash("_gain_calibration_");
g_hash = fnv1_hash_extend(g_hash, cs);
uint32_t g_hash = pref_hash("_gain_calibration_", cs);
this->gain_calibration_pref_ = global_preferences->make_preference<GainCalibration[3]>(g_hash, true);
bool migrated_gain = false;
if (has_distinct_legacy_namespace) {
uint32_t legacy_g_hash = pref_hash("_gain_calibration_", legacy_cs);
auto legacy_gain_calibration_pref = global_preferences->make_preference<GainCalibration[3]>(legacy_g_hash, true);
GainCalibration gain_data[3]{};
int migration_status =
migrate_legacy_pref_if_needed(this->gain_calibration_pref_, legacy_gain_calibration_pref, &gain_data);
migrated_gain = migration_status > 0;
if (migration_status > 0) {
ESP_LOGI(TAG, "[CALIBRATION][%s] Migrated gain calibrations from legacy storage.", cs);
} else if (migration_status < 0) {
ESP_LOGW(TAG, "[CALIBRATION][%s] Failed to migrate gain calibrations from legacy storage.", cs);
}
}
if (migrated_gain) {
global_preferences->sync();
}
this->restore_gain_calibrations_();
if (!this->using_saved_calibrations_) {
@@ -221,8 +294,7 @@ void ATM90E32Component::setup() {
}
void ATM90E32Component::log_calibration_status_() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
bool offset_mismatch = false;
bool power_mismatch = false;
@@ -573,8 +645,7 @@ float ATM90E32Component::get_chip_temperature_() {
}
void ATM90E32Component::run_gain_calibrations() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
if (!this->enable_gain_calibration_) {
ESP_LOGW(TAG, "[CALIBRATION][%s] Gain calibration is disabled! Enable it first with enable_gain_calibration: true",
cs);
@@ -674,8 +745,7 @@ void ATM90E32Component::run_gain_calibrations() {
}
void ATM90E32Component::save_gain_calibration_to_memory_() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
bool success = this->gain_calibration_pref_.save(&this->gain_phase_);
global_preferences->sync();
if (success) {
@@ -688,8 +758,7 @@ void ATM90E32Component::save_gain_calibration_to_memory_() {
}
void ATM90E32Component::save_offset_calibration_to_memory_() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
bool success = this->offset_pref_.save(&this->offset_phase_);
global_preferences->sync();
if (success) {
@@ -705,8 +774,7 @@ void ATM90E32Component::save_offset_calibration_to_memory_() {
}
void ATM90E32Component::save_power_offset_calibration_to_memory_() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
bool success = this->power_offset_pref_.save(&this->power_offset_phase_);
global_preferences->sync();
if (success) {
@@ -722,8 +790,7 @@ void ATM90E32Component::save_power_offset_calibration_to_memory_() {
}
void ATM90E32Component::run_offset_calibrations() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
if (!this->enable_offset_calibration_) {
ESP_LOGW(TAG,
"[CALIBRATION][%s] Offset calibration is disabled! Enable it first with enable_offset_calibration: true",
@@ -753,8 +820,7 @@ void ATM90E32Component::run_offset_calibrations() {
}
void ATM90E32Component::run_power_offset_calibrations() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
if (!this->enable_offset_calibration_) {
ESP_LOGW(
TAG,
@@ -827,15 +893,16 @@ void ATM90E32Component::write_power_offsets_to_registers_(uint8_t phase, int16_t
}
void ATM90E32Component::restore_gain_calibrations_() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
for (uint8_t i = 0; i < 3; ++i) {
this->config_gain_phase_[i].voltage_gain = this->phase_[i].voltage_gain_;
this->config_gain_phase_[i].current_gain = this->phase_[i].ct_gain_;
this->gain_phase_[i] = this->config_gain_phase_[i];
}
if (this->gain_calibration_pref_.load(&this->gain_phase_)) {
bool have_data = this->gain_calibration_pref_.load(&this->gain_phase_);
if (have_data) {
bool all_zero = true;
bool same_as_config = true;
for (uint8_t phase = 0; phase < 3; ++phase) {
@@ -882,12 +949,12 @@ void ATM90E32Component::restore_gain_calibrations_() {
}
void ATM90E32Component::restore_offset_calibrations_() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
for (uint8_t i = 0; i < 3; ++i)
this->config_offset_phase_[i] = this->offset_phase_[i];
bool have_data = this->offset_pref_.load(&this->offset_phase_);
bool all_zero = true;
if (have_data) {
for (auto &phase : this->offset_phase_) {
@@ -925,12 +992,12 @@ void ATM90E32Component::restore_offset_calibrations_() {
}
void ATM90E32Component::restore_power_offset_calibrations_() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
for (uint8_t i = 0; i < 3; ++i)
this->config_power_offset_phase_[i] = this->power_offset_phase_[i];
bool have_data = this->power_offset_pref_.load(&this->power_offset_phase_);
bool all_zero = true;
if (have_data) {
for (auto &phase : this->power_offset_phase_) {
@@ -968,8 +1035,7 @@ void ATM90E32Component::restore_power_offset_calibrations_() {
}
void ATM90E32Component::clear_gain_calibrations() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
if (!this->using_saved_calibrations_) {
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored gain calibrations to clear. Current values:", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] ----------------------------------------------------------", cs);
@@ -1018,8 +1084,7 @@ void ATM90E32Component::clear_gain_calibrations() {
}
void ATM90E32Component::clear_offset_calibrations() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
if (!this->restored_offset_calibration_) {
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored offset calibrations to clear. Current values:", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] --------------------------------------------------------------", cs);
@@ -1061,8 +1126,7 @@ void ATM90E32Component::clear_offset_calibrations() {
}
void ATM90E32Component::clear_power_offset_calibrations() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
if (!this->restored_power_offset_calibration_) {
ESP_LOGI(TAG, "[CALIBRATION][%s] No stored power offsets to clear. Current values:", cs);
ESP_LOGI(TAG, "[CALIBRATION][%s] ---------------------------------------------------------------------", cs);
@@ -1137,8 +1201,7 @@ int16_t ATM90E32Component::calibrate_power_offset(uint8_t phase, bool reactive)
}
bool ATM90E32Component::verify_gain_writes_() {
char cs[GPIO_SUMMARY_MAX_LEN];
this->get_cs_summary_(cs);
const char *cs = this->get_calibration_id_();
bool success = true;
for (uint8_t phase = 0; phase < 3; phase++) {
uint16_t read_voltage = this->read16_(voltage_gain_registers[phase]);
+3
View File
@@ -102,6 +102,7 @@ class ATM90E32Component : public PollingComponent,
void clear_gain_calibrations();
void set_enable_offset_calibration(bool flag) { enable_offset_calibration_ = flag; }
void set_enable_gain_calibration(bool flag) { enable_gain_calibration_ = flag; }
void set_instance_id(const char *id) { instance_id_ = id; }
int16_t calibrate_offset(uint8_t phase, bool voltage);
int16_t calibrate_power_offset(uint8_t phase, bool reactive);
void run_gain_calibrations();
@@ -183,6 +184,7 @@ class ATM90E32Component : public PollingComponent,
bool verify_gain_writes_();
bool validate_spi_read_(uint16_t expected, const char *context = nullptr);
void log_calibration_status_();
const char *get_calibration_id_();
void get_cs_summary_(std::span<char, GPIO_SUMMARY_MAX_LEN> buffer);
struct ATM90E32Phase {
@@ -263,6 +265,7 @@ class ATM90E32Component : public PollingComponent,
bool peak_current_signed_{false};
bool enable_offset_calibration_{false};
bool enable_gain_calibration_{false};
const char *instance_id_{nullptr};
bool restored_offset_calibration_{false};
bool restored_power_offset_calibration_{false};
bool restored_gain_calibration_{false};
+1
View File
@@ -193,6 +193,7 @@ CONFIG_SCHEMA = (
async def to_code(config):
var = cg.new_Pvariable(config[CONF_ID])
cg.add(var.set_instance_id(str(config[CONF_ID])))
await cg.register_component(var, config)
await spi.register_spi_device(var, config)
+8 -5
View File
@@ -64,8 +64,7 @@ class AudioData:
flac_support: bool = False
mp3_support: bool = False
opus_support: bool = False
# WAV defaults to True for backward compatibility; will become opt-in in a future release
wav_support: bool = True
wav_support: bool = False
micro_decoder_support: bool = False
flac: FlacOptions = field(default_factory=FlacOptions)
mp3: Mp3Options = field(default_factory=Mp3Options)
@@ -335,7 +334,7 @@ async def to_code(config):
add_idf_component(
name="esphome/esp-audio-libs",
ref="2.0.4",
ref="3.0.0",
)
data = _get_data()
@@ -387,7 +386,7 @@ async def to_code(config):
# 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")
add_idf_component(name="esphome/micro-flac", ref="0.2.0")
_emit_memory_pair(
data.flac.buffer_memory,
"CONFIG_MICRO_FLAC_PREFER_PSRAM",
@@ -395,6 +394,7 @@ async def to_code(config):
)
if data.mp3_support:
cg.add_define("USE_AUDIO_MP3_SUPPORT")
add_idf_component(name="esphome/micro-mp3", ref="0.2.0")
_emit_memory_pair(
data.mp3.buffer_memory,
"CONFIG_MP3_DECODER_PREFER_PSRAM",
@@ -402,7 +402,7 @@ async def to_code(config):
)
if data.opus_support:
cg.add_define("USE_AUDIO_OPUS_SUPPORT")
add_idf_component(name="esphome/micro-opus", ref="0.4.0")
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
@@ -427,3 +427,6 @@ async def to_code(config):
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")
+6
View File
@@ -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;
+2
View File
@@ -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 {
+85 -105
View File
@@ -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
+16 -15
View File
@@ -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};
};
+47 -36
View File
@@ -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
+2 -4
View File
@@ -148,7 +148,7 @@ 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)
@@ -282,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)
+9 -13
View File
@@ -10,11 +10,6 @@ namespace esphome::binary_sensor {
static const char *const TAG = "sensor.filter";
// AutorepeatFilter still inherits Component (it schedules two distinct timer
// purposes), so it keeps the (Component *, id) scheduler API.
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);
@@ -69,6 +64,10 @@ optional<bool> DelayedOffFilter::new_value(bool value) {
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)
@@ -76,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;
}
@@ -85,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_++;
@@ -98,12 +96,10 @@ 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); }
+3 -2
View File
@@ -84,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;
+2 -17
View File
@@ -161,24 +161,9 @@ void BL0942::received_package_(DataPacket *data) {
return;
}
// cf_cnt is only 24 bits, so track overflows
// cf_cnt wraps at 24 bits; total_increasing on the energy sensor handles the
// wrap (and any spurious chip resets) downstream.
uint32_t cf_cnt = (uint24_t) data->cf_cnt;
uint32_t prev_cf_cnt_24 = this->prev_cf_cnt_ & 0x00ffffff;
cf_cnt |= this->prev_cf_cnt_ & 0xff000000;
if (cf_cnt < this->prev_cf_cnt_) {
if (prev_cf_cnt_24 > 0x800000) {
// Previous value was in the upper half of the 24-bit range,
// so this is likely a genuine overflow from 0xFFFFFF to 0x000000.
cf_cnt += 0x1000000;
} else {
// Previous value was low, so the BL0942 chip likely reset its
// counter (e.g. due to electrical disturbance). Don't treat as
// overflow — just continue from the new value to avoid a ~3MWh jump.
ESP_LOGW(TAG, "BL0942 energy counter unexpectedly decreased from %" PRIu32 " to %" PRIu32 ", assuming chip reset",
this->prev_cf_cnt_, cf_cnt);
}
}
this->prev_cf_cnt_ = cf_cnt;
float v_rms = (uint24_t) data->v_rms / voltage_reference_;
float i_rms = (uint24_t) data->i_rms / current_reference_;
-1
View File
@@ -141,7 +141,6 @@ class BL0942 : public PollingComponent, public uart::UARTDevice {
bool reset_ = false;
LineFrequency line_freq_ = LINE_FREQUENCY_50HZ;
optional<uint32_t> rx_start_{};
uint32_t prev_cf_cnt_ = 0;
bool validate_checksum_(DataPacket *data);
int read_reg_(uint8_t reg);
+3 -2
View File
@@ -2,11 +2,12 @@
CODEOWNERS = ["@esphome/core"]
CONF_BYTE_ORDER = "byte_order"
CONF_CLIMATE_ID = "climate_id"
BYTE_ORDER_LITTLE = "little_endian"
BYTE_ORDER_BIG = "big_endian"
CONF_B_CONSTANT = "b_constant"
CONF_BYTE_ORDER = "byte_order"
CONF_CLIMATE_ID = "climate_id"
CONF_COLOR_DEPTH = "color_depth"
CONF_CRC_ENABLE = "crc_enable"
CONF_DATA_BITS = "data_bits"
+15 -4
View File
@@ -328,17 +328,28 @@ async def build_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.
The trigger arg types are wrapped as `const T &` to match the
`void (*)(..., const Ts &...)` ApplyFn signature.
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, args, return_type=field.type_)
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))
@@ -346,7 +357,7 @@ async def build_apply_lambda_action(
apply_args = [
*prefix_args,
*((t.operator("const").operator("ref"), n) for t, n in args),
*normalized_args,
]
apply_lambda = LambdaExpression(
["\n".join(body_lines)],
+9 -2
View File
@@ -51,10 +51,17 @@ template<typename... Ts> class ToggleAction : public Action<Ts...> {
// 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:
using ApplyFn = void (*)(CoverCall &, const Ts &...);
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 {
@@ -70,7 +77,7 @@ template<typename... Ts> class ControlAction : public Action<Ts...> {
template<typename... Ts> class CoverPublishAction : public Action<Ts...> {
public:
using ApplyFn = void (*)(Cover *, const Ts &...);
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 {
@@ -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 += ' ';
+88
View File
@@ -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)",
@@ -2503,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
+127
View File
@@ -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",
@@ -416,3 +429,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
@@ -117,8 +117,8 @@ void ESPHomeOTAComponent::dump_config() {
" 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) {
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, nullptr);
while (it != nullptr) {
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);
@@ -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)
@@ -13,22 +13,16 @@
#include "esp_timer.h"
// esp-audio-libs
#include <gain.h>
namespace esphome::i2s_audio {
static const char *const TAG = "i2s_audio.speaker";
// Lists the Q15 fixed point scaling factor for volume reduction.
// Has 100 values representing silence and a reduction [49, 48.5, ... 0.5, 0] dB.
// dB to PCM scaling factor formula: floating_point_scale_factor = 2^(-db/6.014)
// float to Q15 fixed point formula: q15_scale_factor = floating_point_scale_factor * 2^(15)
static const std::vector<int16_t> Q15_VOLUME_SCALING_FACTORS = {
0, 116, 122, 130, 137, 146, 154, 163, 173, 183, 194, 206, 218, 231, 244,
259, 274, 291, 308, 326, 345, 366, 388, 411, 435, 461, 488, 517, 548, 580,
615, 651, 690, 731, 774, 820, 868, 920, 974, 1032, 1094, 1158, 1227, 1300, 1377,
1459, 1545, 1637, 1734, 1837, 1946, 2061, 2184, 2313, 2450, 2596, 2750, 2913, 3085, 3269,
3462, 3668, 3885, 4116, 4360, 4619, 4893, 5183, 5490, 5816, 6161, 6527, 6914, 7324, 7758,
8218, 8706, 9222, 9770, 10349, 10963, 11613, 12302, 13032, 13805, 14624, 15491, 16410, 17384, 18415,
19508, 20665, 21891, 23189, 24565, 26022, 27566, 29201, 30933, 32767};
// Software volume control maps the user-facing [0.0, 1.0] range to a Q31 scale factor.
// Volumes in (0.0, 1.0) map linearly to a dB reduction in [-49.0, 0.0] dB.
static constexpr float SOFTWARE_VOLUME_MIN_DB = -49.0f;
void I2SAudioSpeakerBase::setup() {
this->event_group_ = xEventGroupCreate();
@@ -147,14 +141,16 @@ void I2SAudioSpeakerBase::set_volume(float volume) {
} else
#endif // USE_AUDIO_DAC
{
// Fallback to software volume control by using a Q15 fixed point scaling factor.
// At maximum volume (1.0), set to INT16_MAX to completely bypass volume processing
// Fallback to software volume control by using a Q31 fixed point scaling factor.
// At maximum volume (1.0), set to INT32_MAX to bypass volume processing entirely
// and avoid any floating-point precision issues that could cause slight volume reduction.
if (volume >= 1.0f) {
this->q15_volume_factor_ = INT16_MAX;
this->q31_volume_factor_ = INT32_MAX;
} else if (volume <= 0.0f) {
this->q31_volume_factor_ = 0;
} else {
ssize_t decibel_index = remap<ssize_t, float>(volume, 0.0f, 1.0f, 0, Q15_VOLUME_SCALING_FACTORS.size() - 1);
this->q15_volume_factor_ = Q15_VOLUME_SCALING_FACTORS[decibel_index];
this->q31_volume_factor_ =
esp_audio_libs::gain::db_to_q31(remap<float, float>(volume, 0.0f, 1.0f, SOFTWARE_VOLUME_MIN_DB, 0.0f));
}
}
}
@@ -173,7 +169,7 @@ void I2SAudioSpeakerBase::set_mute_state(bool mute_state) {
{
if (mute_state) {
// Fallback to software volume control and scale by 0
this->q15_volume_factor_ = 0;
this->q31_volume_factor_ = 0;
} else {
// Revert to previous volume when unmuting
this->set_volume(this->volume_);
@@ -309,29 +305,14 @@ bool IRAM_ATTR I2SAudioSpeakerBase::i2s_on_sent_cb(i2s_chan_handle_t handle, i2s
}
void I2SAudioSpeakerBase::apply_software_volume_(uint8_t *data, size_t bytes_read) {
if (this->q15_volume_factor_ >= INT16_MAX) {
if (this->q31_volume_factor_ == INT32_MAX) {
return; // Max volume, no processing needed
}
const size_t bytes_per_sample = this->current_stream_info_.samples_to_bytes(1);
const uint32_t len = bytes_read / bytes_per_sample;
// Use Q16 for samples with 1 or 2 bytes: shifted_sample * gain_factor is Q16 * Q15 -> Q31
int32_t shift = 15; // Q31 -> Q16
int32_t gain_factor = this->q15_volume_factor_; // Q15
if (bytes_per_sample >= 3) {
// Use Q23 for samples with 3 or 4 bytes: shifted_sample * gain_factor is Q23 * Q8 -> Q31
shift = 8; // Q31 -> Q23
gain_factor >>= 7; // Q15 -> Q8
}
for (uint32_t i = 0; i < len; ++i) {
int32_t sample = audio::unpack_audio_sample_to_q31(&data[i * bytes_per_sample], bytes_per_sample); // Q31
sample >>= shift;
sample *= gain_factor; // Q31
audio::pack_q31_as_audio_sample(sample, &data[i * bytes_per_sample], bytes_per_sample);
}
esp_audio_libs::gain::apply(data, data, this->q31_volume_factor_, len, bytes_per_sample);
}
void I2SAudioSpeakerBase::swap_esp32_mono_samples_(uint8_t *data, size_t bytes_read) {
@@ -151,7 +151,7 @@ class I2SAudioSpeakerBase : public I2SAudioOut, public speaker::Speaker, public
bool pause_state_{false};
int16_t q15_volume_factor_{INT16_MAX};
int32_t q31_volume_factor_{INT32_MAX};
audio::AudioStreamInfo current_stream_info_; // The currently loaded driver's stream info
@@ -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;
+1 -2
View File
@@ -1,5 +1,6 @@
import esphome.codegen as cg
from esphome.components import i2c, sensor
from esphome.components.const import CONF_B_CONSTANT
import esphome.config_validation as cv
from esphome.const import (
CONF_BATTERY_LEVEL,
@@ -22,8 +23,6 @@ DEPENDENCIES = ["i2c"]
lc709203f_ns = cg.esphome_ns.namespace("lc709203f")
CONF_B_CONSTANT = "b_constant"
LC709203FBatteryVoltage = lc709203f_ns.enum("LC709203FBatteryVoltage")
BATTERY_VOLTAGE_OPTIONS = {
"3.7": LC709203FBatteryVoltage.LC709203F_BATTERY_VOLTAGE_3_7,
+12
View File
@@ -156,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)",
+8
View File
@@ -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"
+7 -2
View File
@@ -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();
}
}
}
+8
View File
@@ -309,6 +309,14 @@ LV_EVENT_MAP = {
"STYLE_CHANGE": "STYLE_CHANGED",
"TRIPLE_CLICK": "TRIPLE_CLICKED",
}
LV_PRESS_EVENTS = ("PRESS", "PRESSING", "RELEASE")
def is_press_event(event: str) -> bool:
return event.removeprefix("on_").upper() in LV_PRESS_EVENTS
LV_SCREEN_EVENT_MAP = {
"SCREEN_LOAD": "SCREEN_LOADED",
"SCREEN_LOAD_START": "SCREEN_LOAD_START",
+2 -2
View File
@@ -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)})"),
)
+15 -1
View File
@@ -890,7 +890,21 @@ lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos) {
int32_t offset = pos - stop1->frac;
return lv_color_mix(stop2->color, stop1->color, range == 0 ? 0 : (offset * 255) / range);
}
#endif
#endif // USE_LVGL_GRADIENT
lv_point_t LvglComponent::get_touch_relative_to_obj(lv_obj_t *obj) {
auto *indev = lv_indev_get_act();
if (indev == nullptr) {
return {INT32_MAX, INT32_MAX};
}
lv_point_t point;
lv_indev_get_point(indev, &point);
lv_area_t coords;
lv_obj_get_coords(obj, &coords);
point.x -= coords.x1;
point.y -= coords.y1;
return point;
}
static void lv_container_constructor(const lv_obj_class_t *class_p, lv_obj_t *obj) {
LV_TRACE_OBJ_CREATE("begin");
+13 -6
View File
@@ -6,7 +6,7 @@
#endif // USE_BINARY_SENSOR
#ifdef USE_IMAGE
#include "esphome/components/image/image.h"
#endif // USE_LVGL_IMAGE
#endif // USE_IMAGE
#ifdef USE_LVGL_ROTARY_ENCODER
#include "esphome/components/rotary_encoder/rotary_encoder.h"
#endif // USE_LVGL_ROTARY_ENCODER
@@ -32,10 +32,10 @@
#ifdef USE_FONT
#include "esphome/components/font/font.h"
#endif // USE_LVGL_FONT
#endif // USE_FONT
#ifdef USE_TOUCHSCREEN
#include "esphome/components/touchscreen/touchscreen.h"
#endif // USE_LVGL_TOUCHSCREEN
#endif // USE_TOUCHSCREEN
#if defined(USE_LVGL_BUTTONMATRIX) || defined(USE_LVGL_KEYBOARD)
#include "esphome/components/key_provider/key_provider.h"
@@ -124,7 +124,8 @@ int16_t lv_get_needle_angle_for_value(lv_obj_t *obj, int32_t value);
*/
lv_color_t lv_grad_calculate_color(const lv_grad_dsc_t *dsc, int32_t pos);
#endif
#endif // USE_LVGL_GRADIENT
// Parent class for things that wrap an LVGL object
class LvCompound {
public:
@@ -169,9 +170,9 @@ template<typename... Ts> class ObjUpdateAction : public Action<Ts...> {
public:
explicit ObjUpdateAction(std::function<void(Ts...)> &&lamb) : lamb_(std::move(lamb)) {}
protected:
void play(const Ts &...x) override { this->lamb_(x...); }
protected:
std::function<void(Ts...)> lamb_;
};
#ifdef USE_LVGL_ANIMIMG
@@ -190,6 +191,12 @@ class LvglComponent : public PollingComponent {
LvglComponent(std::vector<display::Display *> displays, float buffer_frac, bool full_refresh, int draw_rounding,
bool resume_on_input, bool update_when_display_idle, RotationType rotation_type);
static void static_flush_cb(lv_display_t *disp_drv, const lv_area_t *area, uint8_t *color_p);
/**
*
* @param obj A widget
* @return The position of the last indev point relative to the widget's origin.
*/
static lv_point_t get_touch_relative_to_obj(lv_obj_t *obj);
float get_setup_priority() const override { return setup_priority::PROCESSOR; }
void setup() override;
@@ -311,9 +318,9 @@ class IdleTrigger : public Trigger<> {
template<typename... Ts> class LvglAction : public Action<Ts...>, public Parented<LvglComponent> {
public:
explicit LvglAction(std::function<void(LvglComponent *)> &&lamb) : action_(std::move(lamb)) {}
void play(const Ts &...x) override { this->action_(this->parent_); }
protected:
void play(const Ts &...x) override { this->action_(this->parent_); }
std::function<void(LvglComponent *)> action_{};
};
+19 -6
View File
@@ -31,6 +31,7 @@ from .defines import (
CONF_TIME_FORMAT,
LV_GRAD_DIR,
get_remapped_uses,
is_press_event,
)
from .helpers import CONF_IF_NAN, requires_component, validate_printf
from .layout import (
@@ -46,6 +47,7 @@ from .types import (
LvType,
lv_group_t,
lv_obj_t,
lv_point_t,
lv_pseudo_button_t,
lv_style_t,
)
@@ -123,8 +125,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 +139,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
@@ -366,13 +372,20 @@ def automation_schema(typ: LvType):
if typ.has_on_value:
events = events + (CONF_ON_VALUE,)
args = typ.get_arg_type()
args.append(lv_event_t_ptr)
def get_trigger_args(event):
result = args.copy()
if is_press_event(event):
result.append(lv_point_t)
result.append(lv_event_t_ptr)
return result
return {
**{
cv.Optional(event): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
Trigger.template(*args)
Trigger.template(*get_trigger_args(event))
),
}
)
+11 -6
View File
@@ -24,6 +24,7 @@ from .defines import (
LV_SCREEN_EVENT_MAP,
LV_SCREEN_EVENT_TRIGGERS,
SWIPE_TRIGGERS,
is_press_event,
literal,
)
from .lvcode import (
@@ -34,11 +35,10 @@ from .lvcode import (
LvConditional,
lv,
lv_add,
lv_event_t_ptr,
lv_expr,
lvgl_static,
)
from .types import LV_EVENT
from .types import LV_EVENT, lv_point_t
from .widgets import LvScrActType, get_screen_active, widget_map
@@ -133,19 +133,24 @@ def _get_event_literal(trigger: str | MockObj) -> MockObj:
return literal("LV_EVENT_" + TRIGGER_MAP[trigger.upper()])
async def add_trigger(conf, w, *events, is_selected=None):
async def add_trigger(conf, w, *events: str | MockObj, is_selected=None):
is_selected = is_selected or w.is_selected()
tid = conf[CONF_TRIGGER_ID]
trigger = cg.new_Pvariable(tid)
args = w.get_args() + [(lv_event_t_ptr, "event")]
value = w.get_values()
args = w.get_args()
value: list = w.get_values()
if len(events) == 1 and is_press_event(str(events[0])):
# Make the touch point available for selected events
args.append((lv_point_t, "point"))
value.append(lvgl_static.get_touch_relative_to_obj(w.obj))
args.extend(EVENT_ARG)
await automation.build_automation(trigger, args, conf)
async with LambdaContext(EVENT_ARG, where=tid) as context:
with LvConditional(is_selected):
lv_add(trigger.trigger(*value, literal("event")))
callback = await context.get_lambda()
event_literals = [_get_event_literal(event) for event in events]
if isinstance(events[0], str) and events[0] in DISPLAY_TRIGGERS:
if str(events[0]) in DISPLAY_TRIGGERS:
assert len(events) == 1
lv.display_add_event_cb(
lv_expr.obj_get_display(w.obj), callback, event_literals[0], nullptr
+2
View File
@@ -70,6 +70,8 @@ lv_image_t = LvType("lv_image_t")
lv_gradient_t = LvType("lv_grad_dsc_t")
lv_event_t = LvType("lv_event_t")
RotationType = lvgl_ns.enum("RotationType")
lv_point_t = cg.global_ns.struct("lv_point_t")
lv_point_precise_t = cg.global_ns.struct("lv_point_precise_t")
LV_EVENT = MockObj(base="LV_EVENT_", op="")
LV_STATE = MockObj(base="LV_STATE_", op="")
+1 -1
View File
@@ -366,7 +366,7 @@ class Widget:
def get_args(self):
if isinstance(self.type.w_type, LvType):
return self.type.w_type.args
return self.type.w_type.args.copy()
return [(lv_obj_t_ptr, "obj")]
def get_value(self):
+19 -6
View File
@@ -52,14 +52,14 @@ from ..lv_validation import (
lv_text,
opacity,
pixels,
pixels_or_percent,
size,
)
from ..lvcode import LocalVariable, lv, lv_assign, lv_expr
from ..schemas import STYLE_PROPS, TEXT_SCHEMA, point_schema, remap_property
from ..types import LvType, ObjUpdateAction
from ..types import LvType, ObjUpdateAction, lv_point_precise_t
from . import Widget, WidgetType, get_widgets
from .img import CONF_IMAGE
from .line import lv_point_precise_t, process_coord
CONF_CANVAS = "canvas"
CONF_BUFFER_ID = "buffer_id"
@@ -434,6 +434,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 +451,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 +480,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
+7 -14
View File
@@ -1,27 +1,17 @@
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"
CONF_POINTS = "points"
CONF_POINT_LIST_ID = "point_list_id"
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):
@@ -36,7 +26,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))
@@ -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
+1
View File
@@ -641,6 +641,7 @@ void Nextion::process_nextion_commands_() {
} else {
ESP_LOGN(TAG, "String resp: '%s' id: %s type: %s", to_process.c_str(), component->get_variable_name().c_str(),
component->get_queue_type_string());
component->set_state_from_string(to_process, true, false);
}
delete nb; // NOLINT(cppcoreguidelines-owning-memory)
+6 -3
View File
@@ -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"),
],
}
+1 -1
View File
@@ -2,6 +2,7 @@ from math import log
import esphome.codegen as cg
from esphome.components import sensor
from esphome.components.const import CONF_B_CONSTANT
import esphome.config_validation as cv
from esphome.const import (
CONF_CALIBRATION,
@@ -18,7 +19,6 @@ from esphome.const import (
ntc_ns = cg.esphome_ns.namespace("ntc")
NTC = ntc_ns.class_("NTC", cg.Component, sensor.Sensor)
CONF_B_CONSTANT = "b_constant"
CONF_A = "a"
CONF_B = "b"
CONF_C = "c"
@@ -20,8 +20,7 @@ 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.
// Reject any size other than ESP_PARTITION_TABLE_MAX_LEN
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;
@@ -11,6 +11,7 @@
#include <esp_ota_ops.h>
#include <nvs_flash.h>
#include <cinttypes>
#include <cstring>
namespace esphome::ota {
@@ -135,10 +136,20 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a
// 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);
"The new partition table must contain a compatible app partition with:\n"
" size: at least %" PRIu32 " bytes (0x%" PRIX32 ")\n"
" address: one of",
(uint32_t) running_app_size, (uint32_t) running_app_size);
esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_ANY, nullptr);
while (it != nullptr) {
const esp_partition_t *partition = esp_partition_get(it);
if (partition->size >= running_app_size) {
ESP_LOGE(TAG, " 0x%" PRIX32, partition->address);
}
it = esp_partition_next(it);
}
esp_partition_iterator_release(it);
ESP_LOGE(TAG, "Upload a different partition table. No flash content was modified.");
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
}
if (app_partitions_found < 2) {
@@ -154,11 +165,11 @@ OTAResponseTypes IDFOTABackend::validate_new_partition_table_(uint32_t running_a
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
}
if (otadata_overlap) {
// Unlikely, the otadata partition is before the start of the first app partition in most cases
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);
"New otadata partition overlaps with the running app at address: 0x%" PRIX32 ", running app size: %" PRIu32
" bytes",
running_app_offset, (uint32_t) running_app_size);
return OTA_RESPONSE_ERROR_PARTITION_TABLE_VERIFY;
}
@@ -198,8 +209,8 @@ OTAResponseTypes IDFOTABackend::update_partition_table() {
// 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.");
" Loss of power during this operation may render the device\n"
" unable to boot until 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.
@@ -214,7 +225,7 @@ OTAResponseTypes IDFOTABackend::update_partition_table() {
// 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);
ESP_LOGE(TAG, "Cannot resolve running app partition at address 0x%" PRIX32, running_app_offset);
return OTA_RESPONSE_ERROR_PARTITION_TABLE_UPDATE;
}
ESP_LOGD(TAG, "Copying running app from 0x%X to 0x%X (size: 0x%X)", running_app_part->address,
+108 -10
View File
@@ -414,25 +414,39 @@ def _substitute_package_definition(
def _update_substitutions_context(
parent_context: UserDict,
package_substitutions: dict[str, Any],
eval_context: ContextVars | None = None,
) -> None:
"""Resolve and add new substitutions to the parent context.
Skips keys already present (higher-priority sources win).
String values are substituted against the current context so that
cross-references between substitutions are expanded when possible.
String values are substituted against *eval_context* (or *parent_context*
if not provided) so that cross-references between substitutions are
expanded when possible. Resolved values are written into *parent_context*
and back into *package_substitutions* so that subsequent merges into the
consolidated ``substitutions:`` block carry the resolved value (the
package's ``!include vars`` are no longer in scope after this function
returns).
*eval_context* may layer additional vars (e.g. a package's own ``!include
vars``) on top of *parent_context* so that a package's substitutions can
reference vars passed in by the parent file.
"""
if eval_context is None:
eval_context = ContextVars(parent_context)
for key, value in package_substitutions.items():
if key in parent_context:
continue
if not isinstance(value, str):
parent_context[key] = value
continue
parent_context[key] = substitute(
resolved = substitute(
item=value,
path=[CONF_SUBSTITUTIONS, key],
parent_context=ContextVars(parent_context),
parent_context=eval_context,
strict_undefined=False,
)
parent_context[key] = resolved
package_substitutions[key] = resolved
class _PackageProcessor:
@@ -508,11 +522,36 @@ class _PackageProcessor:
package_config = _process_remote_package(package_config)
return package_config
def collect_substitutions(self, package_config: dict) -> None:
"""Extract substitutions from a package and merge into the shared context."""
def collect_substitutions(
self,
package_config: dict,
context_vars: ContextVars | None,
) -> ContextVars:
"""Extract substitutions from a package and merge into the shared context.
Returns the context updated with the package's ``!include vars`` (or
an equivalent of *context_vars* if the package has none) so the caller
can reuse it when recursing into nested packages. ``None`` inputs are
normalized to an empty :class:`ContextVars`, so the result is always
non-``None``.
"""
# Push the package's own !include vars before evaluating its
# substitutions so they can reference vars passed in by the parent
# (e.g. ``vars: {my_variable: ...}`` on the include entry).
package_context = push_context(
package_config, context_vars if context_vars is not None else ContextVars()
)
if subs := package_config.pop(CONF_SUBSTITUTIONS, {}):
# Resolve before merging so that values referencing the package's
# ``!include vars`` are baked into the consolidated substitutions
# block; once we return, the package vars are no longer in scope.
# ``package_context`` is a ChainMap whose chain already terminates
# in ``self.parent_context`` (set up by ``do_packages_pass``), so
# ``parent_context`` mutations from ``_update_substitutions_context``
# remain visible to evaluation reads.
_update_substitutions_context(self.parent_context, subs, package_context)
self.substitutions.data = merge_config(subs, self.substitutions.data)
_update_substitutions_context(self.parent_context, subs)
return package_context
def process_package(
self,
@@ -525,13 +564,13 @@ class _PackageProcessor:
package_config
)
package_config = self.resolve_package(package_config, context_vars, path)
self.collect_substitutions(package_config)
context_vars = self.collect_substitutions(package_config, context_vars)
if CONF_PACKAGES not in package_config:
return package_config
# Push context from !include vars on the package root and on the packages key
context_vars = push_context(package_config, context_vars)
# Push context from !include vars on the packages key (the package root
# was already pushed in collect_substitutions above).
context_vars = push_context(package_config[CONF_PACKAGES], context_vars)
# Disable the deprecated single-package fallback for remote
# packages. _process_remote_package returns dicts with
@@ -611,3 +650,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)
+12
View File
@@ -75,6 +75,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",
+40 -9
View File
@@ -4,6 +4,7 @@ import math
from esphome import automation
import esphome.codegen as cg
from esphome.components import mqtt, web_server, zigbee
from esphome.components.const import CONF_B_CONSTANT
import esphome.config_validation as cv
from esphome.const import (
CONF_ABOVE,
@@ -32,6 +33,8 @@ from esphome.const import (
CONF_OPTIMISTIC,
CONF_PERIOD,
CONF_QUANTILE,
CONF_REFERENCE_RESISTANCE,
CONF_REFERENCE_TEMPERATURE,
CONF_SEND_EVERY,
CONF_SEND_FIRST_AT,
CONF_STATE_CLASS,
@@ -1078,16 +1081,44 @@ def ntc_get_abc(value):
return a, b, c
def ntc_calc_b_constant(value):
beta = value[CONF_B_CONSTANT]
t0 = value[CONF_REFERENCE_TEMPERATURE] + ZERO_POINT
r0 = value[CONF_REFERENCE_RESISTANCE]
a = (1 / t0) - (1 / beta) * math.log(r0)
b = 1 / beta
c = 0
return a, b, c
def ntc_process_calibration(value):
if isinstance(value, dict):
value = cv.Schema(
{
cv.Required(CONF_A): cv.float_,
cv.Required(CONF_B): cv.float_,
cv.Required(CONF_C): cv.float_,
}
)(value)
a, b, c = ntc_get_abc(value)
if CONF_B_CONSTANT in value:
value = cv.Schema(
{
cv.Required(CONF_B_CONSTANT): cv.All(
cv.float_, cv.Range(min=0, min_included=False)
),
cv.Required(CONF_REFERENCE_TEMPERATURE): cv.All(
cv.temperature,
cv.Range(min=-ZERO_POINT, min_included=False),
),
cv.Required(CONF_REFERENCE_RESISTANCE): cv.All(
cv.resistance, cv.Range(min=0, min_included=False)
),
}
)(value)
a, b, c = ntc_calc_b_constant(value)
else:
value = cv.Schema(
{
cv.Required(CONF_A): cv.float_,
cv.Required(CONF_B): cv.float_,
cv.Required(CONF_C): cv.float_,
}
)(value)
a, b, c = ntc_get_abc(value)
elif isinstance(value, list):
if len(value) != 3:
raise cv.Invalid(
@@ -1097,7 +1128,7 @@ def ntc_process_calibration(value):
a, b, c = ntc_calc_steinhart_hart(value)
else:
raise cv.Invalid(
f"Calibration parameter accepts either a list for steinhart-hart calibration, or mapping for b-constant calibration, not {type(value)}"
f"Calibration parameter accepts either a list for steinhart-hart calibration, or mapping for b-constant or precomputed (a, b, c) calibration, not {type(value)}"
)
_LOGGER.info("Coefficient: a:%s, b:%s, c:%s", a, b, c)
return {
+25 -1
View File
@@ -413,7 +413,31 @@ class ThrottleWithPriorityNanFilter : public Filter {
uint32_t min_time_between_inputs_;
};
// Base class for timeout filters - contains common loop logic
// Base class for timeout filters - contains common loop logic.
//
// Why this intentionally inherits Component (and does NOT use the self-keyed
// `App.scheduler.set_timeout(this, ...)` pattern that the other Filter classes
// migrated to):
//
// Timeout filters re-arm on every input, so on devices with many sensors
// using timeout filters (e.g. multi-LD2450 boards) every armed filter would
// require a live SchedulerItem in RAM at the same time. A SchedulerItem is
// substantially larger than the Component bookkeeping bytes carried by this
// class, so paying the Component cost per filter (one-time, BSS) is cheaper
// than paying for a SchedulerItem per filter (live, while armed). #11922
// is the original symptom and switchover to the loop-based design; #16173
// attempted to migrate this onto the scheduler and was closed for exactly
// this reason — even if the scheduler pool were unbounded, RAM per armed
// filter would still be dominated by the SchedulerItem itself, not by
// anything we can shrink in the scheduler.
//
// The loop-based design has additional advantages on top of the RAM win:
// `enable_loop()` / `disable_loop()` partitions the cost away when no
// timeout is armed; while armed, work is a single timestamp compare per
// active filter, with no per-input scheduler cancel/insert path.
//
// Don't try to migrate this class onto the self-keyed scheduler. The math
// doesn't work — at scale, this design is the smaller one.
class TimeoutFilterBase : public Filter, public Component {
public:
void loop() override;
@@ -1,13 +1,19 @@
"""Speaker Media Player Setup."""
from functools import partial
import hashlib
import logging
from pathlib import Path
from esphome import automation, external_files
from esphome import automation
import esphome.codegen as cg
from esphome.components import audio, esp32, media_player, network, ota, psram, speaker
from esphome.components import (
audio,
audio_file,
esp32,
media_player,
network,
ota,
psram,
speaker,
)
from esphome.components.const import (
CONF_VOLUME_INCREMENT,
CONF_VOLUME_INITIAL,
@@ -17,23 +23,16 @@ from esphome.components.const import (
import esphome.config_validation as cv
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_FILE,
CONF_FILES,
CONF_FORMAT,
CONF_ID,
CONF_NUM_CHANNELS,
CONF_ON_TURN_OFF,
CONF_ON_TURN_ON,
CONF_PATH,
CONF_RAW_DATA_ID,
CONF_SAMPLE_RATE,
CONF_SPEAKER,
CONF_TASK_STACK_IN_PSRAM,
CONF_TYPE,
CONF_URL,
)
from esphome.core import CORE, HexInt
from esphome.external_files import download_web_files_in_config
_LOGGER = logging.getLogger(__name__)
@@ -44,9 +43,6 @@ DEPENDENCIES = ["network"]
CODEOWNERS = ["@kahrendt", "@synesthesiam"]
DOMAIN = "media_player"
TYPE_LOCAL = "local"
TYPE_WEB = "web"
CONF_ANNOUNCEMENT = "announcement"
CONF_ANNOUNCEMENT_PIPELINE = "announcement_pipeline"
CONF_CODEC_SUPPORT_ENABLED = "codec_support_enabled" # Remove before 2026.10.0
@@ -83,87 +79,12 @@ StopStreamAction = speaker_ns.class_(
)
def _compute_local_file_path(value: dict) -> Path:
url = value[CONF_URL]
h = hashlib.new("sha256")
h.update(url.encode())
key = h.hexdigest()[:8]
base_dir = external_files.compute_local_file_dir(DOMAIN)
_LOGGER.debug("_compute_local_file_path: base_dir=%s", base_dir / key)
return base_dir / key
_PURPOSE_MAP = {
"MEDIA": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["default"],
"ANNOUNCEMENT": media_player.MEDIA_PLAYER_FORMAT_PURPOSE_ENUM["announcement"],
}
def _file_schema(value):
if isinstance(value, str):
return _validate_file_shorthand(value)
return TYPED_FILE_SCHEMA(value)
def _read_audio_file_and_type(file_config):
conf_file = file_config[CONF_FILE]
file_source = conf_file[CONF_TYPE]
if file_source == TYPE_LOCAL:
path = CORE.relative_config_path(conf_file[CONF_PATH])
elif file_source == TYPE_WEB:
path = _compute_local_file_path(conf_file)
else:
raise cv.Invalid("Unsupported file source")
with open(path, "rb") as f:
data = f.read()
import puremagic
try:
file_type: str = puremagic.from_string(data)
file_type = file_type.removeprefix(".")
except puremagic.PureError as e:
raise cv.Invalid(
f"Unable to determine audio file type of '{path}'. "
f"Try re-encoding the file into a supported format. Details: {e}"
) from e
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["NONE"]
if file_type in ("wav"):
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["WAV"]
elif file_type in ("mp3", "mpeg", "mpga"):
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["MP3"]
elif file_type in ("flac"):
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["FLAC"]
elif (
file_type in ("ogg")
and len(data) >= 36
and data.startswith(b"OggS")
and data[28:36] == b"OpusHead"
):
media_file_type = audio.AUDIO_FILE_TYPE_ENUM["OPUS"]
return data, media_file_type
def _validate_file_shorthand(value):
value = cv.string_strict(value)
if value.startswith("http://") or value.startswith("https://"):
return _file_schema(
{
CONF_TYPE: TYPE_WEB,
CONF_URL: value,
}
)
return _file_schema(
{
CONF_TYPE: TYPE_LOCAL,
CONF_PATH: value,
}
)
_validate_pipeline = media_player.validate_preferred_format(
"speaker media_player", CONF_SPEAKER
)
@@ -192,58 +113,15 @@ def _final_validate(config):
CONF_CODEC_SUPPORT_ENABLED,
)
# Request codecs based on pipeline formats
# Request codecs based on pipeline formats. Codecs needed by local files are
# already requested during CONFIG_SCHEMA validation (via audio_files_schema).
media_player.request_codecs_for_format_configs(
config, [CONF_ANNOUNCEMENT_PIPELINE, CONF_MEDIA_PIPELINE]
)
# Validate local files and request any additional codecs they need
for file_config in config.get(CONF_FILES, []):
_, media_file_type = _read_audio_file_and_type(file_config)
if str(media_file_type) == str(audio.AUDIO_FILE_TYPE_ENUM["NONE"]):
raise cv.Invalid("Unsupported local media file")
for fmt_name, fmt_enum in audio.AUDIO_FILE_TYPE_ENUM.items():
if str(media_file_type) == str(fmt_enum):
if fmt_name == "FLAC":
audio.request_flac_support()
elif fmt_name == "MP3":
audio.request_mp3_support()
elif fmt_name == "OPUS":
audio.request_opus_support()
break
return config
LOCAL_SCHEMA = cv.Schema(
{
cv.Required(CONF_PATH): cv.file_,
}
)
WEB_SCHEMA = cv.Schema(
{
cv.Required(CONF_URL): cv.url,
}
)
TYPED_FILE_SCHEMA = cv.typed_schema(
{
TYPE_LOCAL: LOCAL_SCHEMA,
TYPE_WEB: WEB_SCHEMA,
},
)
MEDIA_FILE_TYPE_SCHEMA = cv.Schema(
{
cv.Required(CONF_ID): cv.declare_id(audio.AudioFile),
cv.Required(CONF_FILE): _file_schema,
cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8),
}
)
PIPELINE_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(AudioPipeline),
@@ -276,12 +154,7 @@ CONFIG_SCHEMA = cv.All(
),
# Remove before 2026.10.0
cv.Optional(CONF_CODEC_SUPPORT_ENABLED): cv.Any(cv.boolean, cv.string),
cv.Optional(CONF_FILES): cv.All(
cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA),
partial(
download_web_files_in_config, path_for=_compute_local_file_path
),
),
cv.Optional(CONF_FILES): audio_file.audio_files_schema(),
cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All(
cv.boolean, cv.requires_component(psram.DOMAIN)
),
@@ -378,31 +251,7 @@ async def to_code(config):
)
for file_config in config.get(CONF_FILES, []):
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,
),
)
cg.new_Pvariable(
file_config[CONF_ID],
media_files_struct,
)
audio_file.generate_audio_file_code(file_config)
@automation.register_action(
@@ -17,9 +17,12 @@ namespace speaker {
// - Each stream has an individual speaker component for output
// - Each stream is handled by an ``AudioPipeline`` object with two parts/tasks
// - ``AudioReader`` handles reading from an HTTP source or from a PROGMEM flash set at compile time
// - ``AudioDecoder`` handles decoding the audio file. All formats are limited to two channels and 16 bits per sample
// - ``AudioDecoder`` handles decoding the audio file. All formats are limited to two channels and 16 bits per
// sample.
// Each format is enabled independently at compile time:
// - FLAC
// - MP3 (based on the libhelix decoder)
// - Ogg Opus
// - WAV
// - Each task runs until it is done processing the file or it receives a stop command
// - Inter-task communication uses a FreeRTOS Event Group
+12 -1
View File
@@ -278,7 +278,18 @@ def _push_context(
"""Resolve a variable, recursively resolving any dependencies it references."""
value = unresolved_vars.pop(key, Missing)
if value is Missing:
return Missing
# Either already resolved (in resolved_vars) or currently being
# resolved (self-reference from inside a dict-valued substitution).
# Returning what we have lets sibling references inside a dict
# value, e.g. ``${device.manufacturer}`` inside ``device.name``,
# see literal sibling values during their own resolution.
return resolved_vars.get(key, Missing)
if isinstance(value, dict):
# Dict-valued substitutions form a namespace; eagerly publish the
# original mapping so its members can reference each other while
# the dict's own substitution pass is still running. The entry is
# replaced with the fully-substituted dict once recursion returns.
resolved_vars[key] = value
try:
value = substitute(value, [], resolver_context, True)
except UndefinedError as err:
@@ -276,9 +276,12 @@ UARTFlushResult HostUartComponent::flush() {
if (this->file_descriptor_ == -1) {
return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS;
}
tcflush(this->file_descriptor_, TCIOFLUSH);
ESP_LOGV(TAG, " Flushing");
return UARTFlushResult::UART_FLUSH_RESULT_ASSUMED_SUCCESS;
if (tcdrain(this->file_descriptor_) == -1) {
this->update_error_(strerror(errno));
return UARTFlushResult::UART_FLUSH_RESULT_FAILED;
}
return UARTFlushResult::UART_FLUSH_RESULT_SUCCESS;
}
void HostUartComponent::update_error_(const std::string &error) {
+2
View File
@@ -46,6 +46,8 @@ void arch_init() {
}
}
#endif
// feed watchdog early. Otherwise OTA may rollback.
arch_feed_wdt();
}
void arch_feed_wdt() {
-13
View File
@@ -9,9 +9,6 @@ from esphome.components.esp32.const import (
VARIANT_ESP32C6,
VARIANT_ESP32H2,
)
from esphome.components.nrf52.boards import BOOTLOADER_CONFIG, Section
from esphome.components.zephyr import zephyr_add_pm_static, zephyr_data
from esphome.components.zephyr.const import KEY_BOOTLOADER
import esphome.config_validation as cv
from esphome.const import CONF_ID, CONF_INTERNAL, CONF_MODEL, CONF_NAME
from esphome.core import CORE, CoroPriority, coroutine_with_priority
@@ -53,15 +50,6 @@ _LOGGER = logging.getLogger(__name__)
CODEOWNERS = ["@luar123", "@tomaszduda23"]
def zigbee_set_core_data(config: ConfigType) -> ConfigType:
if CORE.is_nrf52 and zephyr_data()[KEY_BOOTLOADER] in BOOTLOADER_CONFIG:
zephyr_add_pm_static(
[Section("empty_after_zboss_offset", 0xF4000, 0xC000, "flash_primary")]
)
return config
BINARY_SENSOR_SCHEMA = cv.Schema(
{
cv.Optional(CONF_REPORT): cv.All(
@@ -119,7 +107,6 @@ CONFIG_SCHEMA = cv.All(
).extend(cv.COMPONENT_SCHEMA),
_validate_router_sleepy,
zigbee_require_vfs_select,
zigbee_set_core_data,
cv.Any(
cv.All(
cv.only_on_esp32,
+11 -16
View File
@@ -568,14 +568,9 @@ async def _add_controller_registry_define() -> None:
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_looping_components() -> None:
# Emit a constexpr that computes the looping component count at C++ compile time
# and pre-init the FixedVector with the exact capacity. Uses std::is_same_v to
# detect loop() overrides. The constexpr goes in main.cpp's global section where
# all component types are in scope. calculate_looping_components_() then skips
# the counting pass and only does the two population passes.
# Emit ESPHOME_LOOPING_COMPONENT_COUNT. Sizing of looping_components_
# happens in core to_code() so it lands before safe_mode's early return.
entries = CORE.data.get("looping_component_entries", [])
if not entries:
return
# Build constexpr sum for the exact count, deduplicating by type
# Uses HasLoopOverride<T> which handles ambiguous &T::loop from multiple inheritance
@@ -583,7 +578,7 @@ async def _add_looping_components() -> None:
terms = [
f"({count} * HasLoopOverride<{cpp_type}>::value)"
for cpp_type, count in type_counts.items()
]
] or ["0"]
constexpr_expr = " + \\\n ".join(terms)
cg.add_global(
cg.RawStatement(
@@ -592,14 +587,6 @@ async def _add_looping_components() -> None:
)
)
# Pre-init FixedVector with exact capacity so calculate_looping_components_()
# can skip the counting pass
cg.add(
cg.RawExpression(
"App.looping_components_.init(ESPHOME_LOOPING_COMPONENT_COUNT)"
)
)
@coroutine_with_priority(CoroPriority.CORE)
async def to_code(config: ConfigType) -> None:
@@ -642,6 +629,14 @@ async def to_code(config: ConfigType) -> None:
# Define component count for static allocation
cg.add_define("ESPHOME_COMPONENT_COUNT", len(CORE.component_ids))
# Pre-init FixedVector with exact capacity so calculate_looping_components_()
# can skip the counting pass
cg.add(
cg.RawExpression(
"App.looping_components_.init(ESPHOME_LOOPING_COMPONENT_COUNT)"
)
)
CORE.add_job(_add_platform_defines)
CORE.add_job(_add_controller_registry_define)
CORE.add_job(_add_looping_components)
+1
View File
@@ -180,6 +180,7 @@
#define USE_AUDIO_FLAC_SUPPORT
#define USE_AUDIO_MP3_SUPPORT
#define USE_AUDIO_OPUS_SUPPORT
#define USE_AUDIO_WAV_SUPPORT
#define USE_API
#define USE_API_CLIENT_CONNECTED_TRIGGER
#define USE_API_CLIENT_DISCONNECTED_TRIGGER
+8
View File
@@ -37,6 +37,14 @@ std::unique_ptr<RingBuffer> RingBuffer::create(size_t len, MemoryPreference pref
return rb;
}
void *RingBuffer::receive_acquire(size_t &length, size_t max_length, TickType_t ticks_to_wait) {
length = 0;
void *buffer_data = xRingbufferReceiveUpTo(this->handle_, &length, ticks_to_wait, max_length);
return buffer_data;
}
void RingBuffer::receive_release(void *item) { vRingbufferReturnItem(this->handle_, item); }
size_t RingBuffer::read(void *data, size_t len, TickType_t ticks_to_wait) {
size_t bytes_read = 0;
+22
View File
@@ -27,6 +27,28 @@ class RingBuffer {
*/
size_t read(void *data, size_t len, TickType_t ticks_to_wait = 0);
/**
* @brief Acquires a pointer into the ring buffer's internal storage without copying.
*
* The returned pointer is valid until receive_release() is called. Only one item
* may be checked out at a time.
*
* @param[out] length Set to the number of bytes actually acquired (may be less than max_length at wrap boundary)
* @param max_length Maximum number of bytes to acquire
* @param ticks_to_wait Maximum number of FreeRTOS ticks to wait (default: 0)
* @return Pointer into the ring buffer's internal storage, or nullptr if no data is available
*/
void *receive_acquire(size_t &length, size_t max_length, TickType_t ticks_to_wait = 0);
/**
* @brief Releases a previously acquired ring buffer item.
*
* Must be called exactly once for each successful receive_acquire().
*
* @param item Pointer returned by receive_acquire()
*/
void receive_release(void *item);
/**
* @brief Writes to the ring buffer, overwriting oldest data if necessary.
*
+12 -6
View File
@@ -1,9 +1,15 @@
"""Back-compat shim for ``friendly_name_slugify``.
The function moved to :mod:`esphome.helpers` so it survives the legacy
dashboard's eventual removal — see the
``esphome.helpers.friendly_name_slugify`` docstring. This module
re-exports the name so existing
``from esphome.dashboard.util.text import friendly_name_slugify``
imports keep working while downstream consumers migrate.
"""
from __future__ import annotations
from esphome.helpers import slugify
from esphome.helpers import friendly_name_slugify
def friendly_name_slugify(value: str) -> str:
"""Convert a friendly name to a slug with dashes instead of underscores."""
# First use the standard slugify, then convert underscores to dashes
return slugify(value).replace("_", "-")
__all__ = ["friendly_name_slugify"]
+3 -3
View File
@@ -139,9 +139,9 @@ _ERROR_MESSAGES: dict[int, str] = {
),
RESPONSE_ERROR_PARTITION_TABLE_UPDATE: (
"An error occurred while updating the partition table. The device is now "
"in a degraded state (NVS handles are invalid; many components will fail) "
"and may not be able to boot. Check the logs, reboot the device, and "
"retry the update. If the device fails to boot, recover it via a serial flash."
"in a degraded state and may not be able to boot. Open the logs and retry "
"the partition table update without rebooting the device. If the device "
"fails to boot, recover it via a serial flash."
),
RESPONSE_ERROR_UNKNOWN: "Unknown error from ESP",
}
+23 -1
View File
@@ -120,6 +120,24 @@ def slugify(value: str) -> str:
return "".join(c for c in value if c in ALLOWED_NAME_CHARS)
def friendly_name_slugify(value: str) -> str:
"""Convert a friendly name to a slug with dashes instead of underscores.
Used by:
- esphome.dashboard.web_server (legacy dashboard)
- device-builder (esphome/device-builder) slugifies friendly names
into the YAML filename / device name during adoption + wizard flows.
Lives here rather than in ``esphome.dashboard.util.text`` so it
survives the legacy dashboard's eventual removal.
The dashboard module re-exports this name as a back-compat shim.
Coordinate with the device-builder team before changing the
slugification rules the mapping must stay stable so existing
on-disk filenames keep matching across releases.
"""
return slugify(value).replace("_", "-")
def indent_all_but_first_and_last(text, padding=" "):
lines = text.splitlines(True)
if len(lines) <= 2:
@@ -370,7 +388,11 @@ def rmtree(path: Path | str) -> None:
os.chmod(path, stat.S_IWUSR | stat.S_IRUSR)
func(path)
shutil.rmtree(path, onerror=_onerror)
# ``onerror`` is deprecated in 3.12 in favour of ``onexc`` (different
# callable signature); keep the existing handler shape for now and
# silence the lint locally so this PR doesn't bundle an unrelated
# migration.
shutil.rmtree(path, onerror=_onerror) # pylint: disable=deprecated-argument
def walk_files(path: Path):
+7 -3
View File
@@ -2,15 +2,19 @@ dependencies:
bblanchon/arduinojson:
version: "7.4.2"
esphome/esp-audio-libs:
version: 2.0.4
version: 3.0.0
esphome/esp-micro-speech-features:
version: 1.2.3
esphome/micro-decoder:
version: 0.2.0
esphome/micro-flac:
version: 0.1.1
version: 0.2.0
esphome/micro-mp3:
version: 0.2.0
esphome/micro-opus:
version: 0.4.0
version: 0.4.1
esphome/micro-wav:
version: 0.2.0
espressif/esp-dsp:
version: "1.7.1"
espressif/esp-tflite-micro:
+1 -149
View File
@@ -1,15 +1,13 @@
from dataclasses import dataclass
import json
import logging
import os
from pathlib import Path
import re
import subprocess
import sys
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
from esphome.core import CORE, EsphomeError
from esphome.util import run_external_process
from esphome.util import FlashImage, run_external_process
_LOGGER = logging.getLogger(__name__)
@@ -140,152 +138,6 @@ def get_idedata(config) -> "IDEData":
return idedata
# 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):
idedata = 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_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})")
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)
# 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())
# 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
@dataclass
class FlashImage:
path: Path
offset: str
class IDEData:
def __init__(self, raw):
self.raw = raw
+16 -1
View File
@@ -2,13 +2,28 @@
from __future__ import annotations
import logging
import os
from aioesphomeapi.core import ResolveAPIError, ResolveTimeoutAPIError
import aioesphomeapi.host_resolver as hr
from esphome.async_thread import AsyncThreadRunner
from esphome.core import EsphomeError
RESOLVE_TIMEOUT = 10.0 # seconds
_LOGGER = logging.getLogger(__name__)
_DEFAULT_RESOLVE_TIMEOUT = 20.0
_env_timeout = os.environ.get("ESPHOME_RESOLVE_TIMEOUT", _DEFAULT_RESOLVE_TIMEOUT)
try:
RESOLVE_TIMEOUT = float(_env_timeout)
except ValueError:
_LOGGER.warning(
"ESPHOME_RESOLVE_TIMEOUT=%r is not a valid number; using default %.1fs",
_env_timeout,
_DEFAULT_RESOLVE_TIMEOUT,
)
RESOLVE_TIMEOUT = _DEFAULT_RESOLVE_TIMEOUT
class AsyncResolver:
+28
View File
@@ -21,6 +21,14 @@ def storage_path() -> Path:
def ext_storage_path(config_filename: str) -> Path:
"""Path to the per-config StorageJSON sidecar.
Used by:
- device-builder (esphome/device-builder) locates the sidecar
to read board / framework / firmware-bin / loaded_integrations
info for the dashboard. Coordinate before changing the path
shape; device-builder reads the same file on disk.
"""
return CORE.data_dir / "storage" / f"{config_filename}.json"
@@ -29,6 +37,14 @@ def esphome_storage_path() -> Path:
def ignored_devices_storage_path() -> Path:
"""Path to the dashboard's ignored-devices list.
Used by:
- device-builder (esphome/device-builder) reads the same
``ignored-devices.json`` so the new dashboard's "ignore" toggle
stays compatible with the legacy one. Don't change the file
shape without coordinating.
"""
return CORE.data_dir / "ignored-devices.json"
@@ -46,6 +62,18 @@ def _to_path_if_not_none(value: str | None) -> Path | None:
class StorageJSON:
"""Persisted device metadata sidecar.
Used by:
- esphome.dashboard (legacy dashboard)
- device-builder (esphome/device-builder) reads/writes the same
JSON file as the legacy dashboard so a single config_dir can be
shared between the two during the transition. The schema
(``storage_version``, field names, types) must stay backwards
compatible coordinate with the device-builder team before
adding required fields or changing semantics of existing ones.
"""
def __init__(
self,
storage_version: int,
+27 -5
View File
@@ -94,13 +94,29 @@ def safe_print(message="", end="\n"):
except UnicodeEncodeError:
pass
# Fall back to the stream's actual encoding (e.g. cp1252 on Windows
# redirected pipes). Use "backslashreplace" so unencodable code points
# like the wifi signal-bar block characters (U+2582..U+2588) become
# readable ``\uXXXX`` escapes, and decode back to ``str`` so ``print``
# never receives a ``bytes`` object (which would render as a ``b'...'``
# repr).
encoding = sys.stdout.encoding or "ascii"
try:
print(message.encode("utf-8", "backslashreplace"), end=end)
print(
message.encode(encoding, "backslashreplace").decode(encoding),
end=end,
)
return
except UnicodeEncodeError:
try:
print(message.encode("ascii", "backslashreplace"), end=end)
except UnicodeEncodeError:
print("Cannot print line because of invalid locale!")
pass
try:
print(
message.encode("ascii", "backslashreplace").decode("ascii"),
end=end,
)
except UnicodeEncodeError:
print("Cannot print line because of invalid locale!")
def safe_input(prompt=""):
@@ -487,3 +503,9 @@ def get_esp32_arduino_flash_error_help() -> str | None:
"https://esphome.io/guides/esp32_arduino_to_idf/\n\n",
)
)
@dataclass
class FlashImage:
path: Path
offset: str
+202
View File
@@ -0,0 +1,202 @@
"""HTTP-based OTA upload via the ``web_server`` component's ``/update`` endpoint.
This is the alternative to ``espota2`` (the native API OTA path). Useful when
a device only has ``platform: web_server`` configured under ``ota:``, or when
the user has lost the native OTA password but still has ``web_server`` basic
auth credentials.
"""
from __future__ import annotations
import io
import logging
from pathlib import Path
import secrets
import socket
from typing import BinaryIO
import requests
from requests.auth import HTTPBasicAuth
from esphome.core import EsphomeError
from esphome.helpers import ProgressBar, resolve_ip_address
_LOGGER = logging.getLogger(__name__)
OTA_PATH = "/update"
FORM_FIELD = "update"
# (connect_timeout, read_timeout). The device reboots after a successful
# upload so the read side must allow for a slow flash + response.
TIMEOUT = (20.0, 120.0)
class WebServerOTAError(EsphomeError):
pass
class _MultipartStreamer:
"""Stream a single-file multipart/form-data body during transmission.
``requests.post(files=...)`` materializes the entire body in memory before
sending, so a progress callback wired into the file-like fires during
encoding instead of during the network send. Pass this via ``data=``
(with ``__len__`` so urllib3 sets ``Content-Length`` instead of using
chunked transfer encoding); urllib3 then calls ``read(blocksize)``
repeatedly during the POST and the progress bar tracks bytes leaving the
host.
"""
def __init__(self, file: BinaryIO, file_size: int, filename: str) -> None:
self.boundary = f"esphomeOTA{secrets.token_hex(16)}"
prefix = (
f"--{self.boundary}\r\n"
f'Content-Disposition: form-data; name="{FORM_FIELD}"; '
f'filename="{filename}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n"
).encode()
suffix = f"\r\n--{self.boundary}--\r\n".encode()
# Walked in order; ``read()`` advances to the next source on EOF.
self._sources: list[BinaryIO] = [io.BytesIO(prefix), file, io.BytesIO(suffix)]
self._idx = 0
self._total = len(prefix) + file_size + len(suffix)
self._sent = 0
self.progress = ProgressBar()
def __len__(self) -> int:
return self._total
@property
def content_type(self) -> str:
return f"multipart/form-data; boundary={self.boundary}"
def read(self, size: int = -1) -> bytes:
remaining = self._total if size is None or size < 0 else size
out = bytearray()
while remaining > 0 and self._idx < len(self._sources):
chunk = self._sources[self._idx].read(remaining)
if not chunk:
self._idx += 1
continue
out += chunk
remaining -= len(chunk)
if out:
self._sent += len(out)
self.progress.update(self._sent / self._total)
return bytes(out)
def _try_upload(
host: str,
port: int,
username: str | None,
password: str | None,
filename: Path,
) -> tuple[int, str | None]:
from esphome.core import CORE
try:
addr_infos = resolve_ip_address(host, port, address_cache=CORE.address_cache)
except EsphomeError as err:
_LOGGER.error(
"Error resolving IP address of %s. Is it connected to WiFi?", host
)
if not CORE.dashboard:
_LOGGER.error("(If you know the IP, try --device <IP>)")
raise WebServerOTAError(err) from err
if not addr_infos:
_LOGGER.error("Could not resolve %s", host)
return 1, None
file_size = filename.stat().st_size
_LOGGER.info("Uploading %s (%s bytes) via web_server OTA", filename, file_size)
auth = HTTPBasicAuth(username, password) if username and password else None
# Iterate resolved IPs (IPv4 + IPv6 candidates) just like espota2 does.
for af, _socktype, _, _, sa in addr_infos:
ip = sa[0]
# IPv6 literals must be wrapped in brackets in URLs; link-local
# addresses need a percent-encoded zone index per RFC 6874.
if af == socket.AF_INET6:
scope = sa[3] if len(sa) >= 4 else 0
host_part = f"[{ip}%25{scope}]" if scope else f"[{ip}]"
else:
host_part = ip
url = f"http://{host_part}:{port}{OTA_PATH}"
_LOGGER.info("Connecting to %s port %s...", ip, port)
try:
with open(filename, "rb") as fh:
streamer = _MultipartStreamer(fh, file_size, filename.name)
try:
response = requests.post(
url,
data=streamer,
auth=auth,
timeout=TIMEOUT,
headers={
"Content-Type": streamer.content_type,
"Connection": "close",
},
)
finally:
streamer.progress.done()
except requests.RequestException as err:
_LOGGER.error("OTA upload to %s port %s failed: %s", ip, port, err)
continue
if response.status_code == 401:
raise WebServerOTAError(
"Authentication failed (HTTP 401). Check the 'web_server' "
"'auth' username and password."
)
if response.status_code != 200:
detail = response.text.strip() or response.reason or "no response body"
raise WebServerOTAError(
f"Unexpected HTTP {response.status_code} response from device: {detail}"
)
# The endpoint returns HTTP 200 for both success and failure; the
# body is what tells us which (see ota_web_server.cpp handleRequest).
body = response.text.strip()
if "Successful" in body:
_LOGGER.info("Device response: %s", body)
_LOGGER.info("OTA successful")
return 0, ip
raise WebServerOTAError(
f"Device reported OTA failure: {body or 'no response body'}"
)
return 1, None
def run_ota(
remote_hosts: str | list[str],
remote_port: int,
username: str | None,
password: str | None,
filename: Path,
) -> tuple[int, str | None]:
"""Upload ``filename`` to the first reachable host via ``web_server`` OTA.
Mirrors :func:`esphome.espota2.run_ota` so callers can swap between the
two paths with the same return contract: ``(0, host)`` on success or
``(1, None)`` on failure.
"""
hosts = [remote_hosts] if isinstance(remote_hosts, str) else list(remote_hosts)
for host in hosts:
try:
exit_code, used_host = _try_upload(
host, remote_port, username, password, filename
)
except WebServerOTAError as err:
_LOGGER.error("%s", err)
continue
if exit_code == 0:
return 0, used_host
# Reached only when every attempt failed; per-attempt errors were
# already logged. This summary line gives the user an unambiguous
# "stop reading, nothing worked" marker.
_LOGGER.error("OTA upload failed.")
return 1, None
+41
View File
@@ -60,6 +60,18 @@ TXT_RECORD_VERSION = b"version"
@dataclass
class DiscoveredImport:
"""An importable device discovered via mDNS ``_esphomelib._tcp.local.``.
Used by:
- esphome.dashboard (legacy dashboard)
- device-builder (esphome/device-builder) surfaces these as
"discovered devices" on the new dashboard's adoption flow.
Fields are populated from TXT records on the broadcast service
info (see :class:`DashboardImportDiscovery`). Coordinate before
adding/removing fields both consumers persist them.
"""
friendly_name: str | None
device_name: str
package_import_url: str
@@ -73,6 +85,22 @@ class DashboardBrowser(AsyncServiceBrowser):
class DashboardImportDiscovery:
"""Track importable devices announcing on ``_esphomelib._tcp.local.``.
Used by:
- esphome.dashboard (legacy dashboard)
- device-builder (esphome/device-builder) wired up alongside
the dashboard's own ``ServiceBrowser`` to populate the
"Discovered devices" panel and the adoption flow.
The class maintains ``import_state: dict[str, DiscoveredImport]``
keyed by the mDNS service name. ``on_update`` is invoked with
``(name, info | None)`` for additions and removals; update events
refresh ``import_state`` without firing the callback.
Coordinate before changing the callback signature or the keys
of ``import_state`` device-builder reads both directly.
"""
def __init__(
self, on_update: Callable[[str, DiscoveredImport | None], None] | None = None
) -> None:
@@ -232,6 +260,19 @@ async def async_resolve_hosts(
class AsyncEsphomeZeroconf(AsyncZeroconf):
"""ESPHome-tuned ``AsyncZeroconf`` with a hostname-resolve helper.
Used by:
- esphome.dashboard (legacy dashboard)
- device-builder (esphome/device-builder) drives both the live
mDNS browser and the per-sweep ``async_resolve_host`` fallback
for non-API devices that don't broadcast esphomelib.
Coordinate before adding required constructor args or changing
the ``async_resolve_host`` signature device-builder calls it
on every ping cycle.
"""
async def async_resolve_host(
self, host: str, timeout: float = DEFAULT_TIMEOUT
) -> list[str] | None:
+1 -1
View File
@@ -1,4 +1,4 @@
cryptography==47.0.0
cryptography==48.0.0
voluptuous==0.16.0
PyYAML==6.0.3
paho-mqtt==1.6.1
+34 -1
View File
@@ -1065,7 +1065,40 @@ def convert_keys(converted, schema, path):
else:
converted["key_type"] = str(k)
if hasattr(k, "default") and str(k.default) != "...":
# ``cv.OnlyWith`` / ``cv.OnlyWithout`` expose ``default`` as
# a property that returns ``vol.UNDEFINED`` when the gating
# component isn't loaded — and at schema-generation time
# ``CORE.loaded_integrations`` is always empty, so the
# property never resolves. The unconditional default lives
# on ``_default``; expose it under a *new* per-class field
# (``default_with`` for ``OnlyWith``, ``default_without`` for
# ``OnlyWithout``) that bundles the value with the gating
# component(s). Pure addition to the bundle — old consumers
# that read only ``default`` see these fields as
# default-less (same as today, no regression where they used
# to fall back to a hard-coded UI default); new consumers
# opt-in to the gated fields and apply the default
# *conditionally* on which integrations the user has
# loaded. Without the gate info, an ethernet-only config on
# ``cv.OnlyWith(K, "wifi", default=True)`` would otherwise
# render ``True`` even though ESPHome itself wouldn't apply
# the default for that config.
if isinstance(k, (cv.OnlyWith, cv.OnlyWithout)):
default_value = k._default()
if default_value is not None:
components = (
list(k._component)
if isinstance(k._component, list)
else [k._component]
)
gate_field = (
"default_with" if isinstance(k, cv.OnlyWith) else "default_without"
)
result[gate_field] = {
"value": str(default_value),
"components": components,
}
elif hasattr(k, "default") and str(k.default) != "...":
default_value = k.default()
if default_value is not None:
result["default"] = str(default_value)
+1 -1
View File
@@ -250,7 +250,7 @@ def lint_ext_check(fname):
]
)
def lint_executable_bit(fname: Path) -> str | None:
ex = EXECUTABLE_BIT[str(fname)]
ex = EXECUTABLE_BIT[fname.as_posix()]
if ex != 100644:
return (
f"File has invalid executable bit {ex}. If running from a windows machine please "
+54
View File
@@ -10,6 +10,7 @@ what files have changed. It outputs JSON with the following structure:
"clang_tidy": true/false,
"clang_format": true/false,
"python_linters": true/false,
"device_builder": true/false,
"changed_components": ["component1", "component2", ...],
"component_test_count": 5,
"memory_impact": {
@@ -25,6 +26,7 @@ The CI workflow uses this information to:
- Skip or run clang-tidy (and whether to do a full scan)
- Skip or run clang-format
- Skip or run Python linters (ruff, flake8, pylint, pyupgrade)
- Skip or run downstream esphome/device-builder tests against the PR's Python code
- Determine which components to test individually
- Decide how to split component tests (if there are many)
- Run memory impact analysis whenever there are changed components (merged config), and also for core-only changes
@@ -440,6 +442,56 @@ def should_run_import_time(branch: str | None = None) -> bool:
return False
# Files outside esphome/**/*.py whose changes can affect the downstream
# device-builder build. requirements.txt / pyproject.toml change the runtime
# dependency graph that device-builder picks up when it installs esphome.
DEVICE_BUILDER_TRIGGER_FILES = frozenset(
{
"requirements.txt",
"pyproject.toml",
}
)
def should_run_device_builder(branch: str | None = None) -> bool:
"""Determine if downstream esphome/device-builder tests should run.
device-builder imports esphome as a library, so whenever the importable
Python surface, the runtime dependencies, or any non-C++ file packaged
with esphome (pyproject.toml has ``include-package-data = true``, so
things like esphome/idf_component.yml ship and can affect installs)
changes we re-run its test suite against the PR's code to catch
breakage we'd otherwise only see after a release.
Skipped on beta/release branches: those branches typically lag behind
device-builder@main, so a new device-builder API dependency would
falsely fail the run without reflecting any problem in the PR itself.
Args:
branch: Branch to compare against. If None, uses default.
Returns:
True if the device-builder downstream tests should run, False otherwise.
"""
target_branch = get_target_branch()
if target_branch and (
target_branch.startswith("release") or target_branch.startswith("beta")
):
return False
for file in changed_files(branch):
if file in DEVICE_BUILDER_TRIGGER_FILES:
return True
# Anything under esphome/ that isn't C++ source can change the
# importable / packaged surface device-builder consumes
# (Python sources, packaged YAML/JSON like idf_component.yml,
# etc.). C++ files only affect compiled firmware, not the
# Python install device-builder pulls in.
if file.startswith("esphome/") and not file.endswith(CPP_FILE_EXTENSIONS):
return True
return False
def determine_cpp_unit_tests(
branch: str | None = None,
) -> tuple[bool, list[str]]:
@@ -874,6 +926,7 @@ def main() -> None:
run_clang_format = should_run_clang_format(args.branch)
run_python_linters = should_run_python_linters(args.branch)
run_import_time = should_run_import_time(args.branch)
run_device_builder = should_run_device_builder(args.branch)
changed_cpp_file_count = count_changed_cpp_files(args.branch)
# Get changed components
@@ -1007,6 +1060,7 @@ def main() -> None:
"clang_format": run_clang_format,
"python_linters": run_python_linters,
"import_time": run_import_time,
"device_builder": run_device_builder,
"changed_components": changed_components,
"changed_components_with_tests": changed_components_with_tests,
"directly_changed_components_with_tests": list(directly_changed_with_tests),
+8 -1
View File
@@ -44,7 +44,14 @@ def find_and_activate_virtualenv():
def run_command():
# Execute the remaining arguments in the new environment
if len(sys.argv) > 1:
result = subprocess.run(sys.argv[1:], check=False, close_fds=False)
args = sys.argv[1:]
# Windows CreateProcess doesn't follow shebangs, so prepend the
# current interpreter when the entry is a .py script. Using
# sys.executable also pins the nested call to the same Python that
# ran us — no ambiguous PATH lookup for "python".
if args[0].endswith(".py"):
args = [sys.executable, *args]
result = subprocess.run(args, check=False, close_fds=False)
sys.exit(result.returncode)
else:
print(
@@ -0,0 +1,84 @@
esphome:
name: test-line
esp32:
board: lolin_c3_mini
spi:
mosi_pin:
number: GPIO2
ignore_strapping_warning: true
clk_pin: GPIO1
display:
- platform: mipi_spi
data_rate: 20MHz
model: st7735
cs_pin:
number: GPIO8
ignore_strapping_warning: true
dc_pin:
number: GPIO3
lvgl:
widgets:
# Dict format
- line:
id: line_dict
points:
- x: 10
y: 20
- x: 100
y: 200
- x: 0
y: 0
# List format
- line:
id: line_list
points:
- [10, 20]
- [100, 200]
- [0, 0]
# String format
- line:
id: line_string
points:
- "10, 20"
- "100, 200"
- "0, 0"
# Percentage - dict format
- line:
id: line_pct_dict
points:
- x: "50%"
y: "75%"
# Percentage - list format
- line:
id: line_pct_list
points:
- ["50%", "75%"]
# Percentage - string format
- line:
id: line_pct_string
points:
- "50%, 75%"
# Mixed integer and percentage
- line:
id: line_mixed_dict
points:
- x: 10
y: "50%"
- x: "25%"
y: 200
- line:
id: line_mixed_list
points:
- [10, "50%"]
- ["25%", 200]
+147
View File
@@ -0,0 +1,147 @@
"""Tests for the LVGL line widget point schema and code generation."""
from __future__ import annotations
import re
import pytest
from esphome.components.lvgl.schemas import point_schema
from esphome.config_validation import Invalid
from esphome.const import CONF_X, CONF_Y
# ---------------------------------------------------------------------------
# Validation: point_schema normalises dict / list / string to same result
# ---------------------------------------------------------------------------
class TestPointSchemaValidation:
"""Test that all point input formats normalise to the same dict."""
@pytest.mark.parametrize(
"dict_input,list_input,string_input",
[
({CONF_X: 10, CONF_Y: 20}, [10, 20], "10, 20"),
({CONF_X: 0, CONF_Y: 0}, [0, 0], "0, 0"),
({CONF_X: 100, CONF_Y: 200}, [100, 200], "100, 200"),
({CONF_X: -5, CONF_Y: -10}, [-5, -10], "-5, -10"),
],
)
def test_integer_formats_produce_same_result(
self, dict_input, list_input, string_input
):
result_dict = point_schema(dict_input)
result_list = point_schema(list_input)
result_string = point_schema(string_input)
assert result_dict == result_list
assert result_dict == result_string
def test_percentage_formats_produce_same_result(self):
result_dict = point_schema({CONF_X: "50%", CONF_Y: "75%"})
result_list = point_schema(["50%", "75%"])
result_string = point_schema("50%, 75%")
assert result_dict == result_list
assert result_dict == result_string
def test_pixel_suffix_matches_plain_integer(self):
result_px = point_schema({CONF_X: "10px", CONF_Y: "20px"})
result_int = point_schema({CONF_X: 10, CONF_Y: 20})
assert result_px == result_int
@pytest.mark.parametrize(
"value",
[
{CONF_X: 50, CONF_Y: 75},
[50, 75],
"50, 75",
],
)
def test_output_contains_x_and_y(self, value):
result = point_schema(value)
assert CONF_X in result
assert CONF_Y in result
def test_list_wrong_length_raises(self):
with pytest.raises(Invalid, match="Invalid point"):
point_schema([1])
with pytest.raises(Invalid, match="Invalid point"):
point_schema([1, 2, 3])
def test_string_without_comma_raises(self):
with pytest.raises(Invalid, match="Invalid point"):
point_schema("garbage")
def test_string_extra_commas_raises(self):
with pytest.raises(Invalid, match="Invalid point"):
point_schema("1,2,3")
# ---------------------------------------------------------------------------
# Code generation: different point formats produce identical C++ output
# ---------------------------------------------------------------------------
_SET_POINTS_RE = re.compile(r"(\w+)->set_points\((.+?)\);")
def _extract_set_points(main_cpp: str) -> dict[str, str]:
"""Return {var_name: args_text} for every set_points() call found."""
return {m.group(1): m.group(2) for m in _SET_POINTS_RE.finditer(main_cpp)}
class TestLineCodeGeneration:
"""Verify that alternative point formats generate identical C++ code."""
@pytest.fixture()
def main_cpp(self, generate_main, component_config_path) -> str:
return generate_main(component_config_path("line_points.yaml"))
@pytest.fixture()
def set_points_calls(self, main_cpp) -> dict[str, str]:
return _extract_set_points(main_cpp)
def test_integer_points_all_formats_match(self, set_points_calls):
"""Dict, list, and string formats with integer points produce same set_points call."""
assert set_points_calls["line_dict"] == set_points_calls["line_list"]
assert set_points_calls["line_dict"] == set_points_calls["line_string"]
def test_percentage_points_all_formats_match(self, set_points_calls):
"""Dict, list, and string formats with percentage points produce same set_points call."""
assert set_points_calls["line_pct_dict"] == set_points_calls["line_pct_list"]
assert set_points_calls["line_pct_dict"] == set_points_calls["line_pct_string"]
def test_mixed_points_formats_match(self, set_points_calls):
"""Dict and list formats with mixed int/percent points produce same set_points call."""
assert (
set_points_calls["line_mixed_dict"] == set_points_calls["line_mixed_list"]
)
def test_integer_points_contain_expected_values(self, set_points_calls):
"""Integer points appear literally in the generated code."""
args = set_points_calls["line_dict"]
for val in ("10", "20", "100", "200"):
assert val in args
def test_percentage_points_use_lv_pct(self, set_points_calls):
"""Percentage points are generated using the lv_pct() macro."""
args = set_points_calls["line_pct_dict"]
assert "lv_pct(50)" in args
assert "lv_pct(75)" in args
def test_all_lines_present(self, set_points_calls):
"""All expected line IDs have a set_points call."""
expected = {
"line_dict",
"line_list",
"line_string",
"line_pct_dict",
"line_pct_list",
"line_pct_string",
"line_mixed_dict",
"line_mixed_list",
}
assert expected.issubset(set_points_calls.keys())
@@ -14,6 +14,7 @@ from esphome.components.packages import (
do_packages_pass,
is_package_definition,
merge_packages,
resolve_packages,
)
from esphome.components.substitutions import ContextVars, do_substitution_pass
import esphome.config as config_module
@@ -1621,3 +1622,122 @@ def test_remote_package_vars_resolved_against_sibling_package_substitutions(
actual = packages_pass(config)
assert actual[CONF_SENSOR][0]["pin"] == "GPIO5"
# ---------------------------------------------------------------------------
# resolve_packages — single-call wrapper around do_packages_pass + merge_packages
# ---------------------------------------------------------------------------
def test_resolve_packages_returns_config_unchanged_without_packages() -> None:
"""No ``packages:`` key → no-op, same dict back."""
config = {CONF_ESPHOME: {CONF_NAME: "test"}, CONF_WIFI: {CONF_SSID: "x"}}
result = resolve_packages(config)
assert result is config
assert CONF_PACKAGES not in result
def test_resolve_packages_loads_and_merges_in_one_call() -> None:
"""End-to-end: a config with one local-dict package gets its blocks flattened."""
config = {
CONF_ESPHOME: {CONF_NAME: "main"},
CONF_PACKAGES: {
"shared": {
CONF_WIFI: {CONF_SSID: "from_package"},
CONF_SENSOR: [
{CONF_PLATFORM: "template", CONF_NAME: "from_package_sensor"},
],
}
},
}
result = resolve_packages(config)
# ``packages:`` is gone — it was consumed by the merge.
assert CONF_PACKAGES not in result
# Blocks contributed by the package are now top-level.
assert result[CONF_WIFI][CONF_SSID] == "from_package"
assert result[CONF_SENSOR][0][CONF_NAME] == "from_package_sensor"
# The main config's own keys survive untouched.
assert result[CONF_ESPHOME][CONF_NAME] == "main"
def test_resolve_packages_preserves_main_config_overrides() -> None:
"""Main-config values win over package values for the same key.
Pinning the precedence ESPHome's compiler uses so any future
refactor of the wrapper doesn't accidentally flip the order.
"""
config = {
CONF_ESPHOME: {CONF_NAME: "main"},
CONF_WIFI: {CONF_SSID: "main_wins"},
CONF_PACKAGES: {
"shared": {CONF_WIFI: {CONF_SSID: "package_loses"}},
},
}
result = resolve_packages(config)
assert result[CONF_WIFI][CONF_SSID] == "main_wins"
def test_resolve_packages_forwards_command_line_substitutions() -> None:
"""``command_line_substitutions`` reaches the underlying ``do_packages_pass``.
The wrapper exists so external tools have one stable seam; if
that seam silently dropped a kwarg the underlying call accepts,
callers would see surprising behaviour. This pins the
pass-through.
"""
config = {
CONF_ESPHOME: {CONF_NAME: "main"},
CONF_PACKAGES: {"shared": {CONF_WIFI: {CONF_SSID: "from_package"}}},
}
with patch(
"esphome.components.packages.do_packages_pass",
wraps=do_packages_pass,
) as spy:
resolve_packages(config, command_line_substitutions={"foo": "bar"})
spy.assert_called_once()
_, kwargs = spy.call_args
assert kwargs.get("command_line_substitutions") == {"foo": "bar"}
def test_resolve_packages_does_not_run_substitutions() -> None:
"""``${var}`` placeholders inside package content stay literal.
The full ``validate_config`` pipeline runs ``do_substitution_pass``
BETWEEN ``do_packages_pass`` and ``merge_packages``; this wrapper
skips it on purpose. Pin that contract so a future refactor can't
silently start resolving substitutions and break callers that
deliberately compose the passes themselves.
"""
config = {
CONF_ESPHOME: {CONF_NAME: "main"},
CONF_SUBSTITUTIONS: {"ssid_value": "resolved_ssid"},
CONF_PACKAGES: {
"shared": {CONF_WIFI: {CONF_SSID: "${ssid_value}"}},
},
}
result = resolve_packages(config)
# Without ``do_substitution_pass`` the placeholder is preserved.
assert result[CONF_WIFI][CONF_SSID] == "${ssid_value}"
def test_resolve_packages_does_not_apply_extend_remove() -> None:
"""Top-level ``!remove`` / ``!extend`` markers stay in the merged dict.
The full ``validate_config`` pipeline runs ``resolve_extend_remove``
AFTER ``merge_packages``; this wrapper skips it on purpose. Pin
that contract: a package-contributed block paired with a top-level
``!remove`` is left as-is for callers to handle (or for them to
call ``resolve_extend_remove`` themselves).
"""
config = {
CONF_ESPHOME: {CONF_NAME: "main"},
CONF_WIFI: Remove(),
CONF_PACKAGES: {
"shared": {CONF_WIFI: {CONF_SSID: "from_package"}},
},
}
result = resolve_packages(config)
# ``merge_packages`` keeps the top-level ``!remove`` (it wins
# over the package value during merge), and the marker is not
# resolved by this wrapper.
assert isinstance(result[CONF_WIFI], Remove)
@@ -50,12 +50,33 @@ esphome:
format: "After delay, body still: %s"
args:
- body.c_str()
# Regression test for esphome/esphome#16224: a LightControlAction
# nested inside on_response with capture_response: true puts
# `std::string &` into the trigger's Ts..., which exposed a codegen
# bug where the apply lambda's parameter list did not match the
# ApplyFn signature.
- light.turn_on:
id: test_regression_light
brightness: 100%
effect: "None"
http_request:
useragent: esphome/tagreader
timeout: 10s
verify_ssl: ${verify_ssl}
output:
- platform: template
id: test_regression_output
type: float
write_action:
- logger.log: "set"
light:
- platform: monochromatic
id: test_regression_light
output: test_regression_output
script:
- id: does_not_compile
parameters:
+14 -3
View File
@@ -649,11 +649,15 @@ lvgl:
on_scroll_begin:
logger.log: Button clicked
on_release:
logger.log: Button clicked
logger.log:
format: Button released at %d/%d
args: [point.x, point.y]
on_long_press_repeat:
logger.log: Button clicked
on_pressing:
logger.log: Button pressing
logger.log:
format: Button pressing at %d/%d
args: [point.x, point.y]
on_press_lost:
logger.log: Button press lost
on_single_click:
@@ -925,6 +929,10 @@ lvgl:
value: !lambda |-
static float yyy = 83.0;
return yyy + .8;
on_release:
logger.log:
format: Slider released at %d/%d with value %.0f
args: [point.x, point.y, x]
- button:
styles: spin_button
id: spin_up
@@ -1038,7 +1046,10 @@ lvgl:
- 5, 5
- x: !lambda return random_uint32() % 100;
y: !lambda return random_uint32() % 100;
- 70, 70
- x: 10%
y: 50%
- 70%, 70%
- [75%, 75%]
- 120, 10
- 180, 60
- 240, 10
@@ -17,3 +17,16 @@ media_player:
volume_max: 0.95
volume_min: 0.0
task_stack_in_psram: true
files:
- id: speaker_test_audio
file:
type: local
path: $component_dir/test.wav
script:
- id: play_built_in_file
then:
- media_player.speaker.play_on_device_media_file:
id: speaker_media_player_id
media_file: speaker_test_audio
announcement: true
Binary file not shown.
+18 -2
View File
@@ -202,6 +202,11 @@ sensor:
value: last
- timeout:
timeout: 1d
- to_ntc_temperature:
calibration:
b_constant: 3950
reference_temperature: 25.0°C
reference_resistance: 10kOhm
- to_ntc_resistance:
calibration:
- 10.0kOhm -> 25°C
@@ -270,8 +275,6 @@ cover:
stop_action:
- logger.log: stop_action
optimistic: true
on_open:
- logger.log: "Cover on_open (deprecated)"
on_opened:
- logger.log: "Cover fully opened"
on_closed:
@@ -369,6 +372,19 @@ number:
- valve.control:
id: template_valve
position: !lambda "return x / 100.0f;"
# Same regression test for cover.control: forces the apply-lambda
# codegen to handle a non-empty trigger Ts (float).
- platform: template
id: template_cover_position_number
optimistic: true
min_value: 0
max_value: 100
step: 1
on_value:
then:
- cover.control:
id: template_cover_with_triggers
position: !lambda "return x / 100.0f;"
select:
- platform: template
+8
View File
@@ -0,0 +1,8 @@
esphome:
on_boot:
- lambda: |-
ESP_LOGD("test", "millis=%u micros=%u cycles=%u",
(unsigned) millis(), (unsigned) micros(),
(unsigned) arch_get_cpu_cycle_count());
delay(1);
delayMicroseconds(1);
@@ -0,0 +1 @@
<<: !include common.yaml
+54 -57
View File
@@ -501,14 +501,15 @@ async def _read_stream_lines(
@asynccontextmanager
async def run_binary_and_wait_for_port(
async def run_binary(
binary_path: Path,
host: str,
port: int,
timeout: float = PORT_WAIT_TIMEOUT,
line_callback: Callable[[str], None] | None = None,
) -> AsyncGenerator[None]:
"""Run a binary, wait for it to open a port, and clean up on exit."""
) -> AsyncGenerator[tuple[asyncio.subprocess.Process, list[str]]]:
"""Run a binary under a PTY, capture log output, and clean up on exit.
Yields the running ``Process`` and a live list of captured log lines.
No port wait -- callers that need that should use
``run_binary_and_wait_for_port``."""
# Create a pseudo-terminal to make the binary think it's running interactively
# This is needed because the ESPHome host logger checks isatty()
controller_fd, device_fd = pty.openpty()
@@ -535,7 +536,6 @@ async def run_binary_and_wait_for_port(
controller_transport, _ = await loop.connect_read_pipe(
lambda: controller_protocol, os.fdopen(controller_fd, "rb", 0)
)
output_reader = controller_reader
if process.returncode is not None:
raise RuntimeError(
@@ -543,27 +543,59 @@ async def run_binary_and_wait_for_port(
"Ensure the binary is valid and can run successfully."
)
# Wait for the API server to start listening
loop = asyncio.get_running_loop()
start_time = loop.time()
# Start collecting output
stdout_lines: list[str] = []
output_tasks: list[asyncio.Task] = []
output_task = asyncio.create_task(
_read_stream_lines(controller_reader, stdout_lines, sys.stdout, line_callback)
)
try:
# Read from output stream
output_tasks = [
asyncio.create_task(
_read_stream_lines(
output_reader, stdout_lines, sys.stdout, line_callback
)
)
]
# Small yield to ensure the process has a chance to start
await asyncio.sleep(0)
yield process, stdout_lines
finally:
output_task.cancel()
result = await asyncio.gather(output_task, return_exceptions=True)
if isinstance(result[0], Exception) and not isinstance(
result[0], asyncio.CancelledError
):
print(f"Error reading from PTY: {result[0]}", file=sys.stderr)
# Close the PTY transport (Unix only)
if controller_transport is not None:
controller_transport.close()
# Cleanup: terminate the process gracefully
if process.returncode is None:
# Send SIGINT (Ctrl+C) for graceful shutdown
process.send_signal(signal.SIGINT)
try:
await asyncio.wait_for(process.wait(), timeout=SIGINT_TIMEOUT)
except TimeoutError:
# If SIGINT didn't work, try SIGTERM
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=SIGTERM_TIMEOUT)
except TimeoutError:
# Last resort: SIGKILL
process.kill()
await process.wait()
@asynccontextmanager
async def run_binary_and_wait_for_port(
binary_path: Path,
host: str,
port: int,
timeout: float = PORT_WAIT_TIMEOUT,
line_callback: Callable[[str], None] | None = None,
) -> AsyncGenerator[None]:
"""Run a binary, wait for it to open a port, and clean up on exit."""
async with run_binary(binary_path, line_callback=line_callback) as (
process,
stdout_lines,
):
loop = asyncio.get_running_loop()
start_time = loop.time()
while loop.time() - start_time < timeout:
try:
# Try to connect to the port
@@ -593,41 +625,6 @@ async def run_binary_and_wait_for_port(
raise TimeoutError(error_msg)
finally:
# Cancel output collection tasks
for task in output_tasks:
task.cancel()
# Wait for tasks to complete and check for exceptions
results = await asyncio.gather(*output_tasks, return_exceptions=True)
for i, result in enumerate(results):
if isinstance(result, Exception) and not isinstance(
result, asyncio.CancelledError
):
print(
f"Error reading from PTY: {result}",
file=sys.stderr,
)
# Close the PTY transport (Unix only)
if controller_transport is not None:
controller_transport.close()
# Cleanup: terminate the process gracefully
if process.returncode is None:
# Send SIGINT (Ctrl+C) for graceful shutdown
process.send_signal(signal.SIGINT)
try:
await asyncio.wait_for(process.wait(), timeout=SIGINT_TIMEOUT)
except TimeoutError:
# If SIGINT didn't work, try SIGTERM
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=SIGTERM_TIMEOUT)
except TimeoutError:
# Last resort: SIGKILL
process.kill()
await process.wait()
@asynccontextmanager
async def run_compiled_context(
@@ -0,0 +1,40 @@
esphome:
name: test-autorepeat-filter
host:
api:
batch_delay: 0ms # Disable batching to receive every state transition
logger:
level: DEBUG
binary_sensor:
# The autorepeat filter is applied directly to the template sensor, so each
# write through `binary_sensor.template.publish` runs through the filter
# chain. With the source true the filter must oscillate after `delay`; once
# the source returns to false the filter must cancel both timers and emit a
# final false.
- platform: template
name: "Autorepeat Sensor"
id: autorepeat_sensor
filters:
- autorepeat:
- delay: 200ms
time_off: 100ms
time_on: 100ms
button:
- platform: template
name: "Press"
id: press_button
on_press:
- binary_sensor.template.publish:
id: autorepeat_sensor
state: true
- platform: template
name: "Release"
id: release_button
on_press:
- binary_sensor.template.publish:
id: autorepeat_sensor
state: false
@@ -1,5 +1,5 @@
esphome:
name: host-climate-test
name: host-climate-basic-state
host:
api:
logger:
@@ -10,6 +10,7 @@ climate:
name: Dual-mode Thermostat
sensor: host_thermostat_temperature_sensor
humidity_sensor: host_thermostat_humidity_sensor
on_boot_restore_from: default_preset
humidity_hysteresis: 1.0
min_cooling_off_time: 20s
min_cooling_run_time: 20s
@@ -28,10 +29,6 @@ climate:
min_temperature: 15.0
max_temperature: 32.0
temperature_step: 0.1
# Don't restore previous state from flash — this fixture shares the
# `host-climate-test` build dir with host_mode_climate_control.yaml, so a
# prior run of that test could leave the thermostat in HEAT/COOL.
on_boot_restore_from: default_preset
default_preset: home
preset:
- name: "away"

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