mirror of
https://github.com/esphome/esphome.git
synced 2026-07-10 17:05:36 +00:00
Merge branch 'dev' into sendspin-artwork
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -18,83 +18,22 @@ class APIConnection;
|
||||
class ListEntitiesIterator final : public ComponentIterator {
|
||||
public:
|
||||
ListEntitiesIterator(APIConnection *client);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
bool on_binary_sensor(binary_sensor::BinarySensor *entity) override;
|
||||
#endif
|
||||
#ifdef USE_COVER
|
||||
bool on_cover(cover::Cover *entity) override;
|
||||
#endif
|
||||
#ifdef USE_FAN
|
||||
bool on_fan(fan::Fan *entity) override;
|
||||
#endif
|
||||
#ifdef USE_LIGHT
|
||||
bool on_light(light::LightState *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
bool on_sensor(sensor::Sensor *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SWITCH
|
||||
bool on_switch(switch_::Switch *entity) override;
|
||||
#endif
|
||||
#ifdef USE_BUTTON
|
||||
bool on_button(button::Button *entity) override;
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
bool on_text_sensor(text_sensor::TextSensor *entity) override;
|
||||
#endif
|
||||
|
||||
// Entity overrides (generated from entity_types.h).
|
||||
// All implementations live in list_entities.cpp via LIST_ENTITIES_HANDLER.
|
||||
// NOLINTBEGIN(bugprone-macro-parentheses)
|
||||
#define ENTITY_TYPE_(type, singular, plural, count, upper) bool on_##singular(type *entity) override;
|
||||
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
|
||||
ENTITY_TYPE_(type, singular, plural, count, upper)
|
||||
#include "esphome/core/entity_types.h"
|
||||
#undef ENTITY_TYPE_
|
||||
#undef ENTITY_CONTROLLER_TYPE_
|
||||
// NOLINTEND(bugprone-macro-parentheses)
|
||||
#ifdef USE_API_USER_DEFINED_ACTIONS
|
||||
bool on_service(UserServiceDescriptor *service) override;
|
||||
#endif
|
||||
#ifdef USE_CAMERA
|
||||
bool on_camera(camera::Camera *entity) override;
|
||||
#endif
|
||||
#ifdef USE_CLIMATE
|
||||
bool on_climate(climate::Climate *entity) override;
|
||||
#endif
|
||||
#ifdef USE_NUMBER
|
||||
bool on_number(number::Number *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_DATE
|
||||
bool on_date(datetime::DateEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_TIME
|
||||
bool on_time(datetime::TimeEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_DATETIME
|
||||
bool on_datetime(datetime::DateTimeEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_TEXT
|
||||
bool on_text(text::Text *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
bool on_select(select::Select *entity) override;
|
||||
#endif
|
||||
#ifdef USE_LOCK
|
||||
bool on_lock(lock::Lock *entity) override;
|
||||
#endif
|
||||
#ifdef USE_VALVE
|
||||
bool on_valve(valve::Valve *entity) override;
|
||||
#endif
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
bool on_media_player(media_player::MediaPlayer *entity) override;
|
||||
#endif
|
||||
#ifdef USE_ALARM_CONTROL_PANEL
|
||||
bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override;
|
||||
#endif
|
||||
#ifdef USE_WATER_HEATER
|
||||
bool on_water_heater(water_heater::WaterHeater *entity) override;
|
||||
#endif
|
||||
#ifdef USE_INFRARED
|
||||
bool on_infrared(infrared::Infrared *entity) override;
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
bool on_radio_frequency(radio_frequency::RadioFrequency *entity) override;
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
bool on_event(event::Event *entity) override;
|
||||
#endif
|
||||
#ifdef USE_UPDATE
|
||||
bool on_update(update::UpdateEntity *entity) override;
|
||||
#endif
|
||||
bool on_end() override;
|
||||
|
||||
|
||||
@@ -67,7 +67,10 @@ INITIAL_STATE_HANDLER(water_heater, water_heater::WaterHeater)
|
||||
INITIAL_STATE_HANDLER(update, update::UpdateEntity)
|
||||
#endif
|
||||
|
||||
// Special cases (button and event) are already defined inline in subscribe_state.h
|
||||
// event is an ENTITY_CONTROLLER_TYPE_ but has no state to send.
|
||||
#ifdef USE_EVENT
|
||||
bool InitialStateIterator::on_event(event::Event *entity) { return true; }
|
||||
#endif
|
||||
|
||||
InitialStateIterator::InitialStateIterator(APIConnection *client) : client_(client) {}
|
||||
|
||||
|
||||
@@ -19,78 +19,20 @@ class APIConnection;
|
||||
class InitialStateIterator final : public ComponentIterator {
|
||||
public:
|
||||
InitialStateIterator(APIConnection *client);
|
||||
#ifdef USE_BINARY_SENSOR
|
||||
bool on_binary_sensor(binary_sensor::BinarySensor *entity) override;
|
||||
#endif
|
||||
#ifdef USE_COVER
|
||||
bool on_cover(cover::Cover *entity) override;
|
||||
#endif
|
||||
#ifdef USE_FAN
|
||||
bool on_fan(fan::Fan *entity) override;
|
||||
#endif
|
||||
#ifdef USE_LIGHT
|
||||
bool on_light(light::LightState *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SENSOR
|
||||
bool on_sensor(sensor::Sensor *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SWITCH
|
||||
bool on_switch(switch_::Switch *entity) override;
|
||||
#endif
|
||||
#ifdef USE_BUTTON
|
||||
bool on_button(button::Button *button) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_TEXT_SENSOR
|
||||
bool on_text_sensor(text_sensor::TextSensor *entity) override;
|
||||
#endif
|
||||
#ifdef USE_CLIMATE
|
||||
bool on_climate(climate::Climate *entity) override;
|
||||
#endif
|
||||
#ifdef USE_NUMBER
|
||||
bool on_number(number::Number *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_DATE
|
||||
bool on_date(datetime::DateEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_TIME
|
||||
bool on_time(datetime::TimeEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_DATETIME_DATETIME
|
||||
bool on_datetime(datetime::DateTimeEntity *entity) override;
|
||||
#endif
|
||||
#ifdef USE_TEXT
|
||||
bool on_text(text::Text *entity) override;
|
||||
#endif
|
||||
#ifdef USE_SELECT
|
||||
bool on_select(select::Select *entity) override;
|
||||
#endif
|
||||
#ifdef USE_LOCK
|
||||
bool on_lock(lock::Lock *entity) override;
|
||||
#endif
|
||||
#ifdef USE_VALVE
|
||||
bool on_valve(valve::Valve *entity) override;
|
||||
#endif
|
||||
#ifdef USE_MEDIA_PLAYER
|
||||
bool on_media_player(media_player::MediaPlayer *entity) override;
|
||||
#endif
|
||||
#ifdef USE_ALARM_CONTROL_PANEL
|
||||
bool on_alarm_control_panel(alarm_control_panel::AlarmControlPanel *entity) override;
|
||||
#endif
|
||||
#ifdef USE_WATER_HEATER
|
||||
bool on_water_heater(water_heater::WaterHeater *entity) override;
|
||||
#endif
|
||||
#ifdef USE_INFRARED
|
||||
bool on_infrared(infrared::Infrared *infrared) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_RADIO_FREQUENCY
|
||||
bool on_radio_frequency(radio_frequency::RadioFrequency *radio_frequency) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_EVENT
|
||||
bool on_event(event::Event *event) override { return true; };
|
||||
#endif
|
||||
#ifdef USE_UPDATE
|
||||
bool on_update(update::UpdateEntity *entity) override;
|
||||
#endif
|
||||
|
||||
// Entity overrides (generated from entity_types.h).
|
||||
// ENTITY_TYPE_ entities have no state to send and default to a no-op.
|
||||
// ENTITY_CONTROLLER_TYPE_ entities are implemented in subscribe_state.cpp via INITIAL_STATE_HANDLER,
|
||||
// except on_event which has no state (defined out-of-line in subscribe_state.cpp).
|
||||
// NOLINTBEGIN(bugprone-macro-parentheses)
|
||||
#define ENTITY_TYPE_(type, singular, plural, count, upper) \
|
||||
bool on_##singular(type *entity) override { return true; }
|
||||
#define ENTITY_CONTROLLER_TYPE_(type, singular, plural, count, upper, callback) \
|
||||
bool on_##singular(type *entity) override;
|
||||
#include "esphome/core/entity_types.h"
|
||||
#undef ENTITY_TYPE_
|
||||
#undef ENTITY_CONTROLLER_TYPE_
|
||||
// NOLINTEND(bugprone-macro-parentheses)
|
||||
|
||||
protected:
|
||||
APIConnection *client_;
|
||||
|
||||
@@ -1,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 ¤t_pref, ESPPreferenceObject &legacy_pref,
|
||||
T *scratch) {
|
||||
T current{};
|
||||
if (current_pref.load(¤t)) {
|
||||
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]);
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -55,8 +55,10 @@ const char *audio_file_type_to_string(AudioFileType file_type) {
|
||||
case AudioFileType::OPUS:
|
||||
return "OPUS";
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
case AudioFileType::WAV:
|
||||
return "WAV";
|
||||
#endif
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
@@ -71,9 +73,11 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url)
|
||||
return AudioFileType::MP3;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
if (strcasecmp(content_type, "audio/wav") == 0) {
|
||||
return AudioFileType::WAV;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_AUDIO_FLAC_SUPPORT
|
||||
if (strcasecmp(content_type, "audio/flac") == 0 || strcasecmp(content_type, "audio/x-flac") == 0) {
|
||||
return AudioFileType::FLAC;
|
||||
@@ -91,9 +95,11 @@ AudioFileType detect_audio_file_type(const char *content_type, const char *url)
|
||||
|
||||
// Fallback to URL extension
|
||||
if (url != nullptr && url[0] != '\0') {
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
if (str_endswith_ignore_case(url, ".wav")) {
|
||||
return AudioFileType::WAV;
|
||||
}
|
||||
#endif
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
if (str_endswith_ignore_case(url, ".mp3")) {
|
||||
return AudioFileType::MP3;
|
||||
|
||||
@@ -116,7 +116,9 @@ enum class AudioFileType : uint8_t {
|
||||
#ifdef USE_AUDIO_OPUS_SUPPORT
|
||||
OPUS,
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
WAV,
|
||||
#endif
|
||||
};
|
||||
|
||||
struct AudioFile {
|
||||
|
||||
@@ -20,14 +20,6 @@ AudioDecoder::AudioDecoder(size_t input_buffer_size, size_t output_buffer_size)
|
||||
this->output_transfer_buffer_ = AudioSinkTransferBuffer::create(output_buffer_size);
|
||||
}
|
||||
|
||||
AudioDecoder::~AudioDecoder() {
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
if (this->audio_file_type_ == AudioFileType::MP3) {
|
||||
esp_audio_libs::helix_decoder::MP3FreeDecoder(this->mp3_decoder_);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
esp_err_t AudioDecoder::add_source(std::weak_ptr<RingBuffer> &input_ring_buffer) {
|
||||
auto source = AudioSourceTransferBuffer::create(this->input_buffer_size_);
|
||||
if (source == nullptr) {
|
||||
@@ -87,18 +79,13 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) {
|
||||
this->flac_decoder_ = make_unique<micro_flac::FLACDecoder>();
|
||||
this->free_buffer_required_ =
|
||||
this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header
|
||||
this->decoder_buffers_internally_ = true;
|
||||
break;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
case AudioFileType::MP3:
|
||||
this->mp3_decoder_ = esp_audio_libs::helix_decoder::MP3InitDecoder();
|
||||
|
||||
// MP3 always has 1152 samples per chunk
|
||||
this->free_buffer_required_ = 1152 * sizeof(int16_t) * 2; // samples * size per sample * channels
|
||||
|
||||
// Always reallocate the output transfer buffer to the smallest necessary size
|
||||
this->output_transfer_buffer_->reallocate(this->free_buffer_required_);
|
||||
this->mp3_decoder_ = make_unique<micro_mp3::Mp3Decoder>();
|
||||
this->free_buffer_required_ =
|
||||
this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header
|
||||
break;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_OPUS_SUPPORT
|
||||
@@ -106,20 +93,18 @@ esp_err_t AudioDecoder::start(AudioFileType audio_file_type) {
|
||||
this->opus_decoder_ = make_unique<micro_opus::OggOpusDecoder>();
|
||||
this->free_buffer_required_ =
|
||||
this->output_transfer_buffer_->capacity(); // Adjusted and reallocated after reading the header
|
||||
this->decoder_buffers_internally_ = true;
|
||||
break;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
case AudioFileType::WAV:
|
||||
this->wav_decoder_ = make_unique<esp_audio_libs::wav_decoder::WAVDecoder>();
|
||||
this->wav_decoder_->reset();
|
||||
|
||||
// Processing WAVs doesn't actually require a specific amount of buffer size, as it is already in PCM format.
|
||||
// Thus, we don't reallocate to a minimum size.
|
||||
this->wav_decoder_ = make_unique<micro_wav::WAVDecoder>();
|
||||
// 1 KiB suffices to always make progress while avoiding excessive CPU spinning for decoding
|
||||
this->free_buffer_required_ = 1024;
|
||||
if (this->output_transfer_buffer_->capacity() < this->free_buffer_required_) {
|
||||
this->output_transfer_buffer_->reallocate(this->free_buffer_required_);
|
||||
}
|
||||
break;
|
||||
#endif
|
||||
case AudioFileType::NONE:
|
||||
default:
|
||||
return ESP_ERR_NOT_SUPPORTED;
|
||||
@@ -190,10 +175,8 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
|
||||
// Decode more audio
|
||||
|
||||
// Only shift data on the first loop iteration to avoid unnecessary, slow moves
|
||||
// If the decoder buffers internally, then never shift
|
||||
size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS),
|
||||
first_loop_iteration && !this->decoder_buffers_internally_);
|
||||
// Never shift the input buffer; every decoder buffers internally and consumes only what it processed.
|
||||
size_t bytes_read = this->input_buffer_->fill(pdMS_TO_TICKS(READ_WRITE_TIMEOUT_MS), false);
|
||||
|
||||
if (!first_loop_iteration && (this->input_buffer_->available() < bytes_processed)) {
|
||||
// Less data is available than what was processed in last iteration, so don't attempt to decode.
|
||||
@@ -237,9 +220,11 @@ AudioDecoderState AudioDecoder::decode(bool stop_gracefully) {
|
||||
state = this->decode_opus_();
|
||||
break;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
case AudioFileType::WAV:
|
||||
state = this->decode_wav_();
|
||||
break;
|
||||
#endif
|
||||
case AudioFileType::NONE:
|
||||
default:
|
||||
state = FileDecoderState::IDLE;
|
||||
@@ -312,51 +297,56 @@ FileDecoderState AudioDecoder::decode_flac_() {
|
||||
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
FileDecoderState AudioDecoder::decode_mp3_() {
|
||||
// Look for the next sync word
|
||||
int buffer_length = (int) this->input_buffer_->available();
|
||||
int32_t offset = esp_audio_libs::helix_decoder::MP3FindSyncWord(this->input_buffer_->data(), buffer_length);
|
||||
// microMP3's samples_decoded value is samples per channel; e.g., what ESPHome typically calls an audio frame.
|
||||
// microMP3 uses the term frame to refer to an MP3 frame: an encoded packet that contains multiple audio frames.
|
||||
size_t bytes_consumed = 0;
|
||||
size_t samples_decoded = 0;
|
||||
|
||||
if (offset < 0) {
|
||||
// New data may have the sync word
|
||||
this->input_buffer_->consume(buffer_length);
|
||||
// microMP3 buffers internally: it consumes from our input buffer at its own pace, emits MP3_STREAM_INFO_READY once
|
||||
// the first frame header is parsed, and only then produces PCM. It handles sync-word search and ID3v2 tag skipping.
|
||||
micro_mp3::Mp3Result result = this->mp3_decoder_->decode(
|
||||
this->input_buffer_->data(), this->input_buffer_->available(), this->output_transfer_buffer_->get_buffer_end(),
|
||||
this->output_transfer_buffer_->free(), bytes_consumed, samples_decoded);
|
||||
|
||||
this->input_buffer_->consume(bytes_consumed);
|
||||
|
||||
if (result == micro_mp3::MP3_OK) {
|
||||
if (samples_decoded > 0 && this->audio_stream_info_.has_value()) {
|
||||
this->output_transfer_buffer_->increase_buffer_length(
|
||||
this->audio_stream_info_.value().frames_to_bytes(samples_decoded));
|
||||
}
|
||||
} else if (result == micro_mp3::MP3_STREAM_INFO_READY) {
|
||||
// First successful header parse: capture stream info and resize the output buffer to fit one full frame.
|
||||
// microMP3 always outputs 16-bit PCM.
|
||||
this->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(16, this->mp3_decoder_->get_channels(), this->mp3_decoder_->get_sample_rate());
|
||||
this->free_buffer_required_ =
|
||||
this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t);
|
||||
if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) {
|
||||
return FileDecoderState::FAILED;
|
||||
}
|
||||
} else if (result == micro_mp3::MP3_NEED_MORE_DATA) {
|
||||
return FileDecoderState::MORE_TO_PROCESS;
|
||||
} else if (result == micro_mp3::MP3_OUTPUT_BUFFER_TOO_SMALL) {
|
||||
// Reallocate to decode the frame on the next call
|
||||
if (this->mp3_decoder_->get_channels() > 0) {
|
||||
this->free_buffer_required_ =
|
||||
this->mp3_decoder_->get_samples_per_frame() * this->mp3_decoder_->get_channels() * sizeof(int16_t);
|
||||
} else {
|
||||
// Fallback to worst-case size if channel info isn't available
|
||||
this->free_buffer_required_ = this->mp3_decoder_->get_min_output_buffer_bytes();
|
||||
}
|
||||
if (!this->output_transfer_buffer_->reallocate(this->free_buffer_required_)) {
|
||||
return FileDecoderState::FAILED;
|
||||
}
|
||||
} else if (result == micro_mp3::MP3_DECODE_ERROR) {
|
||||
// Corrupt frame skipped; recoverable, retry on next call
|
||||
ESP_LOGW(TAG, "MP3 decoder skipped a corrupt frame");
|
||||
return FileDecoderState::POTENTIALLY_FAILED;
|
||||
}
|
||||
|
||||
// Advance read pointer to match the offset for the syncword
|
||||
this->input_buffer_->consume(offset);
|
||||
const uint8_t *buffer_start = this->input_buffer_->data();
|
||||
|
||||
buffer_length = (int) this->input_buffer_->available();
|
||||
int err = esp_audio_libs::helix_decoder::MP3Decode(this->mp3_decoder_, &buffer_start, &buffer_length,
|
||||
(int16_t *) this->output_transfer_buffer_->get_buffer_end(), 0);
|
||||
|
||||
size_t consumed = this->input_buffer_->available() - buffer_length;
|
||||
this->input_buffer_->consume(consumed);
|
||||
|
||||
if (err) {
|
||||
switch (err) {
|
||||
case esp_audio_libs::helix_decoder::ERR_MP3_OUT_OF_MEMORY:
|
||||
[[fallthrough]];
|
||||
case esp_audio_libs::helix_decoder::ERR_MP3_NULL_POINTER:
|
||||
return FileDecoderState::FAILED;
|
||||
break;
|
||||
default:
|
||||
// Most errors are recoverable by moving on to the next frame, so mark as potentailly failed
|
||||
return FileDecoderState::POTENTIALLY_FAILED;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
esp_audio_libs::helix_decoder::MP3FrameInfo mp3_frame_info;
|
||||
esp_audio_libs::helix_decoder::MP3GetLastFrameInfo(this->mp3_decoder_, &mp3_frame_info);
|
||||
if (mp3_frame_info.outputSamps > 0) {
|
||||
int bytes_per_sample = (mp3_frame_info.bitsPerSample / 8);
|
||||
this->output_transfer_buffer_->increase_buffer_length(mp3_frame_info.outputSamps * bytes_per_sample);
|
||||
|
||||
if (!this->audio_stream_info_.has_value()) {
|
||||
this->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(mp3_frame_info.bitsPerSample, mp3_frame_info.nChans, mp3_frame_info.samprate);
|
||||
}
|
||||
}
|
||||
// MP3_ALLOCATION_FAILED, MP3_INPUT_INVALID, or any future error -- not recoverable
|
||||
ESP_LOGE(TAG, "MP3 decoder failed: %d", static_cast<int>(result));
|
||||
return FileDecoderState::FAILED;
|
||||
}
|
||||
|
||||
return FileDecoderState::MORE_TO_PROCESS;
|
||||
@@ -401,52 +391,42 @@ FileDecoderState AudioDecoder::decode_opus_() {
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
FileDecoderState AudioDecoder::decode_wav_() {
|
||||
if (!this->audio_stream_info_.has_value()) {
|
||||
// Header hasn't been processed
|
||||
// microWAV's samples_decoded counts individual channel samples; e.g., for
|
||||
// 16-bit stereo, 4 input bytes results in 2 samples_decoded.
|
||||
size_t bytes_consumed = 0;
|
||||
size_t samples_decoded = 0;
|
||||
|
||||
esp_audio_libs::wav_decoder::WAVDecoderResult result =
|
||||
this->wav_decoder_->decode_header(this->input_buffer_->data(), this->input_buffer_->available());
|
||||
micro_wav::WAVDecoderResult result = this->wav_decoder_->decode(
|
||||
this->input_buffer_->data(), this->input_buffer_->available(), this->output_transfer_buffer_->get_buffer_end(),
|
||||
this->output_transfer_buffer_->free(), bytes_consumed, samples_decoded);
|
||||
|
||||
if (result == esp_audio_libs::wav_decoder::WAV_DECODER_SUCCESS_IN_DATA) {
|
||||
this->input_buffer_->consume(this->wav_decoder_->bytes_processed());
|
||||
this->input_buffer_->consume(bytes_consumed);
|
||||
|
||||
this->audio_stream_info_ = audio::AudioStreamInfo(
|
||||
this->wav_decoder_->bits_per_sample(), this->wav_decoder_->num_channels(), this->wav_decoder_->sample_rate());
|
||||
|
||||
this->wav_bytes_left_ = this->wav_decoder_->chunk_bytes_left();
|
||||
this->wav_has_known_end_ = (this->wav_bytes_left_ > 0);
|
||||
return FileDecoderState::MORE_TO_PROCESS;
|
||||
} else if (result == esp_audio_libs::wav_decoder::WAV_DECODER_WARNING_INCOMPLETE_DATA) {
|
||||
// Available data didn't have the full header
|
||||
return FileDecoderState::POTENTIALLY_FAILED;
|
||||
} else {
|
||||
return FileDecoderState::FAILED;
|
||||
if (result == micro_wav::WAV_DECODER_SUCCESS) {
|
||||
if (samples_decoded > 0 && this->audio_stream_info_.has_value()) {
|
||||
this->output_transfer_buffer_->increase_buffer_length(
|
||||
this->audio_stream_info_.value().samples_to_bytes(samples_decoded));
|
||||
}
|
||||
} else if (result == micro_wav::WAV_DECODER_HEADER_READY) {
|
||||
// After HEADER_READY, get_bits_per_sample() returns the output bit depth
|
||||
// (16 for A-law/mu-law, 32 for IEEE float, original value for PCM).
|
||||
this->audio_stream_info_ =
|
||||
audio::AudioStreamInfo(this->wav_decoder_->get_bits_per_sample(), this->wav_decoder_->get_channels(),
|
||||
this->wav_decoder_->get_sample_rate());
|
||||
} else if (result == micro_wav::WAV_DECODER_NEED_MORE_DATA) {
|
||||
return FileDecoderState::MORE_TO_PROCESS;
|
||||
} else if (result == micro_wav::WAV_DECODER_END_OF_STREAM) {
|
||||
return FileDecoderState::END_OF_FILE;
|
||||
} else {
|
||||
if (!this->wav_has_known_end_ || (this->wav_bytes_left_ > 0)) {
|
||||
size_t bytes_to_copy = this->input_buffer_->available();
|
||||
|
||||
if (this->wav_has_known_end_) {
|
||||
bytes_to_copy = std::min(bytes_to_copy, this->wav_bytes_left_);
|
||||
}
|
||||
|
||||
bytes_to_copy = std::min(bytes_to_copy, this->output_transfer_buffer_->free());
|
||||
|
||||
if (bytes_to_copy > 0) {
|
||||
std::memcpy(this->output_transfer_buffer_->get_buffer_end(), this->input_buffer_->data(), bytes_to_copy);
|
||||
this->input_buffer_->consume(bytes_to_copy);
|
||||
this->output_transfer_buffer_->increase_buffer_length(bytes_to_copy);
|
||||
if (this->wav_has_known_end_) {
|
||||
this->wav_bytes_left_ -= bytes_to_copy;
|
||||
}
|
||||
}
|
||||
return FileDecoderState::IDLE;
|
||||
}
|
||||
ESP_LOGE(TAG, "WAV decoder failed: %d", static_cast<int>(result));
|
||||
return FileDecoderState::FAILED;
|
||||
}
|
||||
|
||||
return FileDecoderState::END_OF_FILE;
|
||||
return FileDecoderState::MORE_TO_PROCESS;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace audio
|
||||
} // namespace esphome
|
||||
|
||||
@@ -15,22 +15,26 @@
|
||||
|
||||
#include "esp_err.h"
|
||||
|
||||
// esp-audio-libs
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
#include <mp3_decoder.h>
|
||||
#endif
|
||||
#include <wav_decoder.h>
|
||||
|
||||
// micro-flac
|
||||
#ifdef USE_AUDIO_FLAC_SUPPORT
|
||||
#include <micro_flac/flac_decoder.h>
|
||||
#endif
|
||||
|
||||
// micro-mp3
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
#include <micro_mp3/mp3_decoder.h>
|
||||
#endif
|
||||
|
||||
// micro-opus
|
||||
#ifdef USE_AUDIO_OPUS_SUPPORT
|
||||
#include <micro_opus/ogg_opus_decoder.h>
|
||||
#endif
|
||||
|
||||
// micro-wav
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
#include <micro_wav/wav_decoder.h>
|
||||
#endif
|
||||
|
||||
namespace esphome {
|
||||
namespace audio {
|
||||
|
||||
@@ -54,7 +58,7 @@ class AudioDecoder {
|
||||
* @brief Class that facilitates decoding an audio file.
|
||||
* The audio file is read from a source (ring buffer or const data pointer), decoded, and sent to an audio sink
|
||||
* (ring buffer, speaker component, or callback).
|
||||
* Supports wav, flac, mp3, and ogg opus formats.
|
||||
* Supports flac, mp3, ogg opus, and wav formats (each enabled independently at compile time).
|
||||
*/
|
||||
public:
|
||||
/// @brief Allocates the output transfer buffer and stores the input buffer size for later use by add_source()
|
||||
@@ -62,8 +66,7 @@ class AudioDecoder {
|
||||
/// @param output_buffer_size Size of the output transfer buffer in bytes.
|
||||
AudioDecoder(size_t input_buffer_size, size_t output_buffer_size);
|
||||
|
||||
/// @brief Deallocates the MP3 decoder (the flac, opus, and wav decoders are deallocated automatically)
|
||||
~AudioDecoder();
|
||||
~AudioDecoder() = default;
|
||||
|
||||
/// @brief Adds a source ring buffer for raw file data. Takes ownership of the ring buffer in a shared_ptr.
|
||||
/// @param input_ring_buffer weak_ptr of a shared_ptr of the sink ring buffer to transfer ownership
|
||||
@@ -118,20 +121,22 @@ class AudioDecoder {
|
||||
void set_pause_output_state(bool pause_state) { this->pause_output_ = pause_state; }
|
||||
|
||||
protected:
|
||||
std::unique_ptr<esp_audio_libs::wav_decoder::WAVDecoder> wav_decoder_;
|
||||
#ifdef USE_AUDIO_FLAC_SUPPORT
|
||||
FileDecoderState decode_flac_();
|
||||
std::unique_ptr<micro_flac::FLACDecoder> flac_decoder_;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
FileDecoderState decode_mp3_();
|
||||
esp_audio_libs::helix_decoder::HMP3Decoder mp3_decoder_;
|
||||
std::unique_ptr<micro_mp3::Mp3Decoder> mp3_decoder_;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_OPUS_SUPPORT
|
||||
FileDecoderState decode_opus_();
|
||||
std::unique_ptr<micro_opus::OggOpusDecoder> opus_decoder_;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
FileDecoderState decode_wav_();
|
||||
std::unique_ptr<micro_wav::WAVDecoder> wav_decoder_;
|
||||
#endif
|
||||
|
||||
std::unique_ptr<AudioReadableBuffer> input_buffer_;
|
||||
std::unique_ptr<AudioSinkTransferBuffer> output_transfer_buffer_;
|
||||
@@ -141,16 +146,12 @@ class AudioDecoder {
|
||||
|
||||
size_t input_buffer_size_{0};
|
||||
size_t free_buffer_required_{0};
|
||||
size_t wav_bytes_left_{0};
|
||||
|
||||
uint32_t potentially_failed_count_{0};
|
||||
uint32_t accumulated_frames_written_{0};
|
||||
uint32_t playback_ms_{0};
|
||||
|
||||
bool end_of_file_{false};
|
||||
bool wav_has_known_end_{false};
|
||||
|
||||
bool decoder_buffers_internally_{false};
|
||||
|
||||
bool pause_output_{false};
|
||||
};
|
||||
|
||||
@@ -193,55 +193,66 @@ def _validate_supported_local_file(config: list[ConfigType]) -> list[ConfigType]
|
||||
audio.request_mp3_support()
|
||||
elif media_file_type_str == str(audio.AUDIO_FILE_TYPE_ENUM["OPUS"]):
|
||||
audio.request_opus_support()
|
||||
elif media_file_type_str == str(audio.AUDIO_FILE_TYPE_ENUM["WAV"]):
|
||||
audio.request_wav_support()
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def audio_files_schema() -> cv.All:
|
||||
"""Schema for a list of audio file entries.
|
||||
|
||||
Validates each entry, downloads any web files, and detects the audio file
|
||||
type while requesting codec support. Reusable by other components (e.g.
|
||||
speaker media_player) that embed audio files in firmware without going
|
||||
through the audio_file component's C++ registry.
|
||||
"""
|
||||
return cv.All(
|
||||
cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA),
|
||||
partial(download_web_files_in_config, path_for=_compute_local_file_path),
|
||||
_validate_supported_local_file,
|
||||
)
|
||||
|
||||
|
||||
def generate_audio_file_code(file_config: ConfigType) -> MockObj:
|
||||
"""Generate the progmem data, AudioFile struct, and Pvariable for one file.
|
||||
|
||||
Returns the created Pvariable. Caller is responsible for any further
|
||||
registration (the audio_file component additionally registers each file in
|
||||
its named C++ registry; other consumers may skip that).
|
||||
"""
|
||||
cache = _get_data().file_cache
|
||||
file_id = str(file_config[CONF_ID])
|
||||
if file_id in cache:
|
||||
data, media_file_type = cache[file_id]
|
||||
else:
|
||||
data, media_file_type = read_audio_file_and_type(file_config)
|
||||
|
||||
rhs = [HexInt(x) for x in data]
|
||||
prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs)
|
||||
|
||||
media_files_struct = cg.StructInitializer(
|
||||
audio.AudioFile,
|
||||
("data", prog_arr),
|
||||
("length", len(rhs)),
|
||||
("file_type", media_file_type),
|
||||
)
|
||||
|
||||
return cg.new_Pvariable(file_config[CONF_ID], media_files_struct)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.only_on_esp32,
|
||||
cv.ensure_list(MEDIA_FILE_TYPE_SCHEMA),
|
||||
partial(download_web_files_in_config, path_for=_compute_local_file_path),
|
||||
_validate_supported_local_file,
|
||||
audio_files_schema(),
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: list[ConfigType]) -> None:
|
||||
cache = _get_data().file_cache
|
||||
|
||||
for file_config in config:
|
||||
file_id = str(file_config[CONF_ID])
|
||||
data, media_file_type = cache[file_id]
|
||||
|
||||
rhs = [HexInt(x) for x in data]
|
||||
prog_arr = cg.progmem_array(file_config[CONF_RAW_DATA_ID], rhs)
|
||||
|
||||
media_files_struct = cg.StructInitializer(
|
||||
audio.AudioFile,
|
||||
(
|
||||
"data",
|
||||
prog_arr,
|
||||
),
|
||||
(
|
||||
"length",
|
||||
len(rhs),
|
||||
),
|
||||
(
|
||||
"file_type",
|
||||
media_file_type,
|
||||
),
|
||||
)
|
||||
|
||||
cg.new_Pvariable(
|
||||
file_config[CONF_ID],
|
||||
media_files_struct,
|
||||
)
|
||||
|
||||
# Store file ID for cross-component access
|
||||
file_var = generate_audio_file_code(file_config)
|
||||
_get_data().file_ids[file_id] = file_config[CONF_ID]
|
||||
cg.add(audio_file_ns.add_named_audio_file(file_var, file_id))
|
||||
|
||||
# Register all files in the shared C++ registry
|
||||
cg.add_define("AUDIO_FILE_MAX_FILES", len(config))
|
||||
for file_config in config:
|
||||
file_id = str(file_config[CONF_ID])
|
||||
file_var = await cg.get_variable(file_config[CONF_ID])
|
||||
cg.add(audio_file_ns.add_named_audio_file(file_var, file_id))
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from typing import Any
|
||||
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import media_source, psram
|
||||
from esphome.components import audio, esp32, media_source, psram
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_ID, CONF_TASK_STACK_IN_PSRAM
|
||||
from esphome.types import ConfigType
|
||||
@@ -13,19 +15,30 @@ AudioFileMediaSource = audio_file_ns.class_(
|
||||
"AudioFileMediaSource", cg.Component, media_source.MediaSource
|
||||
)
|
||||
|
||||
|
||||
def _request_micro_decoder(config: ConfigType) -> ConfigType:
|
||||
audio.request_micro_decoder_support()
|
||||
return config
|
||||
|
||||
|
||||
def _validate_task_stack_in_psram(value: Any) -> bool:
|
||||
if value := cv.boolean(value):
|
||||
return cv.requires_component(psram.DOMAIN)(value)
|
||||
return value
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
media_source.media_source_schema(
|
||||
AudioFileMediaSource,
|
||||
)
|
||||
.extend(
|
||||
{
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): cv.All(
|
||||
cv.boolean, cv.requires_component(psram.DOMAIN)
|
||||
),
|
||||
cv.Optional(CONF_TASK_STACK_IN_PSRAM): _validate_task_stack_in_psram,
|
||||
}
|
||||
)
|
||||
.extend(cv.COMPONENT_SCHEMA),
|
||||
cv.only_on_esp32,
|
||||
_request_micro_decoder,
|
||||
)
|
||||
|
||||
|
||||
@@ -34,5 +47,8 @@ async def to_code(config: ConfigType) -> None:
|
||||
await cg.register_component(var, config)
|
||||
await media_source.register_media_source(var, config)
|
||||
|
||||
if CONF_TASK_STACK_IN_PSRAM in config:
|
||||
cg.add(var.set_task_stack_in_psram(config[CONF_TASK_STACK_IN_PSRAM]))
|
||||
if config.get(CONF_TASK_STACK_IN_PSRAM):
|
||||
cg.add(var.set_task_stack_in_psram(True))
|
||||
esp32.add_idf_sdkconfig_option(
|
||||
"CONFIG_SPIRAM_ALLOW_STACK_EXTERNAL_MEMORY", True
|
||||
)
|
||||
|
||||
@@ -2,281 +2,185 @@
|
||||
|
||||
#ifdef USE_ESP32
|
||||
|
||||
#include "esphome/components/audio/audio_decoder.h"
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/task.h>
|
||||
|
||||
#include <cinttypes>
|
||||
#include <cstring>
|
||||
|
||||
namespace esphome::audio_file {
|
||||
|
||||
namespace { // anonymous namespace for internal linkage
|
||||
struct AudioSinkAdapter : public audio::AudioSinkCallback {
|
||||
media_source::MediaSource *source;
|
||||
audio::AudioStreamInfo stream_info;
|
||||
|
||||
size_t audio_sink_write(uint8_t *data, size_t length, TickType_t ticks_to_wait) override {
|
||||
return this->source->write_output(data, length, pdTICKS_TO_MS(ticks_to_wait), this->stream_info);
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
#if defined(USE_AUDIO_OPUS_SUPPORT)
|
||||
static constexpr uint32_t DECODE_TASK_STACK_SIZE = 5 * 1024;
|
||||
#else
|
||||
static constexpr uint32_t DECODE_TASK_STACK_SIZE = 3 * 1024;
|
||||
#endif
|
||||
|
||||
static const char *const TAG = "audio_file_media_source";
|
||||
|
||||
enum EventGroupBits : uint32_t {
|
||||
// Requests to start playback (set by play_uri, handled by loop)
|
||||
REQUEST_START = (1 << 0),
|
||||
// Commands from main loop to decode task
|
||||
COMMAND_STOP = (1 << 1),
|
||||
COMMAND_PAUSE = (1 << 2),
|
||||
// Decode task lifecycle signals (one-shot, cleared by loop)
|
||||
TASK_STARTING = (1 << 7),
|
||||
TASK_RUNNING = (1 << 8),
|
||||
TASK_STOPPING = (1 << 9),
|
||||
TASK_STOPPED = (1 << 10),
|
||||
TASK_ERROR = (1 << 11),
|
||||
// Decode task state (level-triggered, set/cleared by decode task)
|
||||
TASK_PAUSED = (1 << 12),
|
||||
ALL_BITS = 0x00FFFFFF, // All valid FreeRTOS event group bits
|
||||
};
|
||||
static constexpr uint32_t AUDIO_WRITE_TIMEOUT_MS = 50;
|
||||
static constexpr size_t DECODER_TASK_STACK_SIZE = 5120;
|
||||
static constexpr uint8_t DECODER_TASK_PRIORITY = 2;
|
||||
static constexpr uint32_t PAUSE_POLL_DELAY_MS = 20;
|
||||
static constexpr char URI_PREFIX[] = "audio-file://";
|
||||
|
||||
namespace { // anonymous namespace for internal linkage
|
||||
|
||||
// audio::AudioFileType and micro_decoder::AudioFileType use different numeric layouts (audio's
|
||||
// values shift with USE_AUDIO_*_SUPPORT defines; micro_decoder's are fixed and guarded by
|
||||
// MICRO_DECODER_CODEC_*). The codec request flow in audio/__init__.py keeps the two sets of
|
||||
// guards aligned, so a switch with matching #ifdefs covers all reachable cases.
|
||||
micro_decoder::AudioFileType to_micro_decoder_type(audio::AudioFileType type) {
|
||||
switch (type) {
|
||||
#ifdef USE_AUDIO_FLAC_SUPPORT
|
||||
case audio::AudioFileType::FLAC:
|
||||
return micro_decoder::AudioFileType::FLAC;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_MP3_SUPPORT
|
||||
case audio::AudioFileType::MP3:
|
||||
return micro_decoder::AudioFileType::MP3;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_OPUS_SUPPORT
|
||||
case audio::AudioFileType::OPUS:
|
||||
return micro_decoder::AudioFileType::OPUS;
|
||||
#endif
|
||||
#ifdef USE_AUDIO_WAV_SUPPORT
|
||||
case audio::AudioFileType::WAV:
|
||||
return micro_decoder::AudioFileType::WAV;
|
||||
#endif
|
||||
default:
|
||||
return micro_decoder::AudioFileType::NONE;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void AudioFileMediaSource::dump_config() {
|
||||
ESP_LOGCONFIG(TAG, "Audio File Media Source:");
|
||||
ESP_LOGCONFIG(TAG, " Task Stack in PSRAM: %s", this->task_stack_in_psram_ ? "Yes" : "No");
|
||||
ESP_LOGCONFIG(TAG,
|
||||
"Audio File Media Source:\n"
|
||||
" Decoder Task Stack in PSRAM: %s",
|
||||
YESNO(this->decoder_task_stack_in_psram_));
|
||||
}
|
||||
|
||||
void AudioFileMediaSource::setup() {
|
||||
this->disable_loop();
|
||||
|
||||
this->event_group_ = xEventGroupCreate();
|
||||
if (this->event_group_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to create event group");
|
||||
micro_decoder::DecoderConfig config;
|
||||
config.audio_write_timeout_ms = AUDIO_WRITE_TIMEOUT_MS;
|
||||
config.decoder_priority = DECODER_TASK_PRIORITY;
|
||||
config.decoder_stack_size = DECODER_TASK_STACK_SIZE;
|
||||
config.decoder_stack_in_psram = this->decoder_task_stack_in_psram_;
|
||||
|
||||
this->decoder_ = std::make_unique<micro_decoder::DecoderSource>(config);
|
||||
if (this->decoder_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Failed to allocate decoder");
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
this->decoder_->set_listener(this);
|
||||
}
|
||||
|
||||
void AudioFileMediaSource::loop() {
|
||||
EventBits_t event_bits = xEventGroupGetBits(this->event_group_);
|
||||
void AudioFileMediaSource::loop() { this->decoder_->loop(); }
|
||||
|
||||
if (event_bits & REQUEST_START) {
|
||||
xEventGroupClearBits(this->event_group_, REQUEST_START);
|
||||
this->decoding_state_ = AudioFileDecodingState::START_TASK;
|
||||
}
|
||||
|
||||
switch (this->decoding_state_) {
|
||||
case AudioFileDecodingState::START_TASK: {
|
||||
if (!this->decode_task_.is_created()) {
|
||||
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
||||
if (!this->decode_task_.create(decode_task, "AudioFileDec", DECODE_TASK_STACK_SIZE, this, 1,
|
||||
this->task_stack_in_psram_)) {
|
||||
ESP_LOGE(TAG, "Failed to create task");
|
||||
this->status_momentary_error("task_create", 1000);
|
||||
this->set_state_(media_source::MediaSourceState::ERROR);
|
||||
this->decoding_state_ = AudioFileDecodingState::IDLE;
|
||||
return;
|
||||
}
|
||||
}
|
||||
this->decoding_state_ = AudioFileDecodingState::DECODING;
|
||||
break;
|
||||
}
|
||||
case AudioFileDecodingState::DECODING: {
|
||||
if (event_bits & TASK_STARTING) {
|
||||
ESP_LOGD(TAG, "Starting");
|
||||
xEventGroupClearBits(this->event_group_, TASK_STARTING);
|
||||
}
|
||||
|
||||
if (event_bits & TASK_RUNNING) {
|
||||
ESP_LOGV(TAG, "Started");
|
||||
xEventGroupClearBits(this->event_group_, TASK_RUNNING);
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
}
|
||||
|
||||
if ((event_bits & TASK_PAUSED) && this->get_state() != media_source::MediaSourceState::PAUSED) {
|
||||
this->set_state_(media_source::MediaSourceState::PAUSED);
|
||||
} else if (!(event_bits & TASK_PAUSED) && this->get_state() == media_source::MediaSourceState::PAUSED) {
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
}
|
||||
|
||||
if (event_bits & TASK_STOPPING) {
|
||||
ESP_LOGV(TAG, "Stopping");
|
||||
xEventGroupClearBits(this->event_group_, TASK_STOPPING);
|
||||
}
|
||||
|
||||
if (event_bits & TASK_ERROR) {
|
||||
// Report error so the orchestrator knows playback failed; task will have already logged the specific error
|
||||
this->set_state_(media_source::MediaSourceState::ERROR);
|
||||
}
|
||||
|
||||
if (event_bits & TASK_STOPPED) {
|
||||
ESP_LOGD(TAG, "Stopped");
|
||||
xEventGroupClearBits(this->event_group_, ALL_BITS);
|
||||
|
||||
this->decode_task_.deallocate();
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
this->decoding_state_ = AudioFileDecodingState::IDLE;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AudioFileDecodingState::IDLE: {
|
||||
if (this->get_state() == media_source::MediaSourceState::ERROR && !this->status_has_error()) {
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ((this->decoding_state_ == AudioFileDecodingState::IDLE) &&
|
||||
(this->get_state() == media_source::MediaSourceState::IDLE)) {
|
||||
this->disable_loop();
|
||||
}
|
||||
}
|
||||
bool AudioFileMediaSource::can_handle(const std::string &uri) const { return uri.starts_with(URI_PREFIX); }
|
||||
|
||||
// Called from the orchestrator's main loop, so no synchronization needed with loop()
|
||||
bool AudioFileMediaSource::play_uri(const std::string &uri) {
|
||||
if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener() ||
|
||||
xEventGroupGetBits(this->event_group_) & REQUEST_START) {
|
||||
if (!this->is_ready() || this->is_failed() || this->status_has_error() || !this->has_listener()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if source is already playing
|
||||
if (this->get_state() != media_source::MediaSourceState::IDLE) {
|
||||
ESP_LOGE(TAG, "Cannot play '%s': source is busy", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate URI starts with "audio-file://"
|
||||
if (!uri.starts_with("audio-file://")) {
|
||||
if (!uri.starts_with(URI_PREFIX)) {
|
||||
ESP_LOGE(TAG, "Invalid URI: '%s'", uri.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
// Strip "audio-file://" prefix and find the file
|
||||
const char *file_id = uri.c_str() + 13; // "audio-file://" is 13 characters
|
||||
|
||||
const char *file_id = uri.c_str() + sizeof(URI_PREFIX) - 1;
|
||||
this->current_file_ = nullptr;
|
||||
for (const auto &named_file : get_named_audio_files()) {
|
||||
if (strcmp(named_file.file_id, file_id) == 0) {
|
||||
this->current_file_ = named_file.file;
|
||||
xEventGroupSetBits(this->event_group_, EventGroupBits::REQUEST_START);
|
||||
this->enable_loop();
|
||||
return true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
ESP_LOGE(TAG, "Unknown file: '%s'", file_id);
|
||||
if (this->current_file_ == nullptr) {
|
||||
ESP_LOGE(TAG, "Unknown file: '%s'", file_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
micro_decoder::AudioFileType type = to_micro_decoder_type(this->current_file_->file_type);
|
||||
if (this->decoder_->play_buffer(this->current_file_->data, this->current_file_->length, type)) {
|
||||
this->pause_.store(false, std::memory_order_relaxed);
|
||||
this->enable_loop();
|
||||
return true;
|
||||
}
|
||||
|
||||
ESP_LOGE(TAG, "Failed to start playback of '%s'", file_id);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Called from the orchestrator's main loop, so no synchronization needed with loop()
|
||||
void AudioFileMediaSource::handle_command(media_source::MediaSourceCommand command) {
|
||||
if (this->decoding_state_ != AudioFileDecodingState::DECODING) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (command) {
|
||||
case media_source::MediaSourceCommand::STOP:
|
||||
xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_STOP);
|
||||
this->decoder_->stop();
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PAUSE:
|
||||
xEventGroupSetBits(this->event_group_, EventGroupBits::COMMAND_PAUSE);
|
||||
// Only valid while actively playing; ignoring from IDLE/ERROR/PAUSED prevents the state
|
||||
// machine from getting stuck in PAUSED when no playback is active (which would block the
|
||||
// next play_uri() call via its IDLE-state precondition).
|
||||
if (this->get_state() != media_source::MediaSourceState::PLAYING)
|
||||
break;
|
||||
// PAUSE does not stop the decoder task. Instead, on_audio_write() returns 0 and temporarily
|
||||
// yields, which fills any internal buffering and applies back pressure that effectively
|
||||
// pauses the decoder task.
|
||||
this->set_state_(media_source::MediaSourceState::PAUSED);
|
||||
this->pause_.store(true, std::memory_order_relaxed);
|
||||
break;
|
||||
case media_source::MediaSourceCommand::PLAY:
|
||||
xEventGroupClearBits(this->event_group_, EventGroupBits::COMMAND_PAUSE);
|
||||
if (this->get_state() != media_source::MediaSourceState::PAUSED)
|
||||
break;
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
this->pause_.store(false, std::memory_order_relaxed);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
void AudioFileMediaSource::decode_task(void *params) {
|
||||
AudioFileMediaSource *this_source = static_cast<AudioFileMediaSource *>(params);
|
||||
// Called from the decoder task. Forwards to the orchestrator's listener, which is responsible for
|
||||
// being thread-safe with respect to its own audio writer.
|
||||
size_t AudioFileMediaSource::on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) {
|
||||
if (this->pause_.load(std::memory_order_relaxed)) {
|
||||
vTaskDelay(pdMS_TO_TICKS(PAUSE_POLL_DELAY_MS));
|
||||
return 0;
|
||||
}
|
||||
return this->write_output(data, length, timeout_ms, this->stream_info_);
|
||||
}
|
||||
|
||||
do { // do-while(false) ensures RAII objects are destroyed on all exit paths via break
|
||||
// Called from the decoder task before the first on_audio_write().
|
||||
void AudioFileMediaSource::on_stream_info(const micro_decoder::AudioStreamInfo &info) {
|
||||
this->stream_info_ = audio::AudioStreamInfo(info.get_bits_per_sample(), info.get_channels(), info.get_sample_rate());
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STARTING);
|
||||
|
||||
// 0 bytes for input transfer buffer makes it an inplace buffer
|
||||
std::unique_ptr<audio::AudioDecoder> decoder = make_unique<audio::AudioDecoder>(0, 4096);
|
||||
|
||||
esp_err_t err = decoder->start(this_source->current_file_->file_type);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to start decoder: %s", esp_err_to_name(err));
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR | EventGroupBits::TASK_STOPPING);
|
||||
// microDecoder invokes on_state_change() from inside decoder_->loop(), so this runs on the main
|
||||
// loop thread and it's safe to call set_state_() directly.
|
||||
void AudioFileMediaSource::on_state_change(micro_decoder::DecoderState state) {
|
||||
switch (state) {
|
||||
case micro_decoder::DecoderState::IDLE:
|
||||
this->set_state_(media_source::MediaSourceState::IDLE);
|
||||
this->disable_loop();
|
||||
break;
|
||||
}
|
||||
|
||||
// Add the file as a const data source
|
||||
decoder->add_source(this_source->current_file_->data, this_source->current_file_->length);
|
||||
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_RUNNING);
|
||||
|
||||
AudioSinkAdapter audio_sink;
|
||||
bool has_stream_info = false;
|
||||
|
||||
while (true) {
|
||||
EventBits_t event_bits = xEventGroupGetBits(this_source->event_group_);
|
||||
|
||||
if (event_bits & EventGroupBits::COMMAND_STOP) {
|
||||
break;
|
||||
}
|
||||
|
||||
bool paused = event_bits & EventGroupBits::COMMAND_PAUSE;
|
||||
decoder->set_pause_output_state(paused);
|
||||
if (paused) {
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_PAUSED);
|
||||
vTaskDelay(pdMS_TO_TICKS(20));
|
||||
} else {
|
||||
xEventGroupClearBits(this_source->event_group_, EventGroupBits::TASK_PAUSED);
|
||||
}
|
||||
|
||||
// Will stop gracefully once finished with the current file
|
||||
audio::AudioDecoderState decoder_state = decoder->decode(true);
|
||||
|
||||
if (decoder_state == audio::AudioDecoderState::FINISHED) {
|
||||
break;
|
||||
} else if (decoder_state == audio::AudioDecoderState::FAILED) {
|
||||
ESP_LOGE(TAG, "Decoder failed");
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!has_stream_info && decoder->get_audio_stream_info().has_value()) {
|
||||
has_stream_info = true;
|
||||
|
||||
audio::AudioStreamInfo stream_info = decoder->get_audio_stream_info().value();
|
||||
|
||||
ESP_LOGD(TAG, "Bits per sample: %d, Channels: %d, Sample rate: %" PRIu32, stream_info.get_bits_per_sample(),
|
||||
stream_info.get_channels(), stream_info.get_sample_rate());
|
||||
|
||||
if (stream_info.get_bits_per_sample() != 16 || stream_info.get_channels() > 2) {
|
||||
ESP_LOGE(TAG, "Incompatible audio stream. Only 16 bits per sample and 1 or 2 channels are supported");
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
|
||||
break;
|
||||
}
|
||||
|
||||
audio_sink.source = this_source;
|
||||
audio_sink.stream_info = stream_info;
|
||||
esp_err_t err = decoder->add_sink(&audio_sink);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Failed to add sink: %s", esp_err_to_name(err));
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_ERROR);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STOPPING);
|
||||
} while (false);
|
||||
|
||||
// All RAII objects from the do-while block (decoder, audio_sink, etc.) are now destroyed.
|
||||
|
||||
xEventGroupSetBits(this_source->event_group_, EventGroupBits::TASK_STOPPED);
|
||||
vTaskSuspend(nullptr); // Suspend this task indefinitely until the loop method deletes it
|
||||
case micro_decoder::DecoderState::PLAYING:
|
||||
this->set_state_(media_source::MediaSourceState::PLAYING);
|
||||
break;
|
||||
case micro_decoder::DecoderState::FAILED:
|
||||
this->set_state_(media_source::MediaSourceState::ERROR);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::audio_file
|
||||
|
||||
@@ -8,41 +8,48 @@
|
||||
#include "esphome/components/audio_file/audio_file.h"
|
||||
#include "esphome/components/media_source/media_source.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/static_task.h"
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/event_groups.h>
|
||||
#include <micro_decoder/decoder_source.h>
|
||||
#include <micro_decoder/types.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
namespace esphome::audio_file {
|
||||
|
||||
enum class AudioFileDecodingState : uint8_t {
|
||||
START_TASK,
|
||||
DECODING,
|
||||
IDLE,
|
||||
};
|
||||
|
||||
class AudioFileMediaSource : public Component, public media_source::MediaSource {
|
||||
// Inherits from two unrelated listener-style interfaces:
|
||||
// - media_source::MediaSource: this source reports state and writes audio *to* an orchestrator
|
||||
// (the orchestrator calls set_listener() on us with a MediaSourceListener*).
|
||||
// - micro_decoder::DecoderListener: the underlying decoder calls back *into* us with decoded
|
||||
// audio and state changes (we call decoder_->set_listener(this) in setup()).
|
||||
class AudioFileMediaSource : public Component, public media_source::MediaSource, public micro_decoder::DecoderListener {
|
||||
public:
|
||||
void setup() override;
|
||||
void loop() override;
|
||||
void dump_config() override;
|
||||
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->decoder_task_stack_in_psram_ = task_stack_in_psram; }
|
||||
|
||||
// MediaSource interface implementation
|
||||
bool play_uri(const std::string &uri) override;
|
||||
void handle_command(media_source::MediaSourceCommand command) override;
|
||||
bool can_handle(const std::string &uri) const override { return uri.starts_with("audio-file://"); }
|
||||
bool can_handle(const std::string &uri) const override;
|
||||
|
||||
void set_task_stack_in_psram(bool task_stack_in_psram) { this->task_stack_in_psram_ = task_stack_in_psram; }
|
||||
// DecoderListener interface implementation
|
||||
size_t on_audio_write(const uint8_t *data, size_t length, uint32_t timeout_ms) override;
|
||||
void on_stream_info(const micro_decoder::AudioStreamInfo &info) override;
|
||||
void on_state_change(micro_decoder::DecoderState state) override;
|
||||
|
||||
protected:
|
||||
static void decode_task(void *params);
|
||||
|
||||
std::unique_ptr<micro_decoder::DecoderSource> decoder_;
|
||||
audio::AudioStreamInfo stream_info_;
|
||||
audio::AudioFile *current_file_{nullptr};
|
||||
AudioFileDecodingState decoding_state_{AudioFileDecodingState::IDLE};
|
||||
EventGroupHandle_t event_group_{nullptr};
|
||||
StaticTask decode_task_;
|
||||
|
||||
bool task_stack_in_psram_{false};
|
||||
// Written from the main loop in handle_command(), read from the decoder task in
|
||||
// on_audio_write(). Must be atomic to avoid a data race.
|
||||
std::atomic<bool> pause_{false};
|
||||
bool decoder_task_stack_in_psram_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::audio_file
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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 += ' ';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)})"),
|
||||
)
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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_{};
|
||||
};
|
||||
|
||||
|
||||
@@ -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))
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 "
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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):")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user