Compare commits

..

40 Commits

Author SHA1 Message Date
Jonathan Swoboda
9ab2a573ab Merge pull request #17093 from esphome/bump-2026.6.2
2026.6.2
2026-06-20 14:17:55 -04:00
Jonathan Swoboda
99d1c4eb69 Bump version to 2026.6.2 2026-06-20 13:33:41 -04:00
esphome[bot]
b079be756f Bump bundled esphome-device-builder to 1.0.12 (#17091) 2026-06-20 13:33:41 -04:00
J. Nick Koston
039a1f063e [ha-addon] Expose the device-builder public port only when port 6052 is mapped (#17076) 2026-06-20 13:33:41 -04:00
esphome[bot]
2354165e41 Bump bundled esphome-device-builder to 1.0.11 (#17081) 2026-06-20 13:33:41 -04:00
Jonathan Swoboda
f5697b0ae5 [packet_transport] Mark encryption key as cv.sensitive (#17066) 2026-06-20 13:33:41 -04:00
Jonathan Swoboda
fe794a26e8 [fastled_base] Fix RMT5 intr_priority conflict (#17072) 2026-06-20 13:33:41 -04:00
Jonathan Swoboda
8d77051b9a [espidf] Resolve IDF tools path to avoid unnormalized path warning (#17055) 2026-06-20 13:33:41 -04:00
Jesse Hills
9534ab2a19 Merge pull request #17052 from esphome/bump-2026.6.1
2026.6.1
2026-06-19 11:35:03 +12:00
Jesse Hills
1b1c8d767d Bump version to 2026.6.1 2026-06-19 10:06:13 +12:00
esphome[bot]
e3d68deef9 Bump bundled esphome-device-builder to 1.0.10 (#17051) 2026-06-19 10:06:13 +12:00
J. Nick Koston
20cd6a1771 [logger] Hold recursion guard while draining the task log buffer (#17044) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda
d27229a1c7 [esp32] Don't overwrite PlatformIO's factory.bin (#17042) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda
129aebe8f4 [esp32] Support esphome idedata with the native ESP-IDF toolchain (#17040) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda
a84ad7b1f8 [uptime] Revert timestamp sensor device_class to timestamp (#17037) 2026-06-19 10:06:13 +12:00
Jonathan Swoboda
86096b96f5 [build] Skip target-platform deps when populating host unit-test config (#17039) 2026-06-19 10:06:13 +12:00
J. Nick Koston
ac5a28301a [core] Honor transferred address cache in has_resolvable_address (#17025)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-19 10:06:13 +12:00
Jesse Hills
e2157a3d26 Merge pull request #17022 from esphome/bump-2026.6.0
2026.6.0
2026-06-18 12:59:50 +12:00
esphome[bot]
d934fb3910 Bump bundled esphome-device-builder to 1.0.9 (#17021)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-06-18 10:46:22 +12:00
esphome[bot]
c4076ec8a9 Bump bundled esphome-device-builder to 1.0.8 (#17020) 2026-06-18 10:46:12 +12:00
esphome[bot]
9ac22f9244 Bump bundled esphome-device-builder to 1.0.7 (#17018) 2026-06-18 10:46:06 +12:00
Jesse Hills
9e7b3e0330 Bump version to 2026.6.0 2026-06-18 10:18:37 +12:00
Jesse Hills
2abe272867 Merge pull request #17017 from esphome/bump-2026.6.0b4
2026.6.0b4
2026-06-18 10:08:15 +12:00
Jesse Hills
db6b9166f4 Bump version to 2026.6.0b4 2026-06-18 08:20:15 +12:00
esphome[bot]
7ab95ddcb1 Bump bundled esphome-device-builder to 1.0.6 (#17016) 2026-06-18 08:20:02 +12:00
esphome[bot]
cdd2bfbc60 Bump bundled esphome-device-builder to 1.0.4 (#17013) 2026-06-18 08:19:05 +12:00
esphome[bot]
41f7f8cccb Bump bundled esphome-device-builder to 1.0.3 (#17005) 2026-06-18 08:19:05 +12:00
Jonathan Swoboda
045de436ba [ota] Scale ESP-IDF OTA erase watchdog to image size (#16998) 2026-06-18 08:19:05 +12:00
Jonathan Swoboda
24e276c3f9 [esp32_hosted] Bump esp_hosted to 2.12.9 (#16999) 2026-06-18 08:18:25 +12:00
Jesse Hills
9e768bb510 Merge pull request #16997 from esphome/bump-2026.6.0b3
2026.6.0b3
2026-06-16 23:52:22 +12:00
Jesse Hills
53fd99578a Bump version to 2026.6.0b3 2026-06-16 23:02:55 +12:00
Jesse Hills
310baab524 [docker] Bundle device-builder 1.0.1, make HA add-on builder-only (#16989)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-06-16 23:02:55 +12:00
Jesse Hills
0422b581cb [core] Stop parent git repos from breaking ESP-IDF/PlatformIO builds (#16994) 2026-06-16 23:02:55 +12:00
Jesse Hills
0ce89c17ab [ci] Push branch-tagged docker images to ghcr.io for local testing (#16992) 2026-06-16 23:02:55 +12:00
Jesse Hills
66be793cd8 [docker] Remove alpine base, build only on debian (#16991) 2026-06-16 23:02:54 +12:00
Jonathan Swoboda
1d38498ca7 [openthread] Fix InstanceLock releasing the lock twice on try_acquire (#16980) 2026-06-16 23:02:54 +12:00
Kevin Ahrendt
aef9b5b72f [audio] Bump microMP3 to v0.2.3 (#16977) 2026-06-16 23:02:54 +12:00
J. Nick Koston
9bf35ab8fb [core] Attribute "took a long time" blocking warning to the owning script (#16768) 2026-06-16 23:02:54 +12:00
Clyde Stubbs
33ace9d698 [mipi_dsi] Add SWRESET command to M5Stack Tab5-V2 init sequence (#16975) 2026-06-16 23:02:54 +12:00
Jesse Hills
32ab3abd7c [psram] Make schema extractable with per-variant options (#16949)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-06-16 23:02:54 +12:00
83 changed files with 1412 additions and 524 deletions

View File

@@ -1 +1 @@
a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c
72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6

View File

@@ -15,11 +15,6 @@ inputs:
description: "Version to build"
required: true
example: "2023.12.0"
base_os:
description: "Base OS to use"
required: false
default: "debian"
example: "debian"
runs:
using: "composite"
steps:
@@ -60,7 +55,6 @@ runs:
build-args: |
BUILD_TYPE=${{ inputs.build_type }}
BUILD_VERSION=${{ inputs.version }}
BUILD_OS=${{ inputs.base_os }}
outputs: |
type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
@@ -86,7 +80,6 @@ runs:
build-args: |
BUILD_TYPE=${{ inputs.build_type }}
BUILD_VERSION=${{ inputs.version }}
BUILD_OS=${{ inputs.base_os }}
outputs: |
type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true

View File

@@ -22,7 +22,7 @@ on:
- "script/platformio_install_deps.py"
permissions:
contents: read # actions/checkout only; the build does not push images
contents: read # actions/checkout only
concurrency:
# yamllint disable-line rule:line-length
@@ -33,6 +33,9 @@ jobs:
check-docker:
name: Build docker containers
runs-on: ${{ matrix.os }}
permissions:
contents: read # actions/checkout to load Dockerfile and build context
packages: write # push branch-tagged images to ghcr.io for local testing
strategy:
fail-fast: false
matrix:
@@ -41,6 +44,9 @@ jobs:
- "ha-addon"
- "docker"
# - "lint"
outputs:
tag: ${{ steps.tag.outputs.tag }}
push: ${{ steps.tag.outputs.push }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
@@ -50,14 +56,82 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Set TAG
- name: Determine tag and whether to push
id: tag
run: |
echo "TAG=check" >> $GITHUB_ENV
# Sanitize the branch name into a valid docker tag: replace invalid
# characters, ensure the first character is valid (tags must start
# with [A-Za-z0-9_]), and cap the length at 128 characters.
branch="${{ github.head_ref || github.ref_name }}"
tag="${branch//[^a-zA-Z0-9_.-]/-}"
case "$tag" in
[a-zA-Z0-9_]*) ;;
*) tag="pr-${tag}" ;;
esac
tag="${tag:0:128}"
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
# Only push branch images for same-repo pull requests. Push events
# only fire for dev/beta/release, whose images are owned by the
# release pipeline -- never overwrite those from here.
if [ "${{ github.event_name }}" = "pull_request" ] \
&& [ "${{ github.repository }}" = "esphome/esphome" ] \
&& [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then
echo "push=true" >> "$GITHUB_OUTPUT"
else
echo "push=false" >> "$GITHUB_OUTPUT"
fi
- name: Log in to the GitHub container registry
if: steps.tag.outputs.push == 'true'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run build
run: |
docker/build.py \
--tag "${TAG}" \
--tag "${{ steps.tag.outputs.tag }}" \
--arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \
--build-type "${{ matrix.build_type }}" \
build
--registry ghcr \
build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }}
manifest:
name: Push ${{ matrix.build_type }} manifest to ghcr.io
needs: [check-docker]
if: needs.check-docker.outputs.push == 'true'
runs-on: ubuntu-24.04
permissions:
contents: read # actions/checkout to run docker/build.py
packages: write # buildx imagetools writes the multi-arch tag to ghcr.io
strategy:
fail-fast: false
matrix:
build_type:
- "ha-addon"
- "docker"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Log in to the GitHub container registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push manifest
run: |
docker/build.py \
--tag "${{ needs.check-docker.outputs.tag }}" \
--build-type "${{ matrix.build_type }}" \
--registry ghcr \
manifest

View File

@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
# could be handy for archiving the generated documentation or if some version
# control system is used.
PROJECT_NUMBER = 2026.6.0b2
PROJECT_NUMBER = 2026.6.2
# Using the PROJECT_BRIEF tag one can provide an optional one line description
# for a project that appears at the top of each page and should give viewer a

View File

@@ -1,10 +1,9 @@
ARG BUILD_VERSION=dev
ARG BUILD_OS=alpine
ARG BUILD_BASE_VERSION=2025.04.0
ARG BUILD_BASE_VERSION=2026.06.0
ARG BUILD_TYPE=docker
FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker
FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon
FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker
FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon
ARG BUILD_TYPE
FROM base-source-${BUILD_TYPE} AS base
@@ -18,13 +17,9 @@ RUN git config --system --add safe.directory "*" \
# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without
# it idf_tools.py rejects the openocd install with exit 127 and aborts
# the whole framework setup.
RUN if command -v apk > /dev/null; then \
apk add --no-cache build-base libusb; \
else \
apt-get update \
&& apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
&& rm -rf /var/lib/apt/lists/*; \
fi
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
&& rm -rf /var/lib/apt/lists/*
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
@@ -36,6 +31,9 @@ RUN \
uv pip install --no-cache-dir \
-r /requirements.txt
# Install the ESPHome Device Builder dashboard.
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12
RUN \
platformio settings set enable_telemetry No \
&& platformio settings set check_platformio_interval 1000000 \

View File

@@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon"
TYPE_LINT = "lint"
TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT]
REGISTRY_GHCR = "ghcr"
REGISTRY_DOCKERHUB = "dockerhub"
REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB]
parser = argparse.ArgumentParser()
parser.add_argument(
@@ -34,6 +38,12 @@ parser.add_argument(
parser.add_argument(
"--build-type", choices=TYPES, required=True, help="The type of build to run"
)
parser.add_argument(
"--registry",
choices=REGISTRIES,
action="append",
help="Restrict to specific registries (default: all). May be passed multiple times.",
)
parser.add_argument(
"--dry-run", action="store_true", help="Don't run any commands, just print them"
)
@@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t
build_parser.add_argument(
"--load", help="Load the docker image locally", action="store_true"
)
build_parser.add_argument(
"--no-cache-to",
help="Don't write the build cache (avoids polluting the shared cache)",
action="store_true",
)
manifest_parser = subparsers.add_parser(
"manifest", help="Create a manifest from already pushed images"
)
@@ -95,11 +110,14 @@ def main():
print("Command failed")
sys.exit(1)
registries = args.registry or REGISTRIES
# detect channel from tag
match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag)
major_minor_version = None
if match is None:
channel = CHANNEL_DEV
# Custom tag (e.g. a branch name) -- push only the tag itself
channel = None
elif match.group(2) is None:
major_minor_version = match.group(1)
channel = CHANNEL_RELEASE
@@ -128,11 +146,18 @@ def main():
CHANNEL_DEV: "cache-dev",
CHANNEL_BETA: "cache-beta",
CHANNEL_RELEASE: "cache-latest",
}[channel]
cache_img = f"ghcr.io/{params.build_to}:{cache_tag}"
}.get(channel, "cache-dev")
# Cache images live alongside the pushed images; prefer GHCR when it is
# one of the selected registries, otherwise fall back to Docker Hub so a
# registry-restricted build doesn't need GHCR auth.
cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else ""
cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}"
imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push]
imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push]
imgs = []
if REGISTRY_DOCKERHUB in registries:
imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push]
if REGISTRY_GHCR in registries:
imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push]
# 3. build
cmd = [
@@ -155,7 +180,9 @@ def main():
for img in imgs:
cmd += ["--tag", img]
if args.push:
cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"]
cmd += ["--push"]
if not args.no_cache_to:
cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"]
if args.load:
cmd += ["--load"]
@@ -163,20 +190,22 @@ def main():
elif args.command == "manifest":
manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to
targets = [f"{manifest}:{tag}" for tag in tags_to_push]
targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push]
# 1. Create manifests
targets = []
if REGISTRY_DOCKERHUB in registries:
targets += [f"{manifest}:{tag}" for tag in tags_to_push]
if REGISTRY_GHCR in registries:
targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push]
# Use buildx imagetools (not `docker manifest`) so the per-arch sources,
# which buildx pushes as single-platform manifest lists, are combined
# and pushed correctly in one step.
for target in targets:
cmd = ["docker", "manifest", "create", target]
cmd = ["docker", "buildx", "imagetools", "create", "--tag", target]
for arch in ARCHS:
src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}"
if target.startswith("ghcr.io"):
src = f"ghcr.io/{src}"
cmd.append(src)
run_command(*cmd)
# 2. Push manifests
for target in targets:
run_command("docker", "manifest", "push", target)
if __name__ == "__main__":

View File

@@ -27,4 +27,12 @@ if [[ -d /build ]]; then
export ESPHOME_BUILD_PATH=/build
fi
# The default CMD is "dashboard /config". Route the dashboard to the new
# Device Builder, but pass every other subcommand (compile, run, config,
# logs, ...) straight through to the esphome CLI so direct CLI use keeps working.
if [[ "$1" == "dashboard" ]]; then
shift
exec esphome-device-builder "$@"
fi
exec esphome "$@"

View File

@@ -1,22 +0,0 @@
#!/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."

View File

@@ -1,96 +0,0 @@
types {
text/html html htm shtml;
text/css css;
text/xml xml;
image/gif gif;
image/jpeg jpeg jpg;
application/javascript js;
application/atom+xml atom;
application/rss+xml rss;
text/mathml mml;
text/plain txt;
text/vnd.sun.j2me.app-descriptor jad;
text/vnd.wap.wml wml;
text/x-component htc;
image/png png;
image/svg+xml svg svgz;
image/tiff tif tiff;
image/vnd.wap.wbmp wbmp;
image/webp webp;
image/x-icon ico;
image/x-jng jng;
image/x-ms-bmp bmp;
font/woff woff;
font/woff2 woff2;
application/java-archive jar war ear;
application/json json;
application/mac-binhex40 hqx;
application/msword doc;
application/pdf pdf;
application/postscript ps eps ai;
application/rtf rtf;
application/vnd.apple.mpegurl m3u8;
application/vnd.google-earth.kml+xml kml;
application/vnd.google-earth.kmz kmz;
application/vnd.ms-excel xls;
application/vnd.ms-fontobject eot;
application/vnd.ms-powerpoint ppt;
application/vnd.oasis.opendocument.graphics odg;
application/vnd.oasis.opendocument.presentation odp;
application/vnd.oasis.opendocument.spreadsheet ods;
application/vnd.oasis.opendocument.text odt;
application/vnd.openxmlformats-officedocument.presentationml.presentation
pptx;
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
xlsx;
application/vnd.openxmlformats-officedocument.wordprocessingml.document
docx;
application/vnd.wap.wmlc wmlc;
application/x-7z-compressed 7z;
application/x-cocoa cco;
application/x-java-archive-diff jardiff;
application/x-java-jnlp-file jnlp;
application/x-makeself run;
application/x-perl pl pm;
application/x-pilot prc pdb;
application/x-rar-compressed rar;
application/x-redhat-package-manager rpm;
application/x-sea sea;
application/x-shockwave-flash swf;
application/x-stuffit sit;
application/x-tcl tcl tk;
application/x-x509-ca-cert der pem crt;
application/x-xpinstall xpi;
application/xhtml+xml xhtml;
application/xspf+xml xspf;
application/zip zip;
application/octet-stream bin exe dll;
application/octet-stream deb;
application/octet-stream dmg;
application/octet-stream iso img;
application/octet-stream msi msp msm;
audio/midi mid midi kar;
audio/mpeg mp3;
audio/ogg ogg;
audio/x-m4a m4a;
audio/x-realaudio ra;
video/3gpp 3gpp 3gp;
video/mp2t ts;
video/mp4 mp4;
video/mpeg mpeg mpg;
video/quicktime mov;
video/webm webm;
video/x-flv flv;
video/x-m4v m4v;
video/x-mng mng;
video/x-ms-asf asx asf;
video/x-ms-wmv wmv;
video/x-msvideo avi;
}

View File

@@ -1,16 +0,0 @@
proxy_http_version 1.1;
proxy_ignore_client_abort off;
proxy_read_timeout 86400s;
proxy_redirect off;
proxy_send_timeout 86400s;
proxy_max_temp_file_size 0;
proxy_set_header Accept-Encoding "";
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $http_host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-NginX-Proxy true;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Authorization "";

View File

@@ -1,8 +0,0 @@
root /dev/null;
server_name $hostname;
client_max_body_size 512m;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header X-Robots-Tag none;

View File

@@ -1,8 +0,0 @@
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
ssl_session_timeout 10m;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
ssl_stapling on;
ssl_stapling_verify on;

View File

@@ -1,3 +0,0 @@
upstream esphome {
server unix:/var/run/esphome.sock;
}

View File

@@ -1,30 +0,0 @@
daemon off;
user root;
pid /var/run/nginx.pid;
worker_processes 1;
error_log /proc/1/fd/1 error;
events {
worker_connections 1024;
}
http {
include /etc/nginx/includes/mime.types;
access_log off;
default_type application/octet-stream;
gzip on;
keepalive_timeout 65;
sendfile on;
server_tokens off;
tcp_nodelay on;
tcp_nopush on;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
include /etc/nginx/includes/upstream.conf;
include /etc/nginx/servers/*.conf;
}

View File

@@ -1 +0,0 @@
Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley)

View File

@@ -1,28 +0,0 @@
server {
{{ if not .ssl }}
listen 6052 default_server;
{{ else }}
listen 6052 default_server ssl http2;
{{ end }}
include /etc/nginx/includes/server_params.conf;
include /etc/nginx/includes/proxy_params.conf;
{{ if .ssl }}
include /etc/nginx/includes/ssl_params.conf;
ssl_certificate /ssl/{{ .certfile }};
ssl_certificate_key /ssl/{{ .keyfile }};
# Redirect http requests to https on the same port.
# https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/
error_page 497 https://$http_host$request_uri;
{{ end }}
# Clear Home Assistant Ingress header
proxy_set_header X-HA-Ingress "";
location / {
proxy_pass http://esphome;
}
}

View File

@@ -1,18 +0,0 @@
server {
listen 127.0.0.1:{{ .port }} default_server;
listen {{ .interface }}:{{ .port }} default_server;
include /etc/nginx/includes/server_params.conf;
include /etc/nginx/includes/proxy_params.conf;
# Set Home Assistant Ingress header
proxy_set_header X-HA-Ingress "YES";
location / {
allow 172.30.32.2;
allow 127.0.0.1;
deny all;
proxy_pass http://esphome;
}
}

View File

@@ -16,7 +16,7 @@ fi
port=$(bashio::addon.ingress_port)
# Wait for NGINX to become available
# Wait for the ESPHome Device Builder to become available
bashio::net.wait_for "${port}" "127.0.0.1" 300
config=$(\

View File

@@ -2,7 +2,7 @@
# shellcheck shell=bash
# ==============================================================================
# Home Assistant Community Add-on: ESPHome
# Take down the S6 supervision tree when ESPHome dashboard fails
# Take down the S6 supervision tree when ESPHome Device Builder fails
# ==============================================================================
declare exit_code
readonly exit_code_container=$(</run/s6-linux-init-container-results/exitcode)
@@ -10,7 +10,7 @@ readonly exit_code_service="${1}"
readonly exit_code_signal="${2}"
bashio::log.info \
"Service ESPHome dashboard exited with code ${exit_code_service}" \
"Service ESPHome Device Builder exited with code ${exit_code_service}" \
"(by signal ${exit_code_signal})"
if [[ "${exit_code_service}" -eq 256 ]]; then

View File

@@ -2,7 +2,7 @@
# shellcheck shell=bash
# ==============================================================================
# Community Hass.io Add-ons: ESPHome
# Runs the ESPHome dashboard
# Runs the ESPHome Device Builder
# ==============================================================================
readonly pio_cache_base=/data/cache/platformio
@@ -49,12 +49,21 @@ 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)"
# Only signal device-builder to expose the public LAN port when the operator
# mapped port 6052, matching the legacy dashboard where nginx listened on the
# fixed port 6052 only when it was configured. We use the mapping purely as a
# presence check and don't forward the published value; device-builder binds
# its default port 6052 (the fixed container port, as the legacy
# "listen 6052" did). --ha-addon-allow-public is inert on its own: the no-auth
# gate is the DISABLE_HA_AUTHENTICATION env var set above, so both opt-ins are
# required to bind 6052 unauthenticated; either alone stays ingress-only.
set --
if bashio::var.has_value "$(bashio::addon.port 6052)"; then
set -- --ha-addon-allow-public
fi
bashio::log.info "Starting ESPHome dashboard..."
exec esphome dashboard /config/esphome --socket /var/run/esphome.sock --ha-addon
bashio::log.info "Starting ESPHome Device Builder..."
exec esphome-device-builder /config/esphome \
--ha-addon \
--ingress-port "$(bashio::addon.ingress_port)" \
"$@"

View File

@@ -1,35 +0,0 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
# ==============================================================================
# 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
bashio::var.json \
interface "$(bashio::addon.ip_address)" \
port "^$(bashio::addon.ingress_port)" \
| tempio \
-template /etc/nginx/templates/ingress.gtpl \
-out /etc/nginx/servers/ingress.conf
# Generate direct access configuration, if enabled.
if bashio::var.has_value "$(bashio::addon.port 6052)"; then
bashio::config.require.ssl
bashio::var.json \
certfile "$(bashio::config 'certfile')" \
keyfile "$(bashio::config 'keyfile')" \
ssl "^$(bashio::config 'ssl')" \
| tempio \
-template /etc/nginx/templates/direct.gtpl \
-out /etc/nginx/servers/direct.conf
fi

View File

@@ -1 +0,0 @@
/etc/s6-overlay/s6-rc.d/init-nginx/run

View File

@@ -1,25 +0,0 @@
#!/command/with-contenv bashio
# ==============================================================================
# Community Hass.io Add-ons: ESPHome
# Take down the S6 supervision tree when NGINX fails
# ==============================================================================
declare exit_code
readonly exit_code_container=$(</run/s6-linux-init-container-results/exitcode)
readonly exit_code_service="${1}"
readonly exit_code_signal="${2}"
bashio::log.info \
"Service NGINX exited with code ${exit_code_service}" \
"(by signal ${exit_code_signal})"
if [[ "${exit_code_service}" -eq 256 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo $((128 + $exit_code_signal)) > /run/s6-linux-init-container-results/exitcode
fi
[[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt
elif [[ "${exit_code_service}" -ne 0 ]]; then
if [[ "${exit_code_container}" -eq 0 ]]; then
echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode
fi
exec /run/s6/basedir/bin/halt
fi

View File

@@ -1,27 +0,0 @@
#!/command/with-contenv bashio
# shellcheck shell=bash
# ==============================================================================
# Community Hass.io Add-ons: ESPHome
# Runs the NGINX proxy
# ==============================================================================
# The new device builder handles HA ingress itself, so nginx is bypassed.
# Block the longrun so s6 keeps the dependency satisfied, but exit 0 on
# SIGTERM instead of being signal-killed; a 256/15 exit makes nginx/finish
# stamp the container exit 143, which trips the Supervisor's SIGTERM check.
if bashio::config.true 'use_new_device_builder'; then
bashio::log.info "NGINX bypassed: new device builder serves ingress directly."
trap 'exit 0' TERM
sleep infinity &
wait
exit 0
fi
bashio::log.info "Waiting for ESPHome dashboard to come up..."
while [[ ! -S /var/run/esphome.sock ]]; do
sleep 0.5
done
bashio::log.info "Starting NGINX..."
exec nginx

View File

@@ -504,6 +504,12 @@ def has_resolvable_address() -> bool:
if has_ip_address():
return True
# The dashboard pre-resolves the device and passes the IPs via
# --mdns-address-cache/--dns-address-cache; honor a cached address even when the
# device has mDNS disabled (e.g. a .local host found via ping).
if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address):
return True
if has_mdns():
return True
@@ -1765,6 +1771,21 @@ def command_update_all(args: ArgsProtocol) -> int | None:
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
import json
if CORE.using_toolchain_esp_idf:
# Native ESP-IDF derives idedata from the build's compile_commands.json,
# so the configuration must already be compiled.
from esphome.espidf import toolchain as espidf_toolchain
idedata = espidf_toolchain.get_idedata()
if idedata is None:
_LOGGER.error(
"No idedata available; compile the configuration first",
)
return 1
print(json.dumps(idedata, indent=2) + "\n")
return 0
if not CORE.using_toolchain_platformio:
_LOGGER.error(
"The idedata command is not compatible with %s toolchain",

View File

@@ -395,7 +395,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.1")
add_idf_component(name="esphome/micro-mp3", ref="0.2.3")
_emit_memory_pair(
data.mp3.buffer_memory,
"CONFIG_MP3_DECODER_PREFER_PSRAM",

View File

@@ -1,3 +1,4 @@
from collections.abc import Callable, Iterable
import contextlib
from dataclasses import dataclass
import itertools
@@ -6,6 +7,7 @@ import os
from pathlib import Path
import re
import subprocess
from typing import Any
from esphome import yaml_util
import esphome.codegen as cg
@@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority
from esphome.espidf.component import generate_idf_components
import esphome.final_validate as fv
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.types import ConfigType
from esphome.writer import clean_build, clean_cmake_cache
@@ -496,6 +499,32 @@ def get_esp32_variant(core_obj=None):
return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT]
def variant_filtered_enum(
by_variant: dict[str, Iterable[Any]], **kwargs: Any
) -> Callable[[Any], Any]:
"""Build a ``one_of`` validator whose valid set depends on the active variant.
``by_variant`` maps each ESP32 variant constant to the iterable of values that
are valid on that variant. At validation time the value is checked against the
set allowed for the current target variant. For schema extraction the inverted
``{value: [variants, ...]}`` map is returned instead, so the language-schema
dump can tag every option with the variants that accept it and frontends can
filter to the user's selected variant.
"""
by_value: dict[str, list[str]] = {}
for variant, values in by_variant.items():
for value in values:
by_value.setdefault(str(value), []).append(variant)
@schema_extractor("variant_enum")
def validator(value: Any) -> Any:
if value is SCHEMA_EXTRACT:
return by_value
return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value)
return validator
def get_board(core_obj=None):
return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD]

View File

@@ -224,6 +224,17 @@ def merge_factory_bin(source, target, env):
flash_size = env.BoardConfig().get("upload.flash_size", "4MB")
chip = env.BoardConfig().get("build.mcu", "esp32")
# PlatformIO's esp-idf builder already creates a correct firmware.factory.bin (right
# artifact names and partition offsets, including custom partition tables). The merge
# below is only a fallback and cannot honor custom layouts, so don't overwrite an image
# PlatformIO already produced. Post-build actions only run when firmware.bin is rebuilt,
# and PlatformIO's combined-image builder runs before us in that batch, so an existing
# file here is current.
output_path = firmware_path.with_suffix(".factory.bin")
if output_path.exists():
print(f"{output_path.name} already created by PlatformIO - skipping merge")
return
sections = []
flasher_args_path = build_dir / "flasher_args.json"
@@ -291,7 +302,6 @@ def merge_factory_bin(source, target, env):
print("No valid flash sections found — skipping .factory.bin creation.")
return
output_path = firmware_path.with_suffix(".factory.bin")
python_exe = f'"{env.subst("$PYTHONEXE")}"'
cmd = [
python_exe,

View File

@@ -257,7 +257,7 @@ async def to_code(config):
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.5.1")
esp32.add_idf_component(name="espressif/wifi_remote_over_eppp", ref="0.3.2")
esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.5")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.8")
esp32.add_idf_component(name="espressif/esp_hosted", ref="2.12.9")
else:
esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0")
esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0")

View File

@@ -50,6 +50,11 @@ async def new_fastled_light(config):
ref="d44c800a9e876a8394caefc2ce4915dd96dac77b",
)
cg.add_library("SPI", None)
# FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with
# esphome's RMT channels (remote_transmitter etc., priority 0): the IDF
# driver rejects FastLED's channel and show() then hangs ~3s with no
# output. Override to 0 so it shares the interrupt. See #17063.
cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0")
else:
cg.add_library("fastled/FastLED", "3.9.16")
await light.register_light(var, config)

View File

@@ -175,6 +175,10 @@ void Logger::process_messages_() {
#ifdef USE_ESPHOME_TASK_LOG_BUFFER
// Process any buffered messages when available
if (this->log_buffer_.has_messages()) {
// Prevent main-task logs emitted by listener callbacks (e.g. the API send path) from re-entering
// and corrupting the shared tx_buffer_ / API shared_write_buffer_ while we are draining here.
// Mirrors the guard held by log_message_to_buffer_and_send_ on the synchronous logging path.
RecursionGuard guard(this->main_task_recursion_guard_);
logger::TaskLogBuffer::LogMessage *message;
uint16_t text_length;
while (this->log_buffer_.borrow_message_main_loop(message, text_length)) {

View File

@@ -71,6 +71,7 @@ DriverChip(
swap_xy=cv.UNDEFINED,
color_order="RGB",
initsequence=[
(0x01,),
(0x60, 0x71, 0x23, 0xa2),
(0x60, 0x71, 0x23, 0xa3),
(0x60, 0x71, 0x23, 0xa4),

View File

@@ -227,7 +227,7 @@ bool OpenThreadComponent::teardown() {
ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory");
return true;
}
otInstance *instance = lock->get_instance();
otInstance *instance = lock.get_instance();
otSrpClientClearHostAndServices(instance);
otSrpClientBuffersFreeAllServices(instance);
global_openthread_component = nullptr;

View File

@@ -86,19 +86,32 @@ class OpenThreadSrpComponent : public Component {
void *pool_alloc_(size_t size);
};
// RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the
// guard may or may not own the lock (try_acquire can fail), so check it with
// operator bool before use. Non-copyable and non-movable: the factories return
// by value via guaranteed copy elision, so a guard is never duplicated and the
// lock is released exactly once, when the owning guard goes out of scope.
class InstanceLock {
public:
static std::optional<InstanceLock> try_acquire(int delay);
// May fail to acquire within delay ms; check the returned guard with operator bool.
static InstanceLock try_acquire(int delay);
// Blocks until the lock is held.
static InstanceLock acquire();
InstanceLock(const InstanceLock &) = delete;
InstanceLock(InstanceLock &&) = delete;
InstanceLock &operator=(const InstanceLock &) = delete;
InstanceLock &operator=(InstanceLock &&) = delete;
~InstanceLock();
// Returns the global openthread instance guarded by this lock
explicit operator bool() const { return this->owns_; }
// Returns the global openthread instance. Only valid on an owning guard
// (operator bool is true); the instance must not be used without the lock held.
otInstance *get_instance();
private:
// Use a private constructor in order to force the handling
// of acquisition failure
InstanceLock() {}
explicit InstanceLock(bool owns) : owns_(owns) {}
bool owns_;
};
} // namespace esphome::openthread

View File

@@ -216,14 +216,11 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() {
// not thread safe, only use in read-only use cases
otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); }
std::optional<InstanceLock> InstanceLock::try_acquire(int delay) {
InstanceLock InstanceLock::try_acquire(int delay) {
if (!global_openthread_component->is_lock_initialized()) {
return {};
return InstanceLock(false);
}
if (esp_openthread_lock_acquire(delay)) {
return InstanceLock();
}
return {};
return InstanceLock(esp_openthread_lock_acquire(delay));
}
InstanceLock InstanceLock::acquire() {
@@ -242,12 +239,16 @@ InstanceLock InstanceLock::acquire() {
while (!esp_openthread_lock_acquire(100)) {
esp_task_wdt_reset();
}
return InstanceLock();
return InstanceLock(true);
}
otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); }
InstanceLock::~InstanceLock() { esp_openthread_lock_release(); }
InstanceLock::~InstanceLock() {
if (this->owns_) {
esp_openthread_lock_release();
}
}
} // namespace esphome::openthread
#endif

View File

@@ -17,7 +17,7 @@ class OpenThreadInstancePollingComponent : public PollingComponent {
return;
}
this->update_instance(lock->get_instance());
this->update_instance(lock.get_instance());
}
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }

View File

@@ -57,7 +57,18 @@ OTAResponseTypes IDFOTABackend::begin(size_t image_size, ota::OTAType ota_type)
return OTA_RESPONSE_ERROR_NO_UPDATE_PARTITION;
}
watchdog::WatchdogManager watchdog(15000);
// esp_ota_begin() erases the destination region, which blocks loopTask and
// scales with the erase size -- a fixed watchdog overruns on large OTA slots.
// An unknown size (0, e.g. web_server uploads) erases the whole partition, so
// budget against the bytes actually erased. ~10ms/KiB (conservative
// ~100 KiB/s erase) over a 15s floor; panic stays on so a stuck erase still
// resets rather than hanging forever.
size_t erase_size = image_size;
if (erase_size == 0 || erase_size > this->partition_->size) {
erase_size = this->partition_->size;
}
const uint32_t erase_budget_ms = 15000 + (erase_size >> 10) * 10;
watchdog::WatchdogManager watchdog(erase_budget_ms);
esp_err_t err = esp_ota_begin(this->partition_, image_size, &this->update_handle_);
if (err != ESP_OK) {

View File

@@ -69,7 +69,7 @@ ENCRYPTION_SCHEMA = {
cv.Optional(CONF_ENCRYPTION): cv.maybe_simple_value(
cv.Schema(
{
cv.Required(CONF_KEY): cv.string,
cv.Required(CONF_KEY): cv.sensitive(cv.string),
}
),
key=CONF_KEY,

View File

@@ -16,6 +16,7 @@ from esphome.components.esp32 import (
add_idf_sdkconfig_option,
get_esp32_variant,
idf_version,
variant_filtered_enum,
)
import esphome.config_validation as cv
from esphome.const import (
@@ -29,6 +30,7 @@ from esphome.const import (
)
from esphome.core import CORE
import esphome.final_validate as fv
from esphome.types import ConfigType
CODEOWNERS = ["@esphome/core"]
DOMAIN = "psram"
@@ -70,6 +72,11 @@ SPIRAM_SPEEDS = {
VARIANT_ESP32P4: (20, 100, 200),
}
SPIRAM_SPEEDS_MHZ = {
variant: tuple(f"{speed}MHZ" for speed in speeds)
for variant, speeds in SPIRAM_SPEEDS.items()
}
def supported() -> bool:
if not CORE.is_esp32:
@@ -145,15 +152,23 @@ def validate_psram_mode(config):
return config
def get_config_schema(config):
def _set_variant_defaults(config: ConfigType) -> ConfigType:
"""Resolve variant-dependent defaults before the static schema validates.
The set of valid ``mode``/``speed`` values is variant-specific (enforced by
``variant_filtered_enum`` in the schema below); this only supplies the default
when the user omits the option. ``mode`` has no single default on chips that
support more than one mode, so selection is required there.
"""
variant = get_esp32_variant()
speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])]
if not speeds:
modes = SPIRAM_MODES.get(variant)
speeds = SPIRAM_SPEEDS.get(variant)
if not modes or not speeds:
raise cv.Invalid("PSRAM is not supported on this chip")
modes = SPIRAM_MODES[variant]
if CONF_MODE not in config and len(modes) != 1:
raise (
cv.Invalid(
config = config.copy()
if CONF_MODE not in config:
if len(modes) != 1:
raise cv.Invalid(
textwrap.dedent(
f"""
{variant} requires PSRAM mode selection; one of {", ".join(modes)}
@@ -161,20 +176,27 @@ def get_config_schema(config):
"""
)
)
)
return cv.Schema(
config[CONF_MODE] = modes[0]
if CONF_SPEED not in config:
config[CONF_SPEED] = f"{speeds[0]}MHZ"
return config
CONFIG_SCHEMA = cv.All(
_set_variant_defaults,
cv.Schema(
{
cv.GenerateID(): cv.declare_id(PsramComponent),
cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True),
cv.Optional(CONF_MODE): variant_filtered_enum(SPIRAM_MODES, lower=True),
cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean,
cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True),
cv.Optional(CONF_SPEED): variant_filtered_enum(
SPIRAM_SPEEDS_MHZ, upper=True
),
cv.Optional(CONF_DISABLED, default=False): cv.boolean,
cv.Optional(CONF_IGNORE_NOT_FOUND, default=True): cv.boolean,
}
)(config)
CONFIG_SCHEMA = get_config_schema
),
)
def _store_psram_guaranteed(config):

View File

@@ -47,7 +47,7 @@ class RuntimeStatsCollector {
// overhead between Phase A and stats belongs to "residual").
// Residual overhead at log time = active Σ(component) before tail,
// which captures per-iteration inter-component bookkeeping (set_current_component,
// WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls,
// LoopBlockingGuard construction/destruction, feed_wdt_with_time calls,
// the for-loop itself).
void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) {
this->period_active_count_++;

View File

@@ -3,6 +3,7 @@
#include <list>
#include <memory>
#include <tuple>
#include "esphome/core/application.h"
#include "esphome/core/automation.h"
#include "esphome/core/component.h"
#include "esphome/core/helpers.h"
@@ -57,6 +58,14 @@ template<typename... Ts> class Script : public ScriptLogger, public Trigger<Ts..
this->execute(std::get<S>(tuple)...);
}
// Run the action chain with this script's name published as the current source (RAII save/restore,
// so nesting composes), so deferred work inside the script is attributed to it in blocking
// warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame).
inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE {
ScopedSourceGuard source_guard{this->name_};
this->trigger(x...);
}
const LogString *name_{nullptr};
};
@@ -74,7 +83,7 @@ template<typename... Ts> class SingleScript : public Script<Ts...> {
return;
}
this->trigger(x...);
this->run_actions_(x...);
}
};
@@ -91,7 +100,7 @@ template<typename... Ts> class RestartScript : public Script<Ts...> {
this->stop_action();
}
this->trigger(x...);
this->run_actions_(x...);
}
};
@@ -136,7 +145,7 @@ template<typename... Ts> class QueueingScript : public Script<Ts...>, public Com
return;
}
this->trigger(x...);
this->run_actions_(x...);
// Check if the trigger was immediate and we can continue right away.
this->loop();
}
@@ -175,7 +184,7 @@ template<typename... Ts> class QueueingScript : public Script<Ts...>, public Com
}
template<size_t... S> void trigger_tuple_(const std::tuple<Ts...> &tuple, std::index_sequence<S...> /*unused*/) {
this->trigger(std::get<S>(tuple)...);
this->run_actions_(std::get<S>(tuple)...);
}
int num_queued_ = 0; // Number of queued instances (not including currently running)
@@ -197,7 +206,7 @@ template<typename... Ts> class ParallelScript : public Script<Ts...> {
LOG_STR_ARG(this->name_));
return;
}
this->trigger(x...);
this->run_actions_(x...);
}
void set_max_runs(int max_runs) { max_runs_ = max_runs; }

View File

@@ -4,7 +4,7 @@ import esphome.config_validation as cv
from esphome.const import (
CONF_TIME_ID,
DEVICE_CLASS_DURATION,
DEVICE_CLASS_UPTIME,
DEVICE_CLASS_TIMESTAMP,
ENTITY_CATEGORY_DIAGNOSTIC,
ICON_TIMER,
STATE_CLASS_TOTAL_INCREASING,
@@ -33,8 +33,9 @@ CONFIG_SCHEMA = cv.typed_schema(
).extend(cv.polling_component_schema("60s")),
"timestamp": sensor.sensor_schema(
UptimeTimestampSensor,
icon=ICON_TIMER,
accuracy_decimals=0,
device_class=DEVICE_CLASS_UPTIME,
device_class=DEVICE_CLASS_TIMESTAMP,
entity_category=ENTITY_CATEGORY_DIAGNOSTIC,
)
.extend(

View File

@@ -4,7 +4,7 @@ from enum import Enum
from esphome.enum import StrEnum
__version__ = "2026.6.0b2"
__version__ = "2026.6.2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -104,9 +104,13 @@ class Application {
void register_area(Area *area) { this->areas_.push_back(area); }
#endif
void set_current_component(Component *component) { this->current_component_ = component; }
Component *get_current_component() { return this->current_component_; }
// Owning script of the action chain currently executing (nullptr when none); used to attribute
// blocking warnings for deferred work to the script that scheduled it.
void set_current_source(const LogString *source) { this->current_source_ = source; }
const LogString *get_current_source() { return this->current_source_; }
// Entity register methods (generated from entity_types.h).
// Each entity type gets two overloads:
// - register_<entity>(obj) — bare push_back
@@ -393,6 +397,7 @@ class Application {
protected:
friend Component;
friend class Scheduler;
friend class LoopBlockingGuard;
#ifdef USE_RUNTIME_STATS
friend class runtime_stats::RuntimeStatsCollector;
#endif
@@ -402,6 +407,14 @@ class Application {
/// Freshen the cached loop component start time. Called by Scheduler before each dispatch.
void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; }
// Publish the running unit's identity (component + source) and dispatch time together, so a
// dispatch site can't set one without the others. Friend-only (Scheduler).
void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) {
this->current_component_ = component;
this->current_source_ = source;
this->set_loop_component_start_time_(now);
}
/// Walk all registered components looking for any whose component_state_
/// has the given flag set. Used by Component::status_clear_*_slow_path_()
/// (which is a friend) to decide whether to clear the corresponding bit on
@@ -482,6 +495,7 @@ class Application {
// Pointer-sized members first
Component *current_component_{nullptr};
const LogString *current_source_{nullptr};
// std::vector (3 pointers each: begin, end, capacity)
// Partitioned vector design for looping components
@@ -554,6 +568,76 @@ class Application {
/// Global storage of Application pointer - only one Application can exist.
extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the
/// previous value on exit, attributing deferred work scheduled inside to that source.
class ScopedSourceGuard {
public:
explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) {
App.set_current_source(source);
}
~ScopedSourceGuard() { App.set_current_source(this->prev_); }
ScopedSourceGuard(const ScopedSourceGuard &) = delete;
ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete;
private:
const LogString *prev_;
};
// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the
// main loop too long. The constructor publishes the unit's identity + dispatch time to App;
// finish()/the cold warning path read them back, so the guard stores no copy.
//
// Guards must not nest: the constructor publishes to App but never restores on destruction, so a
// nested guard would clobber the outer's context. Safe because the two dispatch sites (component
// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback.
class LoopBlockingGuard {
public:
// Publish the unit's identity + dispatch time, then start timing. The millis start lives in App,
// so only the runtime-stats micros stamp is kept here.
LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) {
App.set_current_execution_context_(component, source, now);
#ifdef USE_RUNTIME_STATS
this->started_us_ = micros();
#endif
}
// Finish the timing operation and return the current time (millis)
// Inlined: the fast path is just millis() + subtract + compare
inline uint32_t HOT finish() {
#ifdef USE_RUNTIME_STATS
uint32_t elapsed_us = micros() - this->started_us_;
// Delays have no component; accumulate into the global counter so loop() can subtract them.
Component *component = App.get_current_component();
if (component != nullptr) {
component->runtime_stats_.record_time(elapsed_us);
} else {
ComponentRuntimeStats::global_recorded_us += elapsed_us;
}
#endif
uint32_t curr_time = MillisInternal::get();
#ifndef USE_BENCHMARK
// Fast path: compare against constant threshold in ms (computed at compile time from centiseconds)
static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast<uint32_t>(WARN_IF_BLOCKING_OVER_CS) * 10U;
uint32_t blocking_time = curr_time - App.get_loop_component_start_time();
if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] {
warn_blocking(blocking_time);
}
#endif
return curr_time;
}
~LoopBlockingGuard() = default;
#ifdef USE_RUNTIME_STATS
protected:
uint32_t started_us_;
#endif
private:
// Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit.
static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time);
};
// Phase A: drain wake notifications and run the scheduler. Invoked on every
// Application::loop() tick regardless of whether a component phase runs, so
// scheduler items fire at their requested cadence even when the caller has
@@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
// before/tail splits recorded below.
uint32_t loop_active_start_us = micros();
// Snapshot the cumulative component-recorded time so we can subtract the
// slice that the scheduler spends inside its own WarnIfComponentBlockingGuard
// slice that the scheduler spends inside its own LoopBlockingGuard
// (scheduler.cpp) — that time is already counted in per-component stats,
// so charging it again to "before" would double-count.
uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us;
@@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
this->current_loop_index_++) {
Component *component = this->looping_components_[this->current_loop_index_];
// Update the cached time before each component runs
this->loop_component_start_time_ = last_op_end_time;
{
this->set_current_component(component);
WarnIfComponentBlockingGuard guard{component, last_op_end_time};
// Guard publishes this component (no script source) + dispatch time, then times loop().
LoopBlockingGuard guard{component, nullptr, last_op_end_time};
component->loop();
// Use the finish method to get the current time as the end time
last_op_end_time = guard.finish();

View File

@@ -201,7 +201,10 @@ template<typename... Ts> class DelayAction : public Action<Ts...> {
/* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER,
/* static_name= */ reinterpret_cast<const char *>(this), /* hash_or_id= */ 0, this->delay_.value(),
[this]() { this->play_next_(); },
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1,
// Record the owning script (if any) so the blocking warning can name it; propagates across
// chained delays via the scheduler.
/* source= */ App.get_current_source());
} else {
// For delays with arguments, capture by value to preserve argument values
// Arguments must be copied because original references may be invalid after delay
@@ -212,7 +215,9 @@ template<typename... Ts> class DelayAction : public Action<Ts...> {
/* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER,
/* static_name= */ reinterpret_cast<const char *>(this), /* hash_or_id= */ 0, this->delay_.value(x...),
std::move(f),
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1,
// See the no-argument branch above: record the owning script for log attribution.
/* source= */ App.get_current_source());
}
}

View File

@@ -258,9 +258,11 @@ void Component::call() {
break;
}
}
bool Component::should_warn_of_blocking(uint32_t blocking_time) {
bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) {
// Convert centisecond threshold to milliseconds for comparison
uint32_t threshold_ms = static_cast<uint32_t>(this->warn_if_blocking_over_) * 10U;
// Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate.
threshold_ms_out = threshold_ms;
if (blocking_time > threshold_ms) {
// Set new threshold: blocking_time + increment, converted back to centiseconds
uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS;
@@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int
uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
#endif
void __attribute__((noinline, cold))
WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) {
bool should_warn;
void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) {
// Identity is published on App by the caller before the guard is built; read it back here.
Component *component = App.get_current_component();
// Component-less path always warns (the caller already checked the constant threshold).
uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS;
if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) {
return; // Component's (possibly ratcheted) threshold not exceeded yet
}
// Component name if any, else the published source (owning script), else a generic label.
const LogString *name;
if (component != nullptr) {
should_warn = component->should_warn_of_blocking(blocking_time);
name = component->get_component_log_str();
} else {
should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller
}
if (should_warn) {
ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms",
component == nullptr ? LOG_STR_LITERAL("<null>") : LOG_STR_ARG(component->get_component_log_str()),
blocking_time);
name = App.get_current_source();
if (name == nullptr)
name = LOG_STR("a scheduled task");
}
ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name),
blocking_time, threshold_ms);
}
#ifdef USE_SETUP_PRIORITY_OVERRIDE

View File

@@ -118,7 +118,7 @@ struct ComponentRuntimeStats {
// Cumulative sum of every record_time() duration since boot, across all
// components. Used by Application::loop() to snapshot time spent inside
// WarnIfComponentBlockingGuard (including guards constructed by the
// LoopBlockingGuard (including guards constructed by the
// scheduler at scheduler.cpp) so main-loop overhead accounting can
// subtract scheduled-callback time from the before_loop_tasks_ wall time.
static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
@@ -326,7 +326,7 @@ class Component {
return component_source_lookup(this->component_source_index_);
}
bool should_warn_of_blocking(uint32_t blocking_time);
bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out);
protected:
friend class Application;
@@ -571,7 +571,7 @@ class Component {
volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context
#ifdef USE_RUNTIME_STATS
friend class runtime_stats::RuntimeStatsCollector;
friend class WarnIfComponentBlockingGuard;
friend class LoopBlockingGuard;
ComponentRuntimeStats runtime_stats_;
#endif
};
@@ -619,59 +619,7 @@ class PollingComponent : public Component {
uint32_t update_interval_;
};
// millis() and micros() are available via hal.h
class WarnIfComponentBlockingGuard {
public:
WarnIfComponentBlockingGuard(Component *component, uint32_t start_time)
: started_(start_time),
component_(component)
#ifdef USE_RUNTIME_STATS
,
started_us_(micros())
#endif
{
}
// Finish the timing operation and return the current time (millis)
// Inlined: the fast path is just millis() + subtract + compare
inline uint32_t HOT finish() {
#ifdef USE_RUNTIME_STATS
uint32_t elapsed_us = micros() - this->started_us_;
// component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...))
if (this->component_ != nullptr) {
this->component_->runtime_stats_.record_time(elapsed_us);
} else {
// Still accumulate into the global counter so Application::loop() can subtract
// this time from before_loop_tasks_ wall time.
ComponentRuntimeStats::global_recorded_us += elapsed_us;
}
#endif
uint32_t curr_time = MillisInternal::get();
#ifndef USE_BENCHMARK
// Fast path: compare against constant threshold in ms (computed at compile time from centiseconds)
static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast<uint32_t>(WARN_IF_BLOCKING_OVER_CS) * 10U;
uint32_t blocking_time = curr_time - this->started_;
if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] {
warn_blocking(this->component_, blocking_time);
}
#endif
return curr_time;
}
~WarnIfComponentBlockingGuard() = default;
protected:
uint32_t started_;
Component *component_;
#ifdef USE_RUNTIME_STATS
uint32_t started_us_;
#endif
private:
// Cold path for blocking warning - defined in component.cpp
static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time);
};
// LoopBlockingGuard lives in application.h because it reads its state from App.
// Function to clear setup priority overrides after all components are set up
// Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined

View File

@@ -16,7 +16,7 @@ namespace esphome {
// Friend-gated accessor for a fast millis() variant intended only for
// known task-context callers on the main loop hot path (Application::loop()
// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context
// and LoopBlockingGuard::finish()). It skips the ISR-context
// dispatch that the public esphome::millis() pays on ESP32 and libretiny.
//
// MUST NOT be called from ISR context: on ESP32 and libretiny it calls the
@@ -50,7 +50,7 @@ class MillisInternal {
#endif
}
friend class Application;
friend class WarnIfComponentBlockingGuard;
friend class LoopBlockingGuard;
};
} // namespace esphome

View File

@@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type,
const char *static_name, uint32_t hash_or_id, uint32_t delay,
std::function<void()> &&func, bool is_retry, bool skip_cancel) {
std::function<void()> &&func, bool is_retry, bool skip_cancel,
const LogString *source) {
if (delay == SCHEDULER_DONT_RUN) {
// Still need to cancel existing timer if we have a name/id
if (!skip_cancel) {
@@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
// Create and populate the scheduler item
SchedulerItem *item = this->get_item_from_pool_locked_();
item->component = component;
// SELF_POINTER items store the source name (owning script) in the union slot instead of a component.
if (name_type == NameType::SELF_POINTER) {
item->source_name = source;
} else {
item->component = component;
}
item->set_name(name_type, static_name, hash_or_id);
item->type = type;
// Use destroy + placement-new instead of move-assignment.
@@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) {
// Not reached timeout yet, done for this call
break;
}
// Don't run on failed components
if (item->component != nullptr && item->component->is_failed()) {
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
if (this->is_item_failed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
@@ -790,10 +796,21 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() {
// Helper to execute a scheduler item
uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) {
App.set_current_component(item->component);
// Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time.
App.set_loop_component_start_time_(now);
WarnIfComponentBlockingGuard guard{item->component, now};
// Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared
// union slot with a single name-type check. Self-keyed items have no owning component; their slot
// holds the source name (e.g. the owning script), published so deferred work chained inside the
// callback re-captures it and the blocking warning can name the script instead of "<null>".
Component *component;
const LogString *source;
if (item->get_name_type() == NameType::SELF_POINTER) {
component = nullptr;
source = item->source_name;
} else {
component = item->component;
source = nullptr;
}
// Guard publishes the item's identity + dispatch time, then times the callback.
LoopBlockingGuard guard{component, source, now};
item->callback();
uint32_t end = guard.finish();
// Feed the watchdog after each scheduled item (both main heap and defer

View File

@@ -183,11 +183,12 @@ class Scheduler {
protected:
struct SchedulerItem {
// Ordered by size to minimize padding.
// `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive).
// Ordered by size to minimize padding. Mutually exclusive by state; read the component via
// get_component() so SELF_POINTER items read as component-less.
union {
Component *component;
SchedulerItem *next_free;
Component *component; // live, non-SELF_POINTER: owning component
const LogString *source_name; // live SELF_POINTER: owning script name (log attribution)
SchedulerItem *next_free; // while pooled
};
// Optimized name storage using tagged union - zero heap allocation
union {
@@ -302,14 +303,23 @@ class Scheduler {
next_execution_high_ = static_cast<uint16_t>(value >> 32);
}
constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; }
const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); }
// The owning component, or nullptr for SELF_POINTER items (whose slot holds source_name instead).
// All component access goes through this so SELF_POINTER items read as component-less.
Component *get_component() const { return name_type_ == NameType::SELF_POINTER ? nullptr : component; }
const LogString *get_source() const {
// Same no-source label as warn_blocking, for consistent log vocabulary.
if (name_type_ == NameType::SELF_POINTER)
return source_name != nullptr ? source_name : LOG_STR("a scheduled task");
return component != nullptr ? component->get_component_log_str() : LOG_STR("unknown");
}
};
// Common implementation for both timeout and interval
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
// `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise.
void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name,
uint32_t hash_or_id, uint32_t delay, std::function<void()> &&func, bool is_retry = false,
bool skip_cancel = false);
bool skip_cancel = false, const LogString *source = nullptr);
// Common implementation for retry - Remove before 2026.8.0
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
@@ -402,8 +412,10 @@ class Scheduler {
// Fixes: https://github.com/esphome/esphome/issues/11940
if (item == nullptr)
return false;
if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) ||
(match_retry && !item->is_retry)) {
// get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they
// match by the `this` key alone.
if (item->get_component() != component || item->type != type ||
(skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) {
return false;
}
// Name type must match
@@ -423,11 +435,16 @@ class Scheduler {
// Helper to execute a scheduler item
uint32_t execute_item_(SchedulerItem *item, uint32_t now);
// Helper to check if item should be skipped
bool should_skip_item_(SchedulerItem *item) const {
return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed());
// True if the item's component is failed (so it must not run). SELF_POINTER delays have no
// component (get_component() == nullptr) and always fire.
bool is_item_failed_(SchedulerItem *item) const {
Component *component = item->get_component();
return component != nullptr && component->is_failed();
}
// Helper to check if item should be skipped
bool should_skip_item_(SchedulerItem *item) const { return is_item_removed_(item) || this->is_item_failed_(item); }
// Helper to recycle a SchedulerItem back to the pool.
// Takes a raw pointer — caller transfers ownership. The item is either added to the
// pool or deleted if the pool is full.

View File

@@ -81,8 +81,13 @@ def _get_idf_tools_path() -> Path:
Path object pointing to the ESP-IDF tools directory
"""
if "ESPHOME_ESP_IDF_PREFIX" in os.environ:
return Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser()
return CORE.data_dir / "idf"
path = Path(get_str_env("ESPHOME_ESP_IDF_PREFIX", None)).expanduser()
else:
path = CORE.data_dir / "idf"
# Resolve so an unnormalized config path (e.g. compiling ``../config/x.yaml``)
# doesn't leave ``..`` segments in the IDF_TOOLS_PATH handed to idf.py, which
# otherwise warns that the venv interpreter path doesn't match the install.
return path.resolve()
# Windows' default MAX_PATH is 260 characters. ESP-IDF toolchains nest deeply

View File

@@ -14,6 +14,7 @@ from esphome.const import CONF_FRAMEWORK, CONF_SOURCE
from esphome.core import CORE, EsphomeError
from esphome.espidf.framework import check_esp_idf_install, get_framework_env
from esphome.espidf.size_summary import print_summary
from esphome.helpers import add_git_ceiling_directory
_LOGGER = logging.getLogger(__name__)
@@ -82,6 +83,11 @@ def _get_idf_env(version: str | None = None) -> dict[str, str]:
env_cache[version] |= get_framework_env(
*_get_esphome_esp_idf_paths(version)
)
# Cap git's repo search at the config directory so ESP-IDF's
# `git describe` for the app version can't error out on an
# uninitialized or corrupt git repo in a parent directory.
add_git_ceiling_directory(env_cache[version], CORE.config_dir)
return env_cache[version]
@@ -466,6 +472,7 @@ def get_idedata() -> dict | None:
pass
data = idedata_from_build(compile_commands)
data["prog_path"] = str(get_elf_path())
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
return data

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import MutableMapping
from contextlib import suppress
import ipaddress
import logging
@@ -374,6 +375,26 @@ def is_ha_addon():
return get_bool_env("ESPHOME_IS_HA_ADDON")
def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) -> None:
"""Add ``directory`` to ``env``'s ``GIT_CEILING_DIRECTORIES`` list.
Git stops walking up the directory tree to find a repository once it reaches
a ceiling directory, so this caps the search at ``directory`` (the ESPHome
project root). Without it, an uninitialized or corrupt git repo in a parent
directory makes the ``git describe`` that build toolchains run for the app
version error out and fail the whole build.
``GIT_CEILING_DIRECTORIES`` is an ``os.pathsep``-joined list of absolute
paths; any existing entries are preserved and duplicates are skipped.
"""
ceiling = str(directory)
existing = env.get("GIT_CEILING_DIRECTORIES", "")
parts = existing.split(os.pathsep) if existing else []
if ceiling not in parts:
parts.append(ceiling)
env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts)
def rmtree(path: Path | str) -> None:
"""Remove a directory tree, handling read-only files on Windows.

View File

@@ -12,7 +12,7 @@ dependencies:
esphome/micro-flac:
version: 0.2.0
esphome/micro-mp3:
version: 0.2.1
version: 0.2.3
esphome/micro-opus:
version: 0.4.1
esphome/micro-wav:
@@ -38,7 +38,7 @@ dependencies:
rules:
- if: "target in [esp32h2, esp32p4]"
espressif/esp_hosted:
version: 2.12.8
version: 2.12.9
rules:
- if: "target in [esp32h2, esp32p4]"
zorxx/multipart-parser:

View File

@@ -7,6 +7,7 @@ import sys
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
from esphome.core import CORE, EsphomeError
from esphome.helpers import add_git_ceiling_directory
from esphome.util import FlashImage, run_external_process
_LOGGER = logging.getLogger(__name__)
@@ -53,6 +54,10 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning")
# Increase uv retry count to handle transient network errors (default is 3)
os.environ.setdefault("UV_HTTP_RETRIES", "10")
# Cap git's repo search at the config directory so the framework's build
# scripts running `git describe` for the app version can't error out on an
# uninitialized or corrupt git repo in a parent directory.
add_git_ceiling_directory(os.environ, CORE.config_dir)
# Strip the Windows extended-length path prefix from sys.executable so it
# doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted
# command lines run through cmd.exe.

View File

@@ -70,12 +70,15 @@ def populate_dependency_config(
* ``domain.platform`` form (e.g. ``sensor.gpio``) appends
``{platform: <name>}`` to ``config[domain]``, creating the list if needed.
* Bare components are looked up via ``get_component_fn``. Platform
components (``IS_PLATFORM_COMPONENT``) and ``MULTI_CONF`` components are
initialised as ``[]`` so the sibling ``domain.platform`` branch can
``append`` into them. Everything else is populated by running the
component's schema with ``{}`` so defaults exist; if the schema requires
explicit input, an empty ``{}`` is used as a fallback.
* Bare components are looked up via ``get_component_fn``. Target-platform
components (``is_target_platform``, e.g. ``esp32``) are skipped entirely:
a host build targets ``host``, so a foreign target platform's sources are
guarded out and its schema must not run here (it would mutate global CORE
state as a side effect). Platform components (``IS_PLATFORM_COMPONENT``)
and ``MULTI_CONF`` components are initialised as ``[]`` so the sibling
``domain.platform`` branch can ``append`` into them. Everything else is
populated by running the component's schema with ``{}`` so defaults exist;
if the schema requires explicit input, an empty ``{}`` is used as a fallback.
Platform components must always be a list here even when no
``domain.platform`` entry follows, because the ``domain.platform`` branch
@@ -96,6 +99,12 @@ def populate_dependency_config(
component = get_component_fn(component_name)
if component is None:
continue
# Skip target platforms (e.g. esp32): a host build targets `host`, so a
# foreign target's sources are guarded out, and running its schema with
# {} leaks global CORE state (esp32 pins CORE.toolchain to ESP-IDF),
# crashing the host compile. See #17035.
if component.is_target_platform:
continue
if component.multi_conf or component.is_platform_component:
config.setdefault(component_name, [])
elif component_name not in config:

View File

@@ -951,6 +951,15 @@ def convert(schema, config_var, path):
elif schema_type == "enum":
config_var[S_TYPE] = "enum"
config_var["values"] = dict.fromkeys(list(data.keys()))
elif schema_type == "variant_enum":
# Per-variant enum (e.g. psram mode/speed): each value carries the
# list of variants that accept it so clients can filter to the
# user's selected variant. Additive to the plain enum format —
# consumers that ignore the metadata still see every option.
config_var[S_TYPE] = "enum"
config_var["values"] = {
value: {"variants": variants} for value, variants in data.items()
}
elif schema_type == "maybe":
# maybe_simple_value: either a scalar shorthand (mapped to the key in
# data[1]) or the full wrapped schema. The wrapped schema is usually a

View File

@@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants(
FINAL_VALIDATE_SCHEMA(config)
def test_psram_applies_single_mode_default(
set_core_config: SetCoreConfigCallable,
) -> None:
"""On a single-mode variant the omitted mode/speed fall back to defaults."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32},
full_config={CONF_ESPHOME: {}},
)
from esphome.components.psram import CONFIG_SCHEMA
config = CONFIG_SCHEMA({})
assert config["mode"] == "quad"
assert config["speed"] == "40MHZ"
assert config["disabled"] is False
assert config["ignore_not_found"] is True
def test_psram_requires_mode_on_multi_mode_variant(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A variant with multiple modes requires an explicit mode selection."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32S3},
full_config={CONF_ESPHOME: {}},
)
from esphome.components.psram import CONFIG_SCHEMA
with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"):
CONFIG_SCHEMA({})
def test_psram_rejects_mode_invalid_for_variant(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A mode not supported by the active variant is rejected by the schema."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_VARIANT: VARIANT_ESP32},
full_config={CONF_ESPHOME: {}},
)
from esphome.components.psram import CONFIG_SCHEMA
with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"):
CONFIG_SCHEMA({"mode": "octal"})
def _setup_psram_final_validation_test(
esp32_config: dict,
set_core_config: SetCoreConfigCallable,

View File

@@ -0,0 +1,5 @@
# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses
# octal; this exercises the other branch of the per-variant mode enum (quad) and
# lets speed fall back to its 40MHz default.
psram:
mode: quad

View File

@@ -0,0 +1,4 @@
# Config-only: with no options the single-mode ESP32 resolves mode -> quad and
# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here,
# so this only runs through `esphome config`.
psram:

View File

@@ -0,0 +1,4 @@
# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz).
# With no options it resolves mode -> hex and speed -> 20MHz, exercising the
# P4-specific default branch of the per-variant enums.
psram:

View File

@@ -0,0 +1,61 @@
esphome:
name: logger-recursion-test
host:
api:
logger:
level: DEBUG
on_message:
# Fires on the main loop for every message delivered to listeners, including
# messages drained from the task log buffer (i.e. logged from a non-main thread).
# The lambda logs again on the main task. Without a recursion guard on the buffered
# drain path this re-entrant log reuses the shared tx_buffer_ and clobbers the
# buffered message that is still being delivered, corrupting its console output.
- level: VERY_VERBOSE
then:
- lambda: |-
ESP_LOGD("reentry", "REENTRANT_CLOBBER_MARKER");
button:
- platform: template
name: "Start Race Test"
id: start_test_button
on_press:
- lambda: |-
// Keep the count well under the host task-log-buffer slot count so every
// message goes through the ring buffer (buffered drain path) instead of the
// emergency console fallback. The main loop is blocked in pthread_join while
// the thread logs, so all messages are drained together once it returns.
static const int NUM_MESSAGES = 30;
struct ThreadTest {
static void *thread_func(void *arg) {
char thread_name[16];
snprintf(thread_name, sizeof(thread_name), "LogThread");
#ifdef __APPLE__
pthread_setname_np(thread_name);
#else
pthread_setname_np(pthread_self(), thread_name);
#endif
for (int i = 0; i < NUM_MESSAGES; i++) {
// Verifiable payload: data is a deterministic function of the message
// index, so a clobbered buffer shows up as a missing or mismatched line.
ESP_LOGD("thread_test", "THREADMSG%03d_DATA_%08X", i, i * 12345);
}
return nullptr;
}
};
// RACE_TEST_START / RACE_TEST_COMPLETE are logged from the main task (the
// synchronous path, which already holds the recursion guard) so the test can
// always detect completion even when the buffered path is corrupted.
ESP_LOGI("thread_test", "RACE_TEST_START: logging %d messages from a thread", NUM_MESSAGES);
pthread_t thread;
if (pthread_create(&thread, nullptr, ThreadTest::thread_func, nullptr) != 0) {
ESP_LOGE("thread_test", "RACE_TEST_ERROR: Failed to create thread");
return;
}
pthread_join(thread, nullptr);
ESP_LOGI("thread_test", "RACE_TEST_COMPLETE: thread finished, expected %d messages", NUM_MESSAGES);

View File

@@ -0,0 +1,22 @@
esphome:
name: scheduler-blocking-warning
on_boot:
then:
- script.execute: blocking_script
host:
api:
logger:
level: DEBUG
# The busy-block runs in the second delay's continuation; the warning must name the script. Two
# delays verify the source survives chained delays (the scheduler republishes it each continuation).
script:
- id: blocking_script
then:
- delay: 10ms
- delay: 10ms
- lambda: |-
const uint32_t start = millis();
while (millis() - start < 80) {
}

View File

@@ -0,0 +1,30 @@
esphome:
name: scheduler-blocking-generic
host:
api:
logger:
level: DEBUG
globals:
- id: done
type: bool
restore_value: false
initial_value: "false"
# A delay in a plain (non-script) automation has no owning script, so the block must log the
# generic "a scheduled task" label, not a script name.
interval:
- interval: 100ms
id: gen_interval
then:
- if:
condition:
lambda: "return !id(done);"
then:
- lambda: "id(done) = true;"
- delay: 10ms
- lambda: |-
const uint32_t start = millis();
while (millis() - start < 80) {
}

View File

@@ -0,0 +1,29 @@
esphome:
name: scheduler-delay-failed
host:
api:
logger:
level: DEBUG
globals:
- id: started
type: bool
restore_value: false
initial_value: "false"
# The interval marks itself failed, then schedules a delay. The delay must still fire: a failed
# component must not drop it, since the SELF_POINTER scheduler item has no owning component.
interval:
- interval: 100ms
id: host_interval
then:
- if:
condition:
lambda: "return !id(started);"
then:
- lambda: |-
id(started) = true;
id(host_interval)->mark_failed();
- delay: 200ms
- logger.log: "DELAY_FIRED_AFTER_FAIL"

View File

@@ -0,0 +1,119 @@
"""Integration test for the recursion guard on the buffered logger drain path.
Regression test for a crash where a log message drained from the task log buffer
(i.e. logged from a non-main thread) re-entered the logger on the main task while it
was still being delivered to listeners. The buffered drain in
``Logger::process_messages_`` did not hold the main-task recursion guard that the
synchronous logging path holds, so a listener callback that logged again on the main
task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused
the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed
up as a ``StoreProhibited`` panic inside the API send path.
The fixture logs a small batch of verifiable messages from a non-main thread (kept
under the host task-log-buffer slot count so they all take the buffered drain path
rather than the emergency console fallback) while an ``on_message`` automation re-logs
``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message.
Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while
the buffered thread message is still being delivered, so the message the API receives is
contaminated (it contains the marker and an embedded newline glued onto the thread
payload). With the guard the re-entrant log is dropped during the drain, the marker
never appears, and every thread message is delivered clean.
"""
from __future__ import annotations
import asyncio
import re
from aioesphomeapi import LogLevel
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index
THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})")
NUM_MESSAGES = 30
@pytest.mark.asyncio
async def test_logger_buffered_recursion_guard(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Buffered (non-main-thread) log messages survive a re-entrant main-task log."""
api_messages: list[str] = []
all_drained = asyncio.Event()
async with (
run_compiled(yaml_config),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
assert device_info.name == "logger-recursion-test"
# Subscribe over the API: this is the exact path that crashed in the field
# (the API log callback runs during the buffered drain). The API message field
# preserves embedded newlines, so it reliably exposes a clobbered buffer.
#
# Every buffered thread message is delivered here whether it survives intact or
# gets clobbered (a clobbered message still carries its THREADMSG payload), so
# counting THREADMSG occurrences is a deterministic "drain complete" signal: no
# arbitrary sleep, no dependence on the fix being present.
def on_log(msg) -> None:
text = msg.message.decode("utf-8", errors="replace")
api_messages.append(text)
received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages)
if received >= NUM_MESSAGES:
all_drained.set()
client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE)
entities, _ = await client.list_entities_services()
buttons = [e for e in entities if e.name == "Start Race Test"]
assert buttons, "Could not find Start Race Test button"
client.button_command(buttons[0].key)
# Wait until every buffered thread message has been delivered over the API.
try:
await asyncio.wait_for(all_drained.wait(), timeout=30.0)
except TimeoutError:
received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages)
pytest.fail(
f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; "
"device likely crashed or hung."
)
intact: set[int] = set()
contaminated: list[str] = []
for raw in api_messages:
text = _ANSI.sub("", raw)
if "THREADMSG" not in text:
continue
# A clean thread message is a single line carrying only its own payload. A
# clobbered buffer glues the re-entrant marker (and an embedded newline) onto it.
if "REENTRANT" in text or "\n" in text:
contaminated.append(repr(raw))
continue
match = THREAD_MSG_PATTERN.search(text)
assert match, f"Unexpected thread message format: {raw!r}"
msg_num = int(match.group(1))
expected = f"{msg_num * 12345:08X}"
if match.group(2) != expected:
contaminated.append(repr(raw))
continue
intact.add(msg_num)
assert not contaminated, (
"Buffered thread messages were clobbered by a re-entrant main-task log "
"(missing recursion guard on the buffered drain path):\n"
+ "\n".join(contaminated[:10])
)
assert len(intact) == NUM_MESSAGES, (
f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got "
f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}"
)

View File

@@ -0,0 +1,120 @@
"""Integration tests for blocking-warning source attribution.
A blocking operation that runs inside a deferred scheduler continuation (e.g. after a ``delay``
in a script) used to be reported as ``<null> took a long time for an operation (NN ms),
max is 30 ms`` because the continuation carries no component. The warning should instead name
the owning script and report the real threshold (50 ms).
"""
from __future__ import annotations
import asyncio
import re
import pytest
from .types import APIClientConnectedFactory, RunCompiledFunction
# Matches: "<source> took a long time for an operation (NN ms), max is NN ms"
WARN_PATTERN = re.compile(
r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms"
)
@pytest.mark.asyncio
async def test_scheduler_blocking_warning(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""Deferred blocking work inside a script is attributed to the script, not "<null>"."""
loop = asyncio.get_running_loop()
warning_future: asyncio.Future[str] = loop.create_future()
def check_output(line: str) -> None:
if WARN_PATTERN.search(line) and not warning_future.done():
warning_future.set_result(line)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
device_info = await client.device_info()
assert device_info is not None
# on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the
# continuation, tripping the blocking warning.
warning_line = await asyncio.wait_for(warning_future, timeout=10.0)
# Must name the owning script, not "<null>" and not the generic fallback.
assert "<null>" not in warning_line, (
f"Warning should name the script, got: {warning_line}"
)
assert "a scheduled task" not in warning_line, (
f"Warning should name the script, got: {warning_line}"
)
match = WARN_PATTERN.search(warning_line)
assert match is not None
assert match.group(1) == "blocking_script", (
f"Warning should name 'blocking_script', got: {warning_line}"
)
# The reported threshold must be the real default (50 ms), not the stale "30 ms".
assert match.group(3) == "50", f"Expected 'max is 50 ms', got: {warning_line}"
@pytest.mark.asyncio
async def test_scheduler_blocking_warning_generic_source(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A delay in a plain (non-script) automation logs the generic label, not a script name."""
loop = asyncio.get_running_loop()
warning_future: asyncio.Future[str] = loop.create_future()
def check_output(line: str) -> None:
if WARN_PATTERN.search(line) and not warning_future.done():
warning_future.set_result(line)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
assert await client.device_info() is not None
warning_line = await asyncio.wait_for(warning_future, timeout=10.0)
assert "a scheduled task took a long time" in warning_line, (
f"Non-script deferred work should log the generic label, got: {warning_line}"
)
assert "<null>" not in warning_line
match = WARN_PATTERN.search(warning_line)
assert match is not None and match.group(3) == "50", (
f"Expected 'max is 50 ms', got: {warning_line}"
)
@pytest.mark.asyncio
async def test_scheduler_delay_runs_on_failed_component(
yaml_config: str,
run_compiled: RunCompiledFunction,
api_client_connected: APIClientConnectedFactory,
) -> None:
"""A delay must still fire even when its context component is marked failed.
Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's
failed-component skip must not drop them.
"""
loop = asyncio.get_running_loop()
fired: asyncio.Future[bool] = loop.create_future()
def check_output(line: str) -> None:
if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done():
fired.set_result(True)
async with (
run_compiled(yaml_config, line_callback=check_output),
api_client_connected() as client,
):
assert await client.device_info() is not None
# If the failed host component wrongly dropped the delay, this times out.
await asyncio.wait_for(fired, timeout=10.0)

View File

@@ -0,0 +1,76 @@
"""Unit tests for script/build_helpers.py."""
from pathlib import Path
import sys
import pytest
# Add the script directory to the path so we can import build_helpers.
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script"))
import build_helpers # noqa: E402
from esphome.core import CORE # noqa: E402
class _FakeComponent:
def __init__(self, config_schema, *, is_target_platform=False):
self.multi_conf = False
self.is_platform_component = False
self.is_target_platform = is_target_platform
self.config_schema = config_schema
@pytest.fixture(autouse=True)
def _restore_core_toolchain():
"""Keep CORE.toolchain changes from leaking between tests."""
saved = CORE.toolchain
try:
yield
finally:
CORE.toolchain = saved
def test_populate_dependency_config_skips_target_platforms() -> None:
"""Target-platform deps must be skipped, not config-populated, in a host build.
Regression test for #17035: esp32 (a target platform) appears only as a
transitive dependency of a host C++ unit test. Running its schema with {}
set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation,
which crashed the host compile with KeyError('esp32'). The fix skips
target-platform components entirely so their schema never runs.
"""
CORE.toolchain = None # the state a host build starts from
schema_calls = []
def leaky_schema(value):
# If this ever runs for a target platform, the bug is back.
schema_calls.append(value)
CORE.toolchain = "esp-idf-leak"
raise ValueError("no board or variant")
config: dict = {}
build_helpers.populate_dependency_config(
config,
["esp32"],
get_component_fn=lambda name: _FakeComponent(
leaky_schema, is_target_platform=True
),
register_platform_fn=lambda domain: None,
)
assert "esp32" not in config # skipped: no synthesized entry
assert schema_calls == [] # schema never run
assert CORE.toolchain is None # no global side effect leaked
def test_populate_dependency_config_populates_defaults() -> None:
"""A non-target-platform dep still has its schema defaults harvested."""
config: dict = {}
build_helpers.populate_dependency_config(
config,
["ok"],
get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}),
register_platform_fn=lambda domain: None,
)
assert config["ok"] == {"default": 1}

View File

@@ -139,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None:
assert "foo" in config_var["schema"]["config_vars"]
def test_convert_emits_variant_enum() -> None:
"""A per-variant enum is dumped with each value tagged by its variants."""
from esphome.components.esp32 import (
VARIANT_ESP32,
VARIANT_ESP32S3,
variant_filtered_enum,
)
validator = variant_filtered_enum(
{VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")},
lower=True,
)
config_var: dict = {}
_bls.convert(validator, config_var, "/test")
assert config_var["type"] == "enum"
assert config_var["values"] == {
"quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]},
"octal": {"variants": [VARIANT_ESP32S3]},
}
def test_convert_keys_emits_heuristic_sensitive_marker() -> None:
converted: dict = {}
_bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root")

View File

@@ -0,0 +1,169 @@
"""Unit tests for docker/build.py command generation."""
import importlib.util
from pathlib import Path
import sys
import pytest
_BUILD_PY = Path(__file__).parents[2] / "docker" / "build.py"
_spec = importlib.util.spec_from_file_location("docker_build", _BUILD_PY)
docker_build = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(docker_build)
def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> list[str]:
"""Run build.py main() in dry-run mode and return the emitted commands."""
full_argv = ["build.py", "--dry-run", *argv]
with pytest.MonkeyPatch.context() as mp:
mp.setattr(sys, "argv", full_argv)
docker_build.main()
out = capsys.readouterr().out
return [line[2:] for line in out.splitlines() if line.startswith("$ ")]
def test_branch_build_pushes_single_ghcr_tag_without_cache_to(
capsys: pytest.CaptureFixture[str],
) -> None:
commands = _run(
capsys,
"--tag",
"my-branch",
"--arch",
"amd64",
"--build-type",
"docker",
"--registry",
"ghcr",
"build",
"--push",
"--no-cache-to",
)
assert len(commands) == 1
cmd = commands[0]
# Custom tag -> only the tag itself, no companion "dev"/"latest" tags
assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd
assert ":dev" not in cmd
# ghcr only -> no Docker Hub image name
assert "--tag esphome/esphome-amd64:my-branch" not in cmd
# custom tag falls back to the dev cache for reads
assert (
"--cache-from type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-dev" in cmd
)
assert "--push" in cmd
# --no-cache-to must suppress the cache write
assert "--cache-to" not in cmd
def test_branch_manifest_targets_ghcr_only(
capsys: pytest.CaptureFixture[str],
) -> None:
commands = _run(
capsys,
"--tag",
"my-branch",
"--build-type",
"ha-addon",
"--registry",
"ghcr",
"manifest",
)
assert commands == [
"docker buildx imagetools create "
"--tag ghcr.io/esphome/esphome-hassio:my-branch "
"ghcr.io/esphome/esphome-hassio-amd64:my-branch "
"ghcr.io/esphome/esphome-hassio-aarch64:my-branch"
]
def test_release_build_keeps_both_registries_and_cache_to(
capsys: pytest.CaptureFixture[str],
) -> None:
commands = _run(
capsys,
"--tag",
"2025.6.0",
"--arch",
"amd64",
"--build-type",
"docker",
"build",
"--push",
)
cmd = commands[0]
# Default (no --registry) keeps both Docker Hub and ghcr image names
assert "--tag esphome/esphome-amd64:2025.6.0" in cmd
assert "--tag ghcr.io/esphome/esphome-amd64:2025.6.0" in cmd
# Release channel still gets its companion tags
assert "--tag esphome/esphome-amd64:latest" in cmd
# Without --no-cache-to the cache write is preserved
assert (
"--cache-to type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-latest,mode=max"
in cmd
)
def test_build_no_push_omits_push_and_cache(
capsys: pytest.CaptureFixture[str],
) -> None:
commands = _run(
capsys,
"--tag",
"my-branch",
"--arch",
"amd64",
"--build-type",
"docker",
"--registry",
"ghcr",
"build",
)
cmd = commands[0]
assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd
assert "--push" not in cmd
assert "--cache-to" not in cmd
def test_build_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None:
commands = _run(
capsys,
"--tag",
"my-branch",
"--arch",
"amd64",
"--build-type",
"docker",
"--registry",
"dockerhub",
"build",
"--push",
)
cmd = commands[0]
assert "--tag esphome/esphome-amd64:my-branch" in cmd
assert "ghcr.io" not in cmd
# Cache reference falls back to Docker Hub when GHCR isn't selected
assert "--cache-from type=registry,ref=esphome/esphome-amd64:cache-dev" in cmd
def test_manifest_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None:
commands = _run(
capsys,
"--tag",
"my-branch",
"--build-type",
"docker",
"--registry",
"dockerhub",
"manifest",
)
create = commands[0]
assert create.startswith(
"docker buildx imagetools create --tag esphome/esphome:my-branch "
)
assert "ghcr.io" not in create

View File

@@ -266,11 +266,13 @@ def _make_component_stub(
*,
multi_conf: bool = False,
is_platform_component: bool = False,
is_target_platform: bool = False,
config_schema=None,
) -> MagicMock:
stub = MagicMock()
stub.multi_conf = multi_conf
stub.is_platform_component = is_platform_component
stub.is_target_platform = is_target_platform
stub.config_schema = config_schema
return stub

View File

@@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "g++"}
assert json.loads(cache.read_text()) == {"cxx_path": "g++"}
prog_path = str(toolchain.get_elf_path())
assert result == {"cxx_path": "g++", "prog_path": prog_path}
assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path}
def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
@@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "fresh"}
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
@@ -147,7 +148,40 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
result = toolchain.get_idedata()
mock_transform.assert_called_once()
assert result == {"cxx_path": "regen"}
assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())}
def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
"""The idedata exposes prog_path (the ELF) so consumers like build-action
can locate firmware.factory.bin / firmware.ota.bin as its siblings."""
compile_commands, _ = _setup_build(setup_core)
compile_commands.parent.mkdir(parents=True, exist_ok=True)
compile_commands.write_text("[]")
with patch(
"esphome.espidf.idedata.idedata_from_build",
return_value={"cxx_path": "g++"},
):
result = toolchain.get_idedata()
# Use Path semantics so the contract holds on Windows too (backslashes).
prog_path = Path(result["prog_path"])
assert prog_path.name == "firmware.elf"
assert prog_path.parent.name == "build"
def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
"""The IDF env caps git's upward search at the config directory.
This stops ESP-IDF's `git describe` from walking into an uninitialized or
corrupt git repo in a parent directory and failing the build.
"""
toolchain._cache().env.clear()
# Set IDF_PATH so the framework-install branch is skipped.
with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}):
env = toolchain._get_idf_env(version="5.5.4")
assert CORE.config_dir == setup_core
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
def test_get_core_framework_version_from_core_data():

View File

@@ -196,6 +196,33 @@ def test_is_ha_addon(monkeypatch, value, expected):
assert actual == expected
def test_add_git_ceiling_directory_sets_when_unset():
"""An empty env gets GIT_CEILING_DIRECTORIES set to the directory."""
env: dict[str, str] = {}
directory = Path("/home/user/config")
helpers.add_git_ceiling_directory(env, directory)
assert env["GIT_CEILING_DIRECTORIES"] == str(directory)
def test_add_git_ceiling_directory_appends_to_existing():
"""An existing value is preserved and the new directory is appended."""
env = {"GIT_CEILING_DIRECTORIES": str(Path("/some/ceiling"))}
directory = Path("/home/user/config")
helpers.add_git_ceiling_directory(env, directory)
assert env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) == [
str(Path("/some/ceiling")),
str(directory),
]
def test_add_git_ceiling_directory_skips_duplicate():
"""A directory already in the list is not appended again."""
directory = Path("/home/user/config")
env = {"GIT_CEILING_DIRECTORIES": str(directory)}
helpers.add_git_ceiling_directory(env, directory)
assert env["GIT_CEILING_DIRECTORIES"] == str(directory)
def test_walk_files(fixture_path):
path = fixture_path / "helpers"

View File

@@ -32,6 +32,7 @@ from esphome.__main__ import (
command_clean_all,
command_config,
command_config_hash,
command_idedata,
command_rename,
command_run,
command_update_all,
@@ -689,6 +690,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None:
assert result == ["192.168.1.100"]
def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None:
"""A .local device with mDNS disabled resolves via the dashboard-supplied cache."""
setup_core(
config={
CONF_API: {},
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
CONF_MDNS: {CONF_DISABLED: True},
},
address="esp32-a1s.local",
)
CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]})
for purpose in (Purpose.LOGGING, Purpose.UPLOADING):
result = choose_upload_log_host(
default="OTA", check_default=None, purpose=purpose
)
assert result == ["192.168.1.50"]
def test_choose_upload_log_host_with_ota_device_with_api_config() -> None:
"""Test OTA device when API is configured (no upload without OTA in config)."""
setup_core(config={CONF_API: {}}, address="192.168.1.100")
@@ -3135,6 +3155,22 @@ def test_has_resolvable_address() -> None:
setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None)
assert has_resolvable_address() is False
# mDNS disabled + .local, but the dashboard cached the address -> resolvable
setup_core(
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
)
CORE.address_cache = AddressCache(
mdns_cache={"esphome-device.local": ["192.168.1.100"]}
)
assert has_resolvable_address() is True
# mDNS disabled + .local, cache present but missing this host -> not resolvable
setup_core(
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
)
CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]})
assert has_resolvable_address() is False
def test_has_name_add_mac_suffix() -> None:
"""Test has_name_add_mac_suffix function."""
@@ -6222,3 +6258,28 @@ def test_command_run_defaults_subscribe_states_true(
mock_run_logs.assert_called_once_with(
CORE.config, ["192.168.1.100"], subscribe_states=True
)
def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
"""Under the native ESP-IDF toolchain, idedata is emitted as JSON."""
setup_core()
CORE.toolchain = Toolchain.ESP_IDF
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get:
result = command_idedata(MagicMock(), CORE.config)
assert result == 0
mock_get.assert_called_once_with()
assert json.loads(capsys.readouterr().out) == data
def test_command_idedata_esp_idf_no_build_errors() -> None:
"""Under ESP-IDF, a missing build (no idedata) returns an error, not a crash."""
setup_core()
CORE.toolchain = Toolchain.ESP_IDF
with patch("esphome.espidf.toolchain.get_idedata", return_value=None):
result = command_idedata(MagicMock(), CORE.config)
assert result == 1

View File

@@ -304,6 +304,11 @@ def test_run_platformio_cli_sets_environment_variables(
)
assert "PLATFORMIO_LIBDEPS_DIR" in os.environ
assert "PYTHONWARNINGS" in os.environ
# Caps git's upward search at the config dir so an uninitialized or
# corrupt parent git repo can't break the framework's `git describe`.
assert str(CORE.config_dir) in os.environ["GIT_CEILING_DIRECTORIES"].split(
os.pathsep
)
# Check command was called correctly — runs PlatformIO as a subprocess
# via the esphome.platformio.runner entry point.