mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 14:34:49 +00:00
@@ -1 +1 @@
|
||||
a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c
|
||||
34f6ce4a4775acf8c7201778f114b191f78269f232b67f01fed920f0cdf73686
|
||||
|
||||
7
.github/actions/build-image/action.yaml
vendored
7
.github/actions/build-image/action.yaml
vendored
@@ -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
|
||||
|
||||
|
||||
84
.github/workflows/ci-docker.yml
vendored
84
.github/workflows/ci-docker.yml
vendored
@@ -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
|
||||
|
||||
2
Doxyfile
2
Doxyfile
@@ -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.0b3
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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.1
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
&& platformio settings set check_platformio_interval 1000000 \
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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."
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 "";
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -1,3 +0,0 @@
|
||||
upstream esphome {
|
||||
server unix:/var/run/esphome.sock;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley)
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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=$(\
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 @@ if bashio::fs.directory_exists '/config/esphome/.esphome'; then
|
||||
rm -rf /config/esphome/.esphome
|
||||
fi
|
||||
|
||||
if bashio::config.true 'use_new_device_builder'; then
|
||||
bashio::log.info "Starting ESPHome Device Builder..."
|
||||
exec esphome-device-builder /config/esphome \
|
||||
--ha-addon \
|
||||
--ingress-port "$(bashio::addon.ingress_port)"
|
||||
fi
|
||||
|
||||
bashio::log.info "Starting ESPHome dashboard..."
|
||||
exec esphome dashboard /config/esphome --socket /var/run/esphome.sock --ha-addon
|
||||
bashio::log.info "Starting ESPHome Device Builder..."
|
||||
exec esphome-device-builder /config/esphome \
|
||||
--ha-addon \
|
||||
--ingress-port "$(bashio::addon.ingress_port)"
|
||||
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
oneshot
|
||||
@@ -1 +0,0 @@
|
||||
/etc/s6-overlay/s6-rc.d/init-nginx/run
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
longrun
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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_++;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.6.0b2"
|
||||
__version__ = "2026.6.0b3"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
5
tests/components/psram/validate-quad.esp32-s3-idf.yaml
Normal file
5
tests/components/psram/validate-quad.esp32-s3-idf.yaml
Normal 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
|
||||
4
tests/components/psram/validate.esp32-idf.yaml
Normal file
4
tests/components/psram/validate.esp32-idf.yaml
Normal 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:
|
||||
4
tests/components/psram/validate.esp32-p4-idf.yaml
Normal file
4
tests/components/psram/validate.esp32-p4-idf.yaml
Normal 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:
|
||||
22
tests/integration/fixtures/scheduler_blocking_warning.yaml
Normal file
22
tests/integration/fixtures/scheduler_blocking_warning.yaml
Normal 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) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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"
|
||||
120
tests/integration/test_scheduler_blocking_warning.py
Normal file
120
tests/integration/test_scheduler_blocking_warning.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
169
tests/script/test_docker_build.py
Normal file
169
tests/script/test_docker_build.py
Normal 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
|
||||
@@ -150,6 +150,20 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
|
||||
assert result == {"cxx_path": "regen"}
|
||||
|
||||
|
||||
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():
|
||||
"""The version is read from CORE.data when validation populated it."""
|
||||
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user