Merge branch 'dev' into sendspin-artwork

This commit is contained in:
Kevin Ahrendt
2026-05-07 07:24:22 -04:00
committed by GitHub
111 changed files with 3783 additions and 1467 deletions
+1 -1
View File
@@ -415,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
+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
@@ -843,22 +877,20 @@ def _make_crystal_freq_callback(
def upload_using_esptool(
config: ConfigType, port: str, file: str, speed: int
) -> str | int:
from esphome import platformio_api
first_baudrate = speed or config[CONF_ESPHOME][CONF_PLATFORMIO_OPTIONS].get(
"upload_speed", os.getenv("ESPHOME_UPLOAD_SPEED", "460800")
)
if file is not None:
flash_images = [platformio_api.FlashImage(path=file, offset="0x0")]
flash_images = [FlashImage(path=file, offset="0x0")]
else:
from esphome import platformio_api
idedata = platformio_api.get_idedata(config)
firmware_offset = "0x10000" if CORE.is_esp32 else "0x0"
flash_images = [
platformio_api.FlashImage(
path=idedata.firmware_bin_path, offset=firmware_offset
),
FlashImage(path=idedata.firmware_bin_path, offset=firmware_offset),
]
for image in idedata.extra_flash_images:
if not image.path.is_file():
@@ -1119,25 +1151,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):
@@ -1161,6 +1251,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
@@ -1881,6 +1993,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).",
@@ -1955,6 +2078,17 @@ def parse_args(argv):
help="Build with native ESP-IDF instead of PlatformIO (ESP32 esp-idf framework only).",
action="store_true",
)
parser_run.add_argument(
"--ota-platform",
choices=[CONF_ESPHOME, CONF_WEB_SERVER],
help=(
"OTA platform to use for network uploads. Defaults to "
f"'{CONF_ESPHOME}' (native API) when configured because it uses "
"challenge-response auth so the password is never sent in "
f"cleartext on the wire. Falls back to '{CONF_WEB_SERVER}' "
"(HTTP Basic auth) when that is the only configured platform."
),
)
parser_clean = subparsers.add_parser(
"clean-mqtt",
@@ -2167,8 +2301,9 @@ def run_esphome(argv):
CORE.config_path = conf_path
CORE.dashboard = args.dashboard
# For logs command, skip updating external components
skip_external = args.command == "logs"
# Commands that don't need fresh external components: logs just connects
# to the device, and clean is about to delete the build directory.
skip_external = args.command in ("logs", "clean")
config = read_config(
dict(args.substitution) if args.substitution else {},
skip_external_update=skip_external,
+7 -5
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)
+11 -72
View File
@@ -18,83 +18,22 @@ class APIConnection;
class ListEntitiesIterator final : public ComponentIterator {
public:
ListEntitiesIterator(APIConnection *client);
#ifdef USE_BINARY_SENSOR
bool on_binary_sensor(binary_sensor::BinarySensor *entity) override;
#endif
#ifdef USE_COVER
bool on_cover(cover::Cover *entity) override;
#endif
#ifdef USE_FAN
bool on_fan(fan::Fan *entity) override;
#endif
#ifdef USE_LIGHT
bool on_light(light::LightState *entity) override;
#endif
#ifdef USE_SENSOR
bool on_sensor(sensor::Sensor *entity) override;
#endif
#ifdef USE_SWITCH
bool on_switch(switch_::Switch *entity) override;
#endif
#ifdef USE_BUTTON
bool on_button(button::Button *entity) override;
#endif
#ifdef USE_TEXT_SENSOR
bool on_text_sensor(text_sensor::TextSensor *entity) override;
#endif
// Entity overrides (generated from entity_types.h).
// All implementations live in list_entities.cpp via LIST_ENTITIES_HANDLER.
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) bool on_##singular(type *entity) override;
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
#ifdef USE_API_USER_DEFINED_ACTIONS
bool on_service(UserServiceDescriptor *service) override;
#endif
#ifdef USE_CAMERA
bool on_camera(camera::Camera *entity) override;
#endif
#ifdef USE_CLIMATE
bool on_climate(climate::Climate *entity) override;
#endif
#ifdef USE_NUMBER
bool on_number(number::Number *entity) override;
#endif
#ifdef USE_DATETIME_DATE
bool on_date(datetime::DateEntity *entity) override;
#endif
#ifdef USE_DATETIME_TIME
bool on_time(datetime::TimeEntity *entity) override;
#endif
#ifdef USE_DATETIME_DATETIME
bool on_datetime(datetime::DateTimeEntity *entity) override;
#endif
#ifdef USE_TEXT
bool on_text(text::Text *entity) override;
#endif
#ifdef USE_SELECT
bool on_select(select::Select *entity) override;
#endif
#ifdef USE_LOCK
bool on_lock(lock::Lock *entity) override;
#endif
#ifdef USE_VALVE
bool on_valve(valve::Valve *entity) override;
#endif
#ifdef USE_MEDIA_PLAYER
bool on_media_player(media_player::MediaPlayer *entity) override;
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override;
#endif
#ifdef USE_WATER_HEATER
bool on_water_heater(water_heater::WaterHeater *entity) override;
#endif
#ifdef USE_INFRARED
bool on_infrared(infrared::Infrared *entity) override;
#endif
#ifdef USE_RADIO_FREQUENCY
bool on_radio_frequency(radio_frequency::RadioFrequency *entity) override;
#endif
#ifdef USE_EVENT
bool on_event(event::Event *entity) override;
#endif
#ifdef USE_UPDATE
bool on_update(update::UpdateEntity *entity) override;
#endif
bool on_end() override;
+4 -1
View File
@@ -67,7 +67,10 @@ INITIAL_STATE_HANDLER(water_heater, water_heater::WaterHeater)
INITIAL_STATE_HANDLER(update, update::UpdateEntity)
#endif
// Special cases (button and event) are already defined inline in subscribe_state.h
// event is an ENTITY_CONTROLLER_TYPE_ but has no state to send.
#ifdef USE_EVENT
bool InitialStateIterator::on_event(event::Event *entity) { return true; }
#endif
InitialStateIterator::InitialStateIterator(APIConnection *client) : client_(client) {}
+14 -72
View File
@@ -19,78 +19,20 @@ class APIConnection;
class InitialStateIterator final : public ComponentIterator {
public:
InitialStateIterator(APIConnection *client);
#ifdef USE_BINARY_SENSOR
bool on_binary_sensor(binary_sensor::BinarySensor *entity) override;
#endif
#ifdef USE_COVER
bool on_cover(cover::Cover *entity) override;
#endif
#ifdef USE_FAN
bool on_fan(fan::Fan *entity) override;
#endif
#ifdef USE_LIGHT
bool on_light(light::LightState *entity) override;
#endif
#ifdef USE_SENSOR
bool on_sensor(sensor::Sensor *entity) override;
#endif
#ifdef USE_SWITCH
bool on_switch(switch_::Switch *entity) override;
#endif
#ifdef USE_BUTTON
bool on_button(button::Button *button) override { return true; };
#endif
#ifdef USE_TEXT_SENSOR
bool on_text_sensor(text_sensor::TextSensor *entity) override;
#endif
#ifdef USE_CLIMATE
bool on_climate(climate::Climate *entity) override;
#endif
#ifdef USE_NUMBER
bool on_number(number::Number *entity) override;
#endif
#ifdef USE_DATETIME_DATE
bool on_date(datetime::DateEntity *entity) override;
#endif
#ifdef USE_DATETIME_TIME
bool on_time(datetime::TimeEntity *entity) override;
#endif
#ifdef USE_DATETIME_DATETIME
bool on_datetime(datetime::DateTimeEntity *entity) override;
#endif
#ifdef USE_TEXT
bool on_text(text::Text *entity) override;
#endif
#ifdef USE_SELECT
bool on_select(select::Select *entity) override;
#endif
#ifdef USE_LOCK
bool on_lock(lock::Lock *entity) override;
#endif
#ifdef USE_VALVE
bool on_valve(valve::Valve *entity) override;
#endif
#ifdef USE_MEDIA_PLAYER
bool on_media_player(media_player::MediaPlayer *entity) override;
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override;
#endif
#ifdef USE_WATER_HEATER
bool on_water_heater(water_heater::WaterHeater *entity) override;
#endif
#ifdef USE_INFRARED
bool on_infrared(infrared::Infrared *infrared) override { return true; };
#endif
#ifdef USE_RADIO_FREQUENCY
bool on_radio_frequency(radio_frequency::RadioFrequency *radio_frequency) override { return true; };
#endif
#ifdef USE_EVENT
bool on_event(event::Event *event) override { return true; };
#endif
#ifdef USE_UPDATE
bool on_update(update::UpdateEntity *entity) override;
#endif
// Entity overrides (generated from entity_types.h).
// ENTITY_TYPE_ entities have no state to send and default to a no-op.
// ENTITY_CONTROLLER_TYPE_ entities are implemented in subscribe_state.cpp via INITIAL_STATE_HANDLER,
// except on_event which has no state (defined out-of-line in subscribe_state.cpp).
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
bool on_##singular(type *entity) override { return true; }
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
bool on_##singular(type *entity) override;
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
protected:
APIConnection *client_;
+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 -6
View File
@@ -161,13 +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;
cf_cnt |= this->prev_cf_cnt_ & 0xff000000;
if (cf_cnt < this->prev_cf_cnt_) {
cf_cnt += 0x1000000;
}
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 {
@@ -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 += ' ';
+76
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
@@ -2515,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
+115
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
@@ -419,3 +420,117 @@ def copy_files() -> None:
remove_float_scanf_file,
CORE.relative_build_path("remove_float_scanf.py"),
)
# ESP logs stack trace decoder, based on https://github.com/me-no-dev/EspExceptionDecoder
ESP8266_EXCEPTION_CODES = {
0: "Illegal instruction (Is the flash damaged?)",
1: "SYSCALL instruction",
2: "InstructionFetchError: Processor internal physical address or data error during "
"instruction fetch",
3: "LoadStoreError: Processor internal physical address or data error during load or store",
4: "Level1Interrupt: Level-1 interrupt as indicated by set level-1 bits in the INTERRUPT "
"register",
5: "Alloca: MOVSP instruction, if caller's registers are not in the register file",
6: "Integer Divide By Zero",
7: "reserved",
8: "Privileged: Attempt to execute a privileged operation when CRING ? 0",
9: "LoadStoreAlignmentCause: Load or store to an unaligned address",
10: "reserved",
11: "reserved",
12: "InstrPIFDataError: PIF data error during instruction fetch",
13: "LoadStorePIFDataError: Synchronous PIF data error during LoadStore access",
14: "InstrPIFAddrError: PIF address error during instruction fetch",
15: "LoadStorePIFAddrError: Synchronous PIF address error during LoadStore access",
16: "InstTLBMiss: Error during Instruction TLB refill",
17: "InstTLBMultiHit: Multiple instruction TLB entries matched",
18: "InstFetchPrivilege: An instruction fetch referenced a virtual address at a ring level "
"less than CRING",
19: "reserved",
20: "InstFetchProhibited: An instruction fetch referenced a page mapped with an attribute "
"that does not permit instruction fetch",
21: "reserved",
22: "reserved",
23: "reserved",
24: "LoadStoreTLBMiss: Error during TLB refill for a load or store",
25: "LoadStoreTLBMultiHit: Multiple TLB entries matched for a load or store",
26: "LoadStorePrivilege: A load or store referenced a virtual address at a ring level less "
"than ",
27: "reserved",
28: "Access to invalid address: LOAD (wild pointer?)",
29: "Access to invalid address: STORE (wild pointer?)",
}
def _decode_pc(config, addr):
from esphome import platformio_api
idedata = platformio_api.get_idedata(config)
if not idedata.addr2line_path or not idedata.firmware_elf_path:
_LOGGER.debug("decode_pc no addr2line")
return
command = [idedata.addr2line_path, "-pfiaC", "-e", idedata.firmware_elf_path, addr]
try:
translation = subprocess.check_output(command, close_fds=False).decode().strip()
except Exception: # pylint: disable=broad-except
_LOGGER.debug("Caught exception for command %s", command, exc_info=1)
return
if "?? ??:0" in translation:
# Nothing useful
return
translation = translation.replace(" at ??:?", "").replace(":?", "")
_LOGGER.warning("Decoded %s", translation)
def _parse_register(config, regex, line):
match = regex.match(line)
if match is not None:
_decode_pc(config, match.group(1))
STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):")
STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})")
STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})")
STACKTRACE_BAD_ALLOC_RE = re.compile(
r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$"
)
STACKTRACE_ESP8266_BACKTRACE_PC_RE = re.compile(r"4[0-9a-f]{7}")
def process_stacktrace(config, line, backtrace_state):
line = line.strip()
# ESP8266 Exception type
match = re.match(STACKTRACE_ESP8266_EXCEPTION_TYPE_RE, line)
if match is not None:
code = int(match.group(1))
_LOGGER.warning(
"Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown")
)
# ESP8266 PC/EXCVADDR
_parse_register(config, STACKTRACE_ESP8266_PC_RE, line)
_parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line)
# bad alloc
match = re.match(STACKTRACE_BAD_ALLOC_RE, line)
if match is not None:
_LOGGER.warning(
"Memory allocation of %s bytes failed at %s", match.group(2), match.group(1)
)
_decode_pc(config, match.group(1))
# ESP8266 multi-line backtrace
if ">>>stack>>>" in line:
# Start of backtrace
backtrace_state = True
_LOGGER.warning("Found stack trace! Trying to decode it")
elif "<<<stack<<<" in line:
# End of backtrace
backtrace_state = False
if backtrace_state:
for addr in re.finditer(STACKTRACE_ESP8266_BACKTRACE_PC_RE, line):
_decode_pc(config, addr.group())
return backtrace_state
@@ -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,
+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)
+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 {
@@ -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) {
@@ -167,5 +167,9 @@ bool ListEntitiesIterator::on_update(update::UpdateEntity *obj) {
}
#endif
#ifdef USE_MEDIA_PLAYER
bool ListEntitiesIterator::on_media_player(media_player::MediaPlayer *obj) { return true; }
#endif
} // namespace esphome::web_server
#endif
+11 -72
View File
@@ -24,78 +24,17 @@ class ListEntitiesIterator final : public ComponentIterator {
#elif defined(USE_ARDUINO)
ListEntitiesIterator(const WebServer *ws, DeferredUpdateEventSource *es);
#endif
#ifdef USE_BINARY_SENSOR
bool on_binary_sensor(binary_sensor::BinarySensor *obj) override;
#endif
#ifdef USE_COVER
bool on_cover(cover::Cover *obj) override;
#endif
#ifdef USE_FAN
bool on_fan(fan::Fan *obj) override;
#endif
#ifdef USE_LIGHT
bool on_light(light::LightState *obj) override;
#endif
#ifdef USE_SENSOR
bool on_sensor(sensor::Sensor *obj) override;
#endif
#ifdef USE_SWITCH
bool on_switch(switch_::Switch *obj) override;
#endif
#ifdef USE_BUTTON
bool on_button(button::Button *obj) override;
#endif
#ifdef USE_TEXT_SENSOR
bool on_text_sensor(text_sensor::TextSensor *obj) override;
#endif
#ifdef USE_CLIMATE
bool on_climate(climate::Climate *obj) override;
#endif
#ifdef USE_NUMBER
bool on_number(number::Number *obj) override;
#endif
#ifdef USE_DATETIME_DATE
bool on_date(datetime::DateEntity *obj) override;
#endif
#ifdef USE_DATETIME_TIME
bool on_time(datetime::TimeEntity *obj) override;
#endif
#ifdef USE_DATETIME_DATETIME
bool on_datetime(datetime::DateTimeEntity *obj) override;
#endif
#ifdef USE_TEXT
bool on_text(text::Text *obj) override;
#endif
#ifdef USE_SELECT
bool on_select(select::Select *obj) override;
#endif
#ifdef USE_LOCK
bool on_lock(lock::Lock *obj) override;
#endif
#ifdef USE_VALVE
bool on_valve(valve::Valve *obj) override;
#endif
#ifdef USE_MEDIA_PLAYER
bool on_media_player(media_player::MediaPlayer *obj) override { return true; }
#endif
#ifdef USE_ALARM_CONTROL_PANEL
bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *obj) override;
#endif
#ifdef USE_WATER_HEATER
bool on_water_heater(water_heater::WaterHeater *obj) override;
#endif
#ifdef USE_INFRARED
bool on_infrared(infrared::Infrared *obj) override;
#endif
#ifdef USE_RADIO_FREQUENCY
bool on_radio_frequency(radio_frequency::RadioFrequency *obj) override;
#endif
#ifdef USE_EVENT
bool on_event(event::Event *obj) override;
#endif
#ifdef USE_UPDATE
bool on_update(update::UpdateEntity *obj) override;
#endif
// Entity overrides (generated from entity_types.h).
// Implementations live in list_entities.cpp.
// NOLINTBEGIN(bugprone-macro-parentheses)
#define ENTITY_TYPE_(type, singular, plural, count, upper) bool on_##singular(type *obj) override;
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
ENTITY_TYPE_(type, singular, plural, count, upper)
#include "esphome/core/entity_types.h"
#undef ENTITY_TYPE_
#undef ENTITY_CONTROLLER_TYPE_
// NOLINTEND(bugprone-macro-parentheses)
bool completed() { return this->state_ == IteratorState::NONE; }
protected:
-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,
+7
View File
@@ -25,6 +25,10 @@ namespace esphome {
static const char *const TAG = "app";
// Delay after setup() finishes before trimming the scheduler freelist of its post-boot peak.
// 10 s is well past the bulk of post-setup async work (Wi-Fi/MQTT connects, first-read latency).
static constexpr uint32_t SCHEDULER_FREELIST_TRIM_DELAY_MS = 10000;
// Helper function for insertion sort of components by priority
// Using insertion sort instead of std::stable_sort saves ~1.3KB of flash
// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas)
@@ -112,6 +116,9 @@ void Application::setup() {
ESP_LOGI(TAG, "setup() finished successfully!");
// Trim the scheduler freelist of its post-boot peak once startup churn settles.
this->scheduler.set_timeout(this, SCHEDULER_FREELIST_TRIM_DELAY_MS, [this]() { this->scheduler.trim_freelist(); });
#ifdef USE_SETUP_PRIORITY_OVERRIDE
// Clear setup priority overrides to free memory
clear_setup_priority_overrides();
+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.
*
+63 -34
View File
@@ -14,18 +14,8 @@ namespace esphome {
static const char *const TAG = "scheduler";
// Memory pool configuration constants
// Pool size of 5 matches typical usage patterns (2-4 active timers)
// - Minimal memory overhead (~250 bytes on ESP32)
// - Sufficient for most configs with a couple sensors/components
// - Still prevents heap fragmentation and allocation stalls
// - Complex setups with many timers will just allocate beyond the pool
// See https://github.com/esphome/backlog/issues/52
static constexpr size_t MAX_POOL_SIZE = 5;
// Maximum number of logically deleted (cancelled) items before forcing cleanup.
// Set to 5 to match the pool size - when we have as many cancelled items as our
// pool can hold, it's time to clean up and recycle them.
// Empirically chosen to balance cleanup overhead against tombstone accumulation in items_.
static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5;
// max delay to start an interval sequence
static constexpr uint32_t MAX_INTERVAL_DELAY = 5000;
@@ -165,7 +155,7 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
delay = 1;
}
// Take lock early to protect scheduler_item_pool_ access and retry-cancelled check
// Take lock early to protect scheduler_item_pool_head_ access and retry-cancelled check
LockGuard guard{this->lock_};
// For retries, check if there's a cancelled timeout first - before allocating an item.
@@ -599,7 +589,7 @@ uint32_t HOT Scheduler::call(uint32_t now) {
if (now_64 - last_print > 2000) {
last_print = now_64;
std::vector<SchedulerItem *> old_items;
ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64, this->items_.size(), this->scheduler_item_pool_.size(),
ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64, this->items_.size(), this->scheduler_item_pool_size_,
now_64);
// Cleanup before debug output
this->cleanup_();
@@ -894,30 +884,68 @@ bool HOT Scheduler::SchedulerItem::cmp(SchedulerItem *a, SchedulerItem *b) {
: (a->next_execution_high_ > b->next_execution_high_);
}
// Recycle a SchedulerItem back to the pool for reuse.
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
// This protects scheduler_item_pool_ from concurrent access by other threads
// that may be acquiring items from the pool in set_timer_common_().
// Recycle a SchedulerItem back to the freelist for reuse.
// IMPORTANT: Caller must hold the scheduler lock.
void Scheduler::recycle_item_main_loop_(SchedulerItem *item) {
if (item == nullptr)
return;
if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) {
// Clear callback to release captured resources
item->callback = nullptr;
this->scheduler_item_pool_.push_back(item);
item->callback = nullptr; // release captured resources
item->next_free = this->scheduler_item_pool_head_;
this->scheduler_item_pool_head_ = item;
this->scheduler_item_pool_size_++;
#ifdef ESPHOME_DEBUG_SCHEDULER
ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size());
#endif
} else {
#ifdef ESPHOME_DEBUG_SCHEDULER
ESP_LOGD(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size());
ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_size_);
#endif
}
// Shrink a SchedulerItem* vector's capacity to its current size.
// std::vector::shrink_to_fit() is non-binding and our toolchain ignores it; the classic
// swap-with-copy idiom (std::vector<T>(other).swap(other)) instantiates the iterator-range
// constructor which pulls in std::__throw_bad_array_new_length and ~120 B of related
// stdlib RTTI/typeinfo. Build into a temp via reserve + push_back instead, then move-assign:
// reserve uses operator new (throws bad_alloc, already linked) and push_back without growth
// is the noexcept tail path. Move-assign just swaps pointers.
// Out-of-line + noinline so the callers in trim_freelist() share one body.
void __attribute__((noinline)) Scheduler::shrink_scheduler_vector_(std::vector<SchedulerItem *> *v) {
if (v->capacity() == v->size())
return; // already exact, common after a quiet period
std::vector<SchedulerItem *> tmp;
tmp.reserve(v->size());
for (SchedulerItem *p : *v)
tmp.push_back(p);
*v = std::move(tmp);
}
void Scheduler::trim_freelist() {
LockGuard guard{this->lock_};
SchedulerItem *item = this->scheduler_item_pool_head_;
size_t freed = 0;
while (item != nullptr) {
SchedulerItem *next = item->next_free;
delete item;
#ifdef ESPHOME_DEBUG_SCHEDULER
this->debug_live_items_--;
#endif
item = next;
freed++;
}
this->scheduler_item_pool_head_ = nullptr;
this->scheduler_item_pool_size_ = 0;
// items_/to_add_/defer_queue_ retain their boot-peak vector capacity (vector grows
// by doubling and otherwise keeps the peak). Reclaim that slack as well.
shrink_scheduler_vector_(&this->items_);
shrink_scheduler_vector_(&this->to_add_);
#ifndef ESPHOME_THREAD_SINGLE
shrink_scheduler_vector_(&this->defer_queue_);
#endif
#ifdef ESPHOME_DEBUG_SCHEDULER
ESP_LOGD(TAG, "Freelist trimmed (%zu items freed)", freed);
#else
(void) freed;
#endif
}
#ifdef ESPHOME_DEBUG_SCHEDULER
@@ -942,14 +970,15 @@ void Scheduler::debug_log_timer_(const SchedulerItem *item, NameType name_type,
}
#endif /* ESPHOME_DEBUG_SCHEDULER */
// Helper to get or create a scheduler item from the pool
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
// Pop from freelist or allocate. IMPORTANT: caller must hold the lock and must overwrite
// `item->component` before releasing it -- the popped slot still holds the freelist link.
Scheduler::SchedulerItem *Scheduler::get_item_from_pool_locked_() {
if (!this->scheduler_item_pool_.empty()) {
SchedulerItem *item = this->scheduler_item_pool_.back();
this->scheduler_item_pool_.pop_back();
if (this->scheduler_item_pool_head_ != nullptr) {
SchedulerItem *item = this->scheduler_item_pool_head_;
this->scheduler_item_pool_head_ = item->next_free;
this->scheduler_item_pool_size_--;
#ifdef ESPHOME_DEBUG_SCHEDULER
ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size());
ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_size_);
#endif
return item;
}
@@ -967,7 +996,7 @@ Scheduler::SchedulerItem *Scheduler::get_item_from_pool_locked_() {
bool Scheduler::debug_verify_no_leak_() const {
// Invariant: every live SchedulerItem must be in exactly one container.
// debug_live_items_ tracks allocations minus deletions.
size_t accounted = this->items_.size() + this->to_add_.size() + this->scheduler_item_pool_.size();
size_t accounted = this->items_.size() + this->to_add_.size() + this->scheduler_item_pool_size_;
#ifndef ESPHOME_THREAD_SINGLE
accounted += this->defer_queue_.size();
#endif
@@ -981,7 +1010,7 @@ bool Scheduler::debug_verify_no_leak_() const {
")",
static_cast<uint32_t>(this->debug_live_items_), static_cast<uint32_t>(accounted),
static_cast<uint32_t>(this->items_.size()), static_cast<uint32_t>(this->to_add_.size()),
static_cast<uint32_t>(this->scheduler_item_pool_.size())
static_cast<uint32_t>(this->scheduler_item_pool_size_)
#ifndef ESPHOME_THREAD_SINGLE
,
static_cast<uint32_t>(this->defer_queue_.size())
+22 -12
View File
@@ -132,6 +132,12 @@ class Scheduler {
// @return Timestamp of the last item that ran, or `now` unchanged if none ran.
uint32_t call(uint32_t now);
// Reclaim memory held by the post-boot peak. Frees every SchedulerItem in the
// recycle freelist and shrinks items_/to_add_/defer_queue_ vector capacity to
// their current sizes (std::vector grows by doubling and otherwise retains the
// peak). Live items in those vectors are preserved.
void trim_freelist();
// Move items from to_add_ into the main heap.
// IMPORTANT: This method should only be called from the main thread (loop task).
// Inlined: the fast path (nothing to add) is just an atomic load / empty check.
@@ -177,8 +183,12 @@ class Scheduler {
protected:
struct SchedulerItem {
// Ordered by size to minimize padding
Component *component;
// Ordered by size to minimize padding.
// `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive).
union {
Component *component;
SchedulerItem *next_free;
};
// Optimized name storage using tagged union - zero heap allocation
union {
const char *static_name; // For STATIC_STRING (string literals) and SELF_POINTER (caller's `this`)
@@ -355,6 +365,10 @@ class Scheduler {
SchedulerItem *get_item_from_pool_locked_();
private:
// Out-of-line helper that shrinks a SchedulerItem* vector's capacity to its current
// size. Centralised so trim_freelist() doesn't pay flash cost per call site.
void shrink_scheduler_vector_(std::vector<SchedulerItem *> *v);
// Helper to cancel matching items - must be called with lock held.
// When find_first=true, stops after the first match (used by set_timer_common_ where
// the cancel-before-add invariant guarantees at most one match).
@@ -713,19 +727,15 @@ class Scheduler {
#endif
}
// Memory pool for recycling SchedulerItem objects to reduce heap churn.
// Design decisions:
// - std::vector is used instead of a fixed array because many systems only need 1-2 scheduler items
// - The vector grows dynamically up to MAX_POOL_SIZE (5) only when needed, saving memory on simple setups
// - Pool size of 5 matches typical usage (2-4 timers) while keeping memory overhead low (~250 bytes on ESP32)
// - The pool significantly reduces heap fragmentation which is critical because heap allocation/deallocation
// can stall the entire system, causing timing issues and dropped events for any components that need
// to synchronize between tasks (see https://github.com/esphome/backlog/issues/52)
std::vector<SchedulerItem *> scheduler_item_pool_;
// Intrusive freelist threaded through SchedulerItem::next_free. Unbounded so it quiesces at the
// app's concurrent-timer high-water mark; the previous fixed cap caused steady-state new/delete
// churn on devices with many timers (see https://github.com/esphome/backlog/issues/52).
SchedulerItem *scheduler_item_pool_head_{nullptr};
size_t scheduler_item_pool_size_{0};
#ifdef ESPHOME_DEBUG_SCHEDULER
// Leak detection: tracks total live SchedulerItem allocations.
// Invariant: debug_live_items_ == items_.size() + to_add_.size() + defer_queue_.size() + scheduler_item_pool_.size()
// Invariant: debug_live_items_ == items_.size() + to_add_.size() + defer_queue_.size() + scheduler_item_pool_size_
// Verified periodically in call() to catch leaks early.
size_t debug_live_items_{0};
+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",
}
+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:
+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
+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 "
+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(
+5 -5
View File
@@ -101,8 +101,8 @@ static void Scheduler_SetTimeout(benchmark::State &state) {
Component dummy_component;
// Register 3 timeouts then call() — realistic worst case where multiple
// components schedule in the same loop iteration. Keeps item count within
// the recycling pool (MAX_POOL_SIZE=5) to avoid spurious malloc/free.
// components schedule in the same loop iteration. warm_pool fills the
// freelist so acquire/recycle never falls back to malloc.
static constexpr int kBatchSize = 3;
static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize");
warm_pool(scheduler, &dummy_component, kBatchSize, 1000);
@@ -209,9 +209,9 @@ static void Scheduler_SetTimeout_ExceedPool(benchmark::State &state) {
Scheduler scheduler;
Component dummy_component;
// Register 10 timeouts then call() — exceeds MAX_POOL_SIZE=5 to measure
// the performance cliff when the recycling pool is exhausted and items
// must be malloc'd/freed.
// Register 10 timeouts then call() — larger working set than the 3-item
// batches above. With the unbounded freelist, warm_pool preallocates 10
// items so this measures steady-state, not malloc cliff.
static constexpr int kBatchSize = 10;
static_assert(kInnerIterations % kBatchSize == 0, "kInnerIterations must be divisible by kBatchSize");
warm_pool(scheduler, &dummy_component, kBatchSize, 1000);
@@ -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
+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,25 @@
esphome:
name: safe-mode-loop-runs
host:
logger:
safe_mode:
num_attempts: 10
on_safe_mode:
- lambda: |-
// Spawn a detached thread that logs a unique marker. The
// non-main-thread log goes through the task log buffer, which
// is only drained by Logger::loop(). If looping components
// weren't initialized (the bug fixed in #16269), the buffer is
// never read and the marker never reaches the console.
struct MarkerThread {
static void *thread_func(void *) {
ESP_LOGI("safe_mode_test", "looping component ran in safe mode");
return nullptr;
}
};
pthread_t t;
pthread_create(&t, nullptr, MarkerThread::thread_func, nullptr);
pthread_detach(t);
@@ -221,14 +221,10 @@ script:
- id: test_full_pool_reuse
then:
- lambda: |-
ESP_LOGI("test", "Phase 6: Testing pool size limits after Phase 5 items complete");
ESP_LOGI("test", "Phase 6: Testing pool reuse after Phase 5 items complete");
// At this point, all Phase 5 timeouts should have completed and been recycled.
// The pool should be at its maximum size (5).
// Creating 10 new items tests that:
// - First 5 items reuse from the pool
// - Remaining 5 items allocate new (pool empty)
// - Pool doesn't grow beyond MAX_POOL_SIZE of 5
// Phase 5 timeouts have completed and been recycled. The freelist is unbounded;
// creating 10 new items reuses from it and only allocates fresh when empty.
auto *component = id(test_sensor);
int full_reuse_count = 10;
+39
View File
@@ -0,0 +1,39 @@
"""Helpers for manipulating the host platform's preferences file.
ESPHome's host platform stores preferences in
``~/.esphome/prefs/<app_name>.prefs`` using a simple binary layout that
mirrors ``HostPreferences::sync()``:
``[uint32_t key][uint8_t len][uint8_t data[len]]`` per entry.
Tests use these helpers to pre-populate state the binary will see at
boot (e.g. forcing safe mode) or to clear stale state between runs.
"""
from __future__ import annotations
from pathlib import Path
import struct
def host_prefs_path(device_name: str) -> Path:
"""Return the on-disk prefs file path for a host-platform device."""
return Path.home() / ".esphome" / "prefs" / f"{device_name}.prefs"
def clear_host_prefs(device_name: str) -> None:
"""Delete the prefs file for a host-platform device, if it exists."""
host_prefs_path(device_name).unlink(missing_ok=True)
def write_host_pref(device_name: str, key: int, data: bytes) -> Path:
"""Write a single preference entry, replacing the file's contents.
Returns the path that was written.
"""
if len(data) > 255:
raise ValueError(f"Preference data too long: {len(data)} bytes (max 255)")
path = host_prefs_path(device_name)
path.parent.mkdir(parents=True, exist_ok=True)
payload = struct.pack("<IB", key, len(data)) + data
path.write_bytes(payload)
return path
@@ -0,0 +1,94 @@
"""Regression test for safe_mode + looping_components init ordering.
Reproduces the bug fixed in https://github.com/esphome/esphome/pull/16269:
``App.looping_components_.init(...)`` was emitted at ``CoroPriority.FINAL``,
which placed it *after* the ``safe_mode`` early-return in ``setup_app()``.
When safe mode was entered, the ``FixedVector`` backing the looping-component
list was never sized, ``looping_components_active_end_`` stayed at 0, and
``loop()`` iterated zero components -- so any looping component above
``CoroPriority.APPLICATION`` (e.g. wifi, logger) never ran.
The test forces safe mode by writing ``ENTER_SAFE_MODE_MAGIC`` to the host
preferences file before booting, then asserts that ``Logger::loop()`` runs
by logging from a non-main thread. Non-main-thread logs are buffered in
``TaskLogBuffer`` and only emitted to the console when ``Logger::loop()``
drains the buffer. Without the fix, the marker stays in the buffer
forever; with the fix, it reaches the console.
The API server (``CoroPriority.WEB``, 40) is registered below safe_mode
(``CoroPriority.APPLICATION``, 50), so it's never set up when safe mode
is active and ``run_compiled`` would hang waiting for the API port.
This test uses ``run_binary`` directly to skip the port wait.
"""
from __future__ import annotations
import asyncio
import re
import struct
import pytest
from .conftest import run_binary
from .host_prefs import clear_host_prefs, write_host_pref
from .types import CompileFunction, ConfigWriter
# Must match esphome::safe_mode::RTC_KEY in safe_mode.h
SAFE_MODE_RTC_KEY = 233825507
# Must match esphome::safe_mode::SafeModeComponent::ENTER_SAFE_MODE_MAGIC
ENTER_SAFE_MODE_MAGIC = 0x5AFE5AFE
DEVICE_NAME = "safe-mode-loop-runs"
THREAD_LOG_MARKER = "looping component ran in safe mode"
@pytest.mark.asyncio
async def test_safe_mode_loop_runs(
yaml_config: str,
write_yaml_config: ConfigWriter,
compile_esphome: CompileFunction,
) -> None:
"""When safe mode is active, ``App.loop()`` must still iterate looping
components -- proven here by a thread-logged marker reaching the
console (which requires ``Logger::loop()`` to run)."""
config_path = await write_yaml_config(yaml_config)
binary_path = await compile_esphome(config_path)
# Compile finished successfully; pre-populate prefs so the *next* run
# enters safe mode immediately.
write_host_pref(
DEVICE_NAME, SAFE_MODE_RTC_KEY, struct.pack("<I", ENTER_SAFE_MODE_MAGIC)
)
try:
loop = asyncio.get_running_loop()
safe_mode_active = loop.create_future()
thread_log_seen = loop.create_future()
safe_mode_pattern = re.compile(r"SAFE MODE IS ACTIVE")
thread_log_pattern = re.compile(re.escape(THREAD_LOG_MARKER))
def on_log(line: str) -> None:
if not safe_mode_active.done() and safe_mode_pattern.search(line):
safe_mode_active.set_result(True)
if not thread_log_seen.done() and thread_log_pattern.search(line):
thread_log_seen.set_result(True)
async with run_binary(binary_path, line_callback=on_log):
try:
await asyncio.wait_for(safe_mode_active, timeout=15.0)
except TimeoutError:
pytest.fail(
"Did not observe 'SAFE MODE IS ACTIVE' -- safe mode "
"didn't trigger, so this test isn't exercising the bug."
)
try:
await asyncio.wait_for(thread_log_seen, timeout=10.0)
except TimeoutError:
pytest.fail(
f"Did not observe thread-logged marker {THREAD_LOG_MARKER!r} "
"within timeout. Logger::loop() never drained the task "
"log buffer, meaning App.looping_components_ was never "
"sized -- this is the regression #16269 fixed."
)
finally:
clear_host_prefs(DEVICE_NAME)
+15 -9
View File
@@ -180,16 +180,22 @@ async def test_scheduler_pool(
# Verify pool behavior
assert pool_recycle_count > 0, "Should have recycled items to pool"
# Check pool metrics
if pool_recycle_count > 0:
max_pool_size = 0
for line in log_lines:
if match := recycle_pattern.search(line):
size = int(match.group(1))
max_pool_size = max(max_pool_size, size)
# Pool is unbounded; the cap was the source of the churn it was meant to prevent.
assert pool_full_count == 0, (
f"Pool should never report full (got {pool_full_count})"
)
# Pool can grow up to its maximum of 5
assert max_pool_size <= 5, f"Pool grew beyond maximum ({max_pool_size})"
# Verify the pool actually grew past the old MAX_POOL_SIZE=5 cap.
# Phase 5 + Phase 6 schedule 8 + 10 same-component timeouts respectively, so the
# observed peak should comfortably exceed 5. Without this lower-bound check, a
# silent regression that re-introduced a small cap could pass the test above.
max_pool_size = 0
for line in log_lines:
if match := recycle_pattern.search(line):
max_pool_size = max(max_pool_size, int(match.group(1)))
assert max_pool_size > 5, (
f"Pool should grow past the old cap of 5; observed peak {max_pool_size}"
)
# Log summary for debugging
print("\nScheduler Pool Test Summary (Python Orchestrated):")
+6 -9
View File
@@ -9,7 +9,6 @@ Tests that:
from __future__ import annotations
import asyncio
from pathlib import Path
import socket
from typing import Any
@@ -17,9 +16,12 @@ from aioesphomeapi import TextInfo, TextState
import pytest
from .conftest import run_binary_and_wait_for_port, wait_and_connect_api_client
from .host_prefs import clear_host_prefs
from .state_utils import InitialStateHelper, require_entity
from .types import CompileFunction, ConfigWriter
DEVICE_NAME = "host-template-text-save-test"
@pytest.mark.asyncio
async def test_template_text_save(
@@ -32,11 +34,7 @@ async def test_template_text_save(
port, port_socket = reserved_tcp_port
# Clean up any stale preference file from previous runs
prefs_file = (
Path.home() / ".esphome" / "prefs" / "host-template-text-save-test.prefs"
)
if prefs_file.exists():
prefs_file.unlink()
clear_host_prefs(DEVICE_NAME)
# Write and compile once
config_path = await write_yaml_config(yaml_config)
@@ -59,7 +57,7 @@ async def test_template_text_save(
wait_and_connect_api_client(port=port) as client,
):
device_info = await client.device_info()
assert device_info.name == "host-template-text-save-test"
assert device_info.name == DEVICE_NAME
entities, _ = await client.list_entities_services()
text_entity = require_entity(
@@ -127,5 +125,4 @@ async def test_template_text_save(
)
# Clean up preference file
if prefs_file.exists():
prefs_file.unlink()
clear_host_prefs(DEVICE_NAME)
+12 -11
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from unittest.mock import patch
from esphome.components import esp32
from esphome.components.api import client as api_client
from esphome.core import EsphomeError
@@ -18,11 +19,11 @@ def test_decoder_swallows_esphome_error() -> None:
reconnect.
"""
config = {"esphome": {"name": "test"}}
processor = api_client._LogLineProcessor(config, None)
with patch.object(
api_client, "process_stacktrace", side_effect=EsphomeError("no idedata")
esp32, "process_stacktrace", side_effect=EsphomeError("no idedata")
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
assert mock_process.called
@@ -47,9 +48,9 @@ def test_decoder_warning_uses_fallback_for_empty_error(caplog) -> None:
must show a useful explanation rather than empty parens.
"""
config = {"esphome": {"name": "test"}}
processor = api_client._LogLineProcessor(config, None)
with patch.object(api_client, "process_stacktrace", side_effect=EsphomeError()):
with patch.object(esp32, "process_stacktrace", side_effect=EsphomeError()):
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
warnings = [r.message for r in caplog.records if r.levelname == "WARNING"]
@@ -65,11 +66,11 @@ def test_decoder_short_circuits_after_failure() -> None:
stall log streaming.
"""
config = {"esphome": {"name": "test"}}
processor = api_client._LogLineProcessor(config, None)
with patch.object(
api_client, "process_stacktrace", side_effect=EsphomeError("no idedata")
esp32, "process_stacktrace", side_effect=EsphomeError("no idedata")
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line("PC: 0x4010496e")
processor.process_line("BT0: 0x4010496e")
processor.process_line("BT1: 0x401049aa")
@@ -80,18 +81,18 @@ def test_decoder_short_circuits_after_failure() -> None:
def test_decoder_threads_backtrace_state() -> None:
"""When decoding succeeds, backtrace_state is threaded across calls."""
config = {"esphome": {"name": "test"}}
processor = api_client._LogLineProcessor(config, None)
with patch.object(
api_client, "process_stacktrace", side_effect=[True, False]
esp32, "process_stacktrace", side_effect=[True, False]
) as mock_process:
processor = api_client._LogLineProcessor(config, esp32.process_stacktrace)
processor.process_line(">>>stack>>>")
assert processor.backtrace_state is True
processor.process_line("<<<stack<<<")
assert processor.backtrace_state is False
assert mock_process.call_args_list[0].kwargs == {"backtrace_state": False}
assert mock_process.call_args_list[1].kwargs == {"backtrace_state": True}
assert not mock_process.call_args_list[0].args[-1]
assert mock_process.call_args_list[1].args[-1]
def test_decoder_uses_platform_handler_when_provided() -> None:
@@ -105,7 +106,7 @@ def test_decoder_uses_platform_handler_when_provided() -> None:
processor = api_client._LogLineProcessor(config, platform_handler)
with patch.object(api_client, "process_stacktrace") as mock_generic:
with patch.object(esp32, "process_stacktrace") as mock_generic:
processor.process_line("BT0: 0x4010496e")
assert calls == [(config, "BT0: 0x4010496e", False)]
@@ -0,0 +1,109 @@
"""Tests for ESP32 component."""
from pathlib import Path
from unittest.mock import Mock
def test_process_stacktrace_esp8266_exception(setup_core: Path, caplog) -> None:
"""Test process_stacktrace handles ESP8266 exceptions."""
from esphome.components.esp8266 import process_stacktrace
config = {"name": "test"}
# Test exception type parsing
line = "Exception (28):"
backtrace_state = False
result = process_stacktrace(config, line, backtrace_state)
assert "Access to invalid address: LOAD (wild pointer?)" in caplog.text
assert result is False
def test_process_stacktrace_esp8266_backtrace(
setup_core: Path, mock_esp8266_decode_pc: Mock
) -> None:
"""Test process_stacktrace handles ESP8266 multi-line backtrace."""
from esphome.components.esp8266 import process_stacktrace
config = {"name": "test"}
# Start of backtrace
line1 = ">>>stack>>>"
state = process_stacktrace(config, line1, False)
assert state is True
# Backtrace content with addresses
line2 = "40201234 40205678"
state = process_stacktrace(config, line2, state)
assert state is True
assert mock_esp8266_decode_pc.call_count == 2
# End of backtrace
line3 = "<<<stack<<<"
state = process_stacktrace(config, line3, state)
assert state is False
def test_process_stacktrace_esp32_backtrace(
setup_core: Path, mock_esp32_decode_pc: Mock
) -> None:
"""Test process_stacktrace handles ESP32 single-line backtrace."""
from esphome.components.esp32 import process_stacktrace
config = {"name": "test"}
line = "Backtrace: 0x40081234:0x3ffb1234 0x40085678:0x3ffb5678"
state = process_stacktrace(config, line, False)
# Should decode both addresses
assert mock_esp32_decode_pc.call_count == 2
mock_esp32_decode_pc.assert_any_call(config, "40081234")
mock_esp32_decode_pc.assert_any_call(config, "40085678")
assert state is False
def test_process_stacktrace_bad_alloc(
setup_core: Path, mock_esp32_decode_pc: Mock, caplog
) -> None:
"""Test process_stacktrace handles bad alloc messages."""
from esphome.components.esp32 import process_stacktrace
config = {"name": "test"}
line = "last failed alloc call: 40201234(512)"
state = process_stacktrace(config, line, False)
assert "Memory allocation of 512 bytes failed at 40201234" in caplog.text
mock_esp32_decode_pc.assert_called_once_with(config, "40201234")
assert state is False
def test_process_stacktrace_esp32_crash_handler(
setup_core: Path, mock_esp32_decode_pc: Mock
) -> None:
"""Test process_stacktrace handles ESP32 crash handler backtrace lines."""
from esphome.components.esp32 import process_stacktrace
config = {"name": "test"}
# Simulate crash handler log lines as they appear from the API/serial
line_pc = "[E][esp32.crash:078]: PC: 0x400D1234 (fault location)"
state = process_stacktrace(config, line_pc, False)
# PC line is matched by existing STACKTRACE_ESP32_PC_RE
mock_esp32_decode_pc.assert_called_with(config, "400D1234")
assert state is False
mock_esp32_decode_pc.reset_mock()
line_bt0 = "[E][esp32.crash:080]: BT0: 0x400D5678 (backtrace)"
state = process_stacktrace(config, line_bt0, False)
mock_esp32_decode_pc.assert_called_once_with(config, "400D5678")
assert state is False
mock_esp32_decode_pc.reset_mock()
line_bt1 = "[E][esp32.crash:080]: BT1: 0x42005ABC (backtrace)"
state = process_stacktrace(config, line_bt1, False)
mock_esp32_decode_pc.assert_called_once_with(config, "42005ABC")
assert state is False
+10 -3
View File
@@ -77,9 +77,16 @@ def mock_run_platformio_cli_run() -> Generator[Mock, None, None]:
@pytest.fixture
def mock_decode_pc() -> Generator[Mock, None, None]:
"""Mock _decode_pc for platformio_api."""
with patch("esphome.platformio_api._decode_pc") as mock:
def mock_esp32_decode_pc() -> Generator[Mock, None, None]:
"""Mock _decode_pc for esp32."""
with patch("esphome.components.esp32._decode_pc") as mock:
yield mock
@pytest.fixture
def mock_esp8266_decode_pc() -> Generator[Mock, None, None]:
"""Mock _decode_pc for esp8266."""
with patch("esphome.components.esp8266._decode_pc") as mock:
yield mock

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