Compare commits

..

49 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
Jesse Hills
94b248527d Merge pull request #16948 from esphome/bump-2026.6.0b2
2026.6.0b2
2026-06-15 12:05:19 +12:00
Jesse Hills
a46aa594b3 Bump version to 2026.6.0b2 2026-06-15 11:04:46 +12:00
Jonathan Swoboda
99425e3a97 [esp32] Add flash_mode and flash_frequency config options (#16920)
Co-authored-by: Jesse Hills <3060199+jesserockz@users.noreply.github.com>
2026-06-15 11:04:46 +12:00
Jonathan Swoboda
f83e3ad6a6 [core] Support platformio_options on the native ESP-IDF toolchain (#16917) 2026-06-15 11:04:46 +12:00
Jonathan Swoboda
c768e2eabc [esp32] Fix idedata generation failing on unset ESPHOME_ARDUINO (#16925) 2026-06-15 11:04:46 +12:00
Clyde Stubbs
9ffd350095 [mipi_spi] Implement automatic mapping of offsets (#16722)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-06-15 11:04:46 +12:00
Clyde Stubbs
26ccaf70db [lvgl] Fix schema extraction (#16895)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 11:04:46 +12:00
Tobiasz Jakubowski
20925b3220 [spi] Skip logging on begin_transaction() of an auto-releasing write-only SPI device (#16921)
Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
2026-06-15 11:04:46 +12:00
J. Nick Koston
83504d2de2 [esp8266] Decode crash handler PC and backtrace in logs (#16911) 2026-06-15 11:04:46 +12:00
111 changed files with 2860 additions and 741 deletions

View File

@@ -1 +1 @@
442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451
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.0b1
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]
@@ -1615,8 +1644,14 @@ FLASH_SIZES = [
]
CONF_FLASH_SIZE = "flash_size"
CONF_FLASH_MODE = "flash_mode"
CONF_FLASH_FREQUENCY = "flash_frequency"
CONF_CPU_FREQUENCY = "cpu_frequency"
CONF_PARTITIONS = "partitions"
FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"]
FLASH_FREQUENCIES = [
f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16)
]
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
@@ -1630,6 +1665,10 @@ CONFIG_SCHEMA = cv.All(
cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of(
*FLASH_SIZES, upper=True
),
cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True),
cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of(
*FLASH_FREQUENCIES, upper=True
),
cv.Optional(CONF_PARTITIONS): cv.Any(
cv.file_,
cv.ensure_list(
@@ -1866,6 +1905,12 @@ async def to_code(config):
"board_upload.maximum_size",
int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024,
)
if flash_mode := config.get(CONF_FLASH_MODE):
cg.add_platformio_option("board_build.flash_mode", flash_mode)
if flash_frequency := config.get(CONF_FLASH_FREQUENCY):
cg.add_platformio_option(
"board_build.f_flash", f"{flash_frequency[:-3]}000000L"
)
if CONF_SOURCE in conf:
cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]])
@@ -2016,6 +2061,14 @@ async def to_code(config):
add_idf_sdkconfig_option(
f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True
)
if flash_mode := config.get(CONF_FLASH_MODE):
add_idf_sdkconfig_option(
f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True
)
if flash_frequency := config.get(CONF_FLASH_FREQUENCY):
add_idf_sdkconfig_option(
f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True
)
# ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3
# from y to n. PlatformIO uses sections.ld.in (for rev <3) or

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

@@ -1,3 +1,5 @@
import os
Import("env") # noqa: F821
# Remove custom_sdkconfig from the board config as it causes
@@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board:
del board._manifest["espidf"]["custom_sdkconfig"]
if not board._manifest["espidf"]:
del board._manifest["espidf"]
# Referenced by rules in esphome/idf_component.yml; an unset env var is a
# fatal error there. Always 0: in PlatformIO builds arduino is not a managed
# IDF component.
os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0")

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

@@ -492,6 +492,15 @@ def _parse_register(config, regex, line):
STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):")
STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})")
STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})")
# Structured crash handler output (crash_handler.cpp) from a previous boot:
# PC: 0x40220060
# EXCVADDR: 0x0000008A
# BT0: 0x40212345
STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile(
r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})"
)
STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})")
STACKTRACE_BAD_ALLOC_RE = re.compile(
r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$"
)
@@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state):
"Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown")
)
# ESP8266 PC/EXCVADDR
# ESP8266 PC/EXCVADDR (legacy Arduino postmortem)
_parse_register(config, STACKTRACE_ESP8266_PC_RE, line)
_parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line)
# ESP8266 structured crash handler (crash_handler.cpp) from previous boot
_parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line)
_parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line)
match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line)
if match is not None:
_decode_pc(config, match.group(1))
# bad alloc
match = re.match(STACKTRACE_BAD_ALLOC_RE, line)
if match is not None:

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

@@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda
from esphome.cpp_generator import MockObj
from esphome.final_validate import full_config
from esphome.helpers import write_file_if_changed
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
from esphome.writer import clean_build
from esphome.yaml_util import load_yaml
@@ -75,10 +76,14 @@ from .schemas import (
BASE_PROPS,
DISP_BG_SCHEMA,
FULL_STYLE_SCHEMA,
SET_STATE_SCHEMA,
STATE_SCHEMA,
STYLE_REMAP,
STYLE_SCHEMA,
WIDGET_TYPES,
any_widget_schema,
container_schema,
container_schema_value,
obj_dict,
)
from .styles import styles_to_code, theme_to_code
@@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA
page_spec,
)
# These style schemas live in .schemas but are imported here so they land in
# this module's namespace, where script/build_language_schema.py registers them
# as *named* schemas and emits `extends` references — instead of inlining the
# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the
# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in
# this file; this tuple keeps the imports live (and self-documents why).
_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA)
# Widget registration happens via WidgetType.__init__ in individual widget files
# The imports below trigger creation of the widget types
# Action registration (lvgl.{widget}.update) happens automatically
@@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict:
FINAL_VALIDATE_SCHEMA = final_validation
LVGL_SCHEMA = cv.All(
container_schema(
obj_spec,
cv.polling_component_schema("1s")
.extend(
{
**{
cv.Optional(event): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
Trigger.template(lv_obj_t_ptr, lv_event_t_ptr)
),
}
)
for event in df.LV_SCREEN_EVENT_TRIGGERS
+ df.LV_DISPLAY_EVENT_TRIGGERS
},
cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent),
cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t),
cv.GenerateID(df.CONF_DISPLAYS): display_schema,
cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16),
cv.Optional(
df.CONF_DEFAULT_FONT, default="montserrat_14"
): lvalid.lv_font,
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
cv.Optional(
df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False
): cv.boolean,
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
cv.Optional(CONF_ROTATION): validate_rotation,
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
*df.LV_LOG_LEVELS, upper=True
),
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
"big_endian", "little_endian", lower=True
),
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend(
FULL_STYLE_SCHEMA
)
),
cv.Optional(CONF_ON_IDLE): validate_automation(
# The options accepted at the top level of an `lvgl:` block, on top of the base
# object schema that `container_schema(obj_spec, ...)` supplies. Held in a
# module-level name (rather than inline) so the schema-extractor wrapper on
# CONFIG_SCHEMA below can hand the language-schema dumper the same composed
# schema the runtime validates against.
LVGL_TOP_LEVEL_SCHEMA = (
cv.polling_component_schema("1s")
.extend(
{
**{
cv.Optional(event): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger),
cv.Required(CONF_TIMEOUT): cv.templatable(
cv.positive_time_period_milliseconds
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
Trigger.template(lv_obj_t_ptr, lv_event_t_ptr)
),
}
),
cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)),
**{
cv.Optional(x): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger),
},
single=True,
)
for x in SIMPLE_TRIGGERS
},
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA),
cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool,
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec),
cv.Optional(
df.CONF_TRANSPARENCY_KEY, default=0x000400
): lvalid.lv_color,
cv.Optional(df.CONF_THEME): _theme_schema,
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,
cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG,
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
}
)
.extend(DISP_BG_SCHEMA),
),
)
for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS
},
cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent),
cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t),
cv.GenerateID(df.CONF_DISPLAYS): display_schema,
cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16),
cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font,
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean,
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
cv.Optional(CONF_ROTATION): validate_rotation,
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
*df.LV_LOG_LEVELS, upper=True
),
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
"big_endian", "little_endian", lower=True
),
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend(
FULL_STYLE_SCHEMA
)
),
cv.Optional(CONF_ON_IDLE): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger),
cv.Required(CONF_TIMEOUT): cv.templatable(
cv.positive_time_period_milliseconds
),
}
),
cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)),
**{
cv.Optional(x): validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger),
},
single=True,
)
for x in SIMPLE_TRIGGERS
},
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA),
cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool,
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec),
cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color,
cv.Optional(df.CONF_THEME): _theme_schema,
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,
cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG,
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
}
)
.extend(DISP_BG_SCHEMA)
)
LVGL_SCHEMA = cv.All(
container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA),
cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT),
add_hello_world,
)
@schema_extractor("schema")
def lvgl_config_schema(config):
"""
Can't use cv.ensure_list here because it converts an empty config to an empty list,
rather than a default config.
"""
if config is SCHEMA_EXTRACT:
# CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema
# closure, so the language-schema dumper can't see the top-level `lvgl:`
# fields (it would emit an empty schema). Hand it the same composed
# obj + top-level schema the runtime validates against, plus the
# `widgets:` key (added per-value by append_layout_schema at runtime, so
# otherwise invisible to the dumper). Validation of real configs (the
# branches below) is unchanged.
return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend(
{cv.Optional(df.CONF_WIDGETS): any_widget_schema()}
)
if not config or isinstance(config, dict):
return [LVGL_SCHEMA(config)]
return cv.Schema([LVGL_SCHEMA])(config)

View File

@@ -22,7 +22,11 @@ from esphome.const import (
)
from esphome.core import TimePeriod
from esphome.core.config import StartupTrigger
from esphome.schema_extractors import EnableSchemaExtraction
from esphome.schema_extractors import (
SCHEMA_EXTRACT,
EnableSchemaExtraction,
schema_extractor,
)
from . import defines as df, lv_validation as lvalid
from .defines import (
@@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[
] = {}
def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema:
"""
Build the static schema that :func:`container_schema` validates against, i.e.
everything except the value-dependent ``append_layout_schema`` applied at
validation time.
Factored out and exposed so the language-schema dumper can extract a
representative schema for a widget — and for the top-level ``lvgl:`` block,
whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the
:func:`container_schema` validator closure.
"""
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
return schema.extend(widget_type.schema)
def container_schema(
widget_type: WidgetType, extras: Any = None
) -> Callable[[Any], Any]:
@@ -649,12 +672,7 @@ def container_schema(
def get_schema() -> cv.Schema:
nonlocal cached_schema
if cached_schema is None:
schema = obj_schema(widget_type).extend(
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
)
if extras:
schema = schema.extend(extras)
cached_schema = schema.extend(widget_type.schema)
cached_schema = container_schema_value(widget_type, extras)
return cached_schema
def validator(value: Any) -> Any:
@@ -678,7 +696,23 @@ def any_widget_schema(extras=None):
:return: A validator for the Widgets key
"""
@schema_extractor("schema")
def validator(value):
if value is SCHEMA_EXTRACT:
# The widgets: list is built per-value at validation time, so the
# language-schema dumper sees nothing. Enumerate every registered
# widget type as an optional key (a widget item is really a
# single-key mapping; over-listing them lets editors complete any
# widget — `esphome config` enforces exactly one). extras carries the
# layout child options where applicable.
return cv.ensure_list(
cv.Schema(
{
cv.Optional(name): container_schema_value(widget_type, extras)
for name, widget_type in WIDGET_TYPES.items()
}
)
)
if isinstance(value, dict):
# Convert to list
is_dict = True

View File

@@ -139,6 +139,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips
# Special constant for delays in command sequences
DELAY_FLAG = 0xFFF # Special flag to indicate a delay
CONF_PAD_HEIGHT = "pad_height"
CONF_PAD_WIDTH = "pad_width"
CONF_PIXEL_MODE = "pixel_mode"
CONF_USE_AXIS_FLIPS = "use_axis_flips"
@@ -202,6 +204,8 @@ def dimension_schema(rounding):
rounding
),
cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding),
cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding),
cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding),
}
),
)
@@ -311,6 +315,36 @@ class DriverChip:
name = name.upper()
self.name = name
self.initsequence = initsequence
if CONF_NATIVE_WIDTH in defaults:
if CONF_WIDTH not in defaults:
defaults[CONF_WIDTH] = (
defaults[CONF_NATIVE_WIDTH]
- defaults.get(CONF_OFFSET_WIDTH, 0)
- defaults.get(CONF_PAD_WIDTH, 0)
)
else:
native_width = (
defaults.get(CONF_WIDTH, 0)
+ defaults.get(CONF_OFFSET_WIDTH, 0)
+ defaults.get(CONF_PAD_WIDTH, 0)
)
if native_width != 0:
defaults[CONF_NATIVE_WIDTH] = native_width
if CONF_NATIVE_HEIGHT in defaults:
if CONF_HEIGHT not in defaults:
defaults[CONF_HEIGHT] = (
defaults[CONF_NATIVE_HEIGHT]
- defaults.get(CONF_OFFSET_HEIGHT, 0)
- defaults.get(CONF_PAD_HEIGHT, 0)
)
else:
native_height = (
defaults.get(CONF_HEIGHT, 0)
+ defaults.get(CONF_OFFSET_HEIGHT, 0)
+ defaults.get(CONF_PAD_HEIGHT, 0)
)
if native_height != 0:
defaults[CONF_NATIVE_HEIGHT] = native_height
self.defaults = defaults
DriverChip.models[name] = self
@@ -336,18 +370,6 @@ class DriverChip:
initsequence = list(kwargs.pop("initsequence", self.initsequence))
initsequence.extend(kwargs.pop("add_init_sequence", ()))
defaults = self.defaults.copy()
if (
CONF_WIDTH in defaults
and CONF_OFFSET_WIDTH in kwargs
and CONF_NATIVE_WIDTH not in defaults
):
defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH]
if (
CONF_HEIGHT in defaults
and CONF_OFFSET_HEIGHT in kwargs
and CONF_NATIVE_HEIGHT not in defaults
):
defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT]
defaults.update(kwargs)
return self.__class__(name, initsequence=tuple(initsequence), **defaults)
@@ -385,13 +407,16 @@ class DriverChip:
return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms
return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms
def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]:
def get_dimensions(
self, config, swap: bool = True
) -> tuple[int, int, int, int, int, int]:
"""
Return the dimensions of the current model.
:param config: The current configuration
:param swap: If width/height should be swapped when axes are swapped.
:return:
:return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height).
"""
if CONF_DIMENSIONS in config:
# Explicit dimensions, just use as is
dimensions = config[CONF_DIMENSIONS]
@@ -400,33 +425,71 @@ class DriverChip:
height = dimensions[CONF_HEIGHT]
offset_width = dimensions[CONF_OFFSET_WIDTH]
offset_height = dimensions[CONF_OFFSET_HEIGHT]
return width, height, offset_width, offset_height
(width, height) = dimensions
return width, height, 0, 0
if CONF_PAD_WIDTH in dimensions:
pad_width = dimensions[CONF_PAD_WIDTH]
native_width = width + offset_width + pad_width
else:
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
if native_width == 0:
pad_width = 0
native_width = width + offset_width
else:
pad_width = native_width - width - offset_width
if CONF_PAD_HEIGHT in dimensions:
pad_height = dimensions[CONF_PAD_HEIGHT]
native_height = height + offset_height + pad_height
else:
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
if native_height == 0:
pad_height = 0
native_height = height + offset_height
else:
pad_height = native_height - height - offset_height
if (
pad_width + offset_width >= native_width
or pad_height + offset_height >= native_height
):
raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS])
if pad_width < 0 or pad_height < 0:
raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS])
return width, height, offset_width, offset_height, pad_width, pad_height
# Must be a tuple
width, height = dimensions
return width, height, 0, 0, 0, 0
# Default dimensions, use model defaults
transform = self.get_transform(config)
width = self.get_default(CONF_WIDTH)
height = self.get_default(CONF_HEIGHT)
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
offset_width = self.get_default(CONF_OFFSET_WIDTH, 0)
offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0)
pad_width = self.get_default(
CONF_PAD_WIDTH, native_width - width - offset_width
)
pad_height = self.get_default(
CONF_PAD_HEIGHT, native_height - height - offset_height
)
if pad_width < 0 or pad_height < 0:
raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS])
# if mirroring axes and there are offsets, also mirror the offsets to cater for situations where
# the offset is asymmetric
if transform.get(CONF_MIRROR_X):
native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2)
offset_width = native_width - width - offset_width
offset_width, pad_width = pad_width, offset_width
if transform.get(CONF_MIRROR_Y):
native_height = self.get_default(
CONF_NATIVE_HEIGHT, height + offset_height * 2
)
offset_height = native_height - height - offset_height
# Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer
offset_height, pad_height = pad_height, offset_height
# Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer
if swap and transform.get(CONF_SWAP_XY) is True:
width, height = height, width
offset_height, offset_width = offset_width, offset_height
return width, height, offset_width, offset_height
pad_width, pad_height = pad_height, pad_width
return width, height, offset_width, offset_height, pad_width, pad_height
def get_base_transform(self, config):
transform = config.get(
@@ -450,20 +513,8 @@ class DriverChip:
def get_transform(self, config) -> dict[str, bool]:
transform = self.get_base_transform(config)
can_transform = self.rotation_as_transform(config)
# Can we use the MADCTL register to set the rotation?
if can_transform and CONF_TRANSFORM not in config:
rotation = config[CONF_ROTATION]
if rotation == 180:
transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X]
transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y]
elif rotation == 90:
transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY]
transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X]
else:
transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY]
transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y]
transform[CONF_TRANSFORM] = True
transform[CONF_TRANSFORM] = self.rotation_as_transform(config)
return transform
def swap_xy_schema(self):
@@ -498,8 +549,8 @@ class DriverChip:
return madctl
def add_madctl(self, sequence: list, config: dict):
# Add the MADCTL command to the sequence based on the configuration.
# This takes into account rotation if it can be implemented in the transform
# Add the MADCTL command to the sequence based on the base configuration.
# Rotation is not applied here, it will be done at runtime.
transform = self.get_transform(config)
madctl = self.get_madctl(transform, config)
sequence.append((MADCTL, madctl & 0xFF))

View File

@@ -172,7 +172,9 @@ def _config_schema(config):
)(config)
config = model_schema(config)(config)
model = MODELS[config[CONF_MODEL].upper()]
width, height, _offset_width, _offset_height = model.get_dimensions(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
display.add_metadata(
config[CONF_ID],
width,
@@ -206,7 +208,9 @@ async def to_code(config):
model = MODELS[config[CONF_MODEL].upper()]
color_depth = COLOR_DEPTHS[get_color_depth(config)]
pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit"))
width, height, _offset_width, _offset_height = model.get_dimensions(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode)
sequence = model.get_sequence(config)

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

@@ -235,7 +235,9 @@ def _config_schema(config):
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]),
)(config)
model = MODELS[config[CONF_MODEL].upper()]
width, height, _offset_width, _offset_height = model.get_dimensions(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
display.add_metadata(
config[CONF_ID],
width,
@@ -273,7 +275,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
model = MODELS[config[CONF_MODEL].upper()]
width, height, _offset_width, _offset_height = model.get_dimensions(config)
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
var = cg.new_Pvariable(config[CONF_ID], width, height)
cg.add(var.set_model(model.name))
if enable_pin := config.get(CONF_ENABLE_PIN):

View File

@@ -27,7 +27,7 @@ from esphome.components.mipi import (
requires_buffer,
)
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
import esphome.config_validation as cv
from esphome.config_validation import ALLOW_EXTRA
from esphome.const import (
@@ -121,7 +121,9 @@ def denominator(config):
"""
model = MODELS[config[CONF_MODEL]]
frac = config.get(CONF_BUFFER_SIZE)
_width, height, _offset_width, _offset_height = model.get_dimensions(config)
_width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
if frac is None or frac > 0.75 or height < 32:
return 1
try:
@@ -169,11 +171,22 @@ def model_schema(config):
]
if bus_mode == TYPE_SINGLE:
other_options.append(CONF_SPI_16)
# Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise.
spi_mode = model.get_default(CONF_SPI_MODE)
if not spi_mode:
if bus_mode == TYPE_OCTAL or (
bus_mode == TYPE_SINGLE
and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN))
):
spi_mode = "MODE3"
else:
spi_mode = "MODE0"
schema = (
display.FULL_DISPLAY_SCHEMA.extend(
spi.spi_device_schema(
cs_pin_required=False,
default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0",
default_mode=spi_mode,
default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000),
mode=bus_mode,
)
@@ -279,8 +292,8 @@ def customise_schema(config):
CONF_MIRROR_Y,
CONF_SWAP_XY,
}
width, height, _offset_width, _offset_height = model.get_dimensions(
config, not has_hardware_transform
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config, not has_hardware_transform)
)
display.add_metadata(
config[CONF_ID],
@@ -313,14 +326,17 @@ def _final_validate(config):
# If no drawing methods are configured, and LVGL is not enabled, show a test card
config[CONF_SHOW_TEST_CARD] = True
# Always call this to check dimensions during validation
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
model.get_dimensions(config)
)
if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config:
# If PSRAM is not enabled, choose a small buffer size by default
if not requires_buffer(config):
return # No need to pick a size
color_depth = get_color_depth(config)
frac = denominator(config)
width, height, _offset_width, _offset_height = model.get_dimensions(config)
buffer_size = color_depth // 8 * width * height // frac
# Target a buffer size of 20kB, except for large displays, which shouldn't end up here
fraction = min(20000.0, buffer_size // 4) / buffer_size
@@ -347,8 +363,8 @@ def get_instance(config):
CONF_MIRROR_Y,
CONF_SWAP_XY,
}
width, height, offset_width, offset_height = model.get_dimensions(
config, not has_hardware_transform
width, height, offset_width, offset_height, pad_width, pad_height = (
model.get_dimensions(config, not has_hardware_transform)
)
color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit"))
@@ -374,6 +390,8 @@ def get_instance(config):
height,
offset_width,
offset_height,
pad_width,
pad_height,
madctl,
has_hardware_transform,
]

View File

@@ -81,10 +81,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w
* @tparam HEIGHT Height of the display in pixels
* @tparam OFFSET_WIDTH The x-offset of the display in pixels
* @tparam OFFSET_HEIGHT The y-offset of the display in pixels
* @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width
* @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width
* @tparam MADCTL The base MADCTL value for the display, with no rotation bits set.
* @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation.
* buffer
*/
template<typename BUFFERTYPE, PixelMode BUFFERPIXEL, bool IS_BIG_ENDIAN, PixelMode DISPLAYPIXEL, BusType BUS_TYPE,
int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, uint16_t MADCTL, bool HAS_HARDWARE_ROTATION>
int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL,
bool HAS_HARDWARE_ROTATION>
class MipiSpi : public display::Display,
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
spi::DATA_RATE_1MHZ> {
@@ -126,17 +131,6 @@ class MipiSpi : public display::Display,
return HEIGHT;
}
// If hardware rotation is in use, the actual display width/height changes with rotation
int get_width_internal() override {
if constexpr (HAS_HARDWARE_ROTATION)
return get_width();
return WIDTH;
}
int get_height_internal() override {
if constexpr (HAS_HARDWARE_ROTATION)
return get_height();
return HEIGHT;
}
void set_init_sequence(const std::vector<uint8_t> &sequence) { this->init_sequence_ = sequence; }
// reset the display, and write the init sequence
@@ -233,14 +227,25 @@ class MipiSpi : public display::Display,
}
void dump_config() override {
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT,
(uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_,
this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
HAS_HARDWARE_ROTATION);
internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(),
this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8,
IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, this->mode_,
this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION);
}
protected:
/* METHODS */
// If hardware rotation is in use, the actual display width/height changes with rotation
int get_width_internal() override {
if constexpr (HAS_HARDWARE_ROTATION)
return get_width();
return WIDTH;
}
int get_height_internal() override {
if constexpr (HAS_HARDWARE_ROTATION)
return get_height();
return HEIGHT;
}
// convenience functions to write commands with or without data
void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); }
void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); }
@@ -330,20 +335,34 @@ class MipiSpi : public display::Display,
this->write_command_(MADCTL_CMD, madctl);
}
uint16_t get_offset_width_() {
uint16_t get_offset_width_() const {
if constexpr (HAS_HARDWARE_ROTATION) {
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
return OFFSET_HEIGHT;
switch (this->rotation_) {
case display::DISPLAY_ROTATION_90_DEGREES:
return OFFSET_HEIGHT;
case display::DISPLAY_ROTATION_180_DEGREES:
return PAD_WIDTH;
case display::DISPLAY_ROTATION_270_DEGREES:
return PAD_HEIGHT;
default:
break;
}
}
return OFFSET_WIDTH;
}
uint16_t get_offset_height_() {
uint16_t get_offset_height_() const {
if constexpr (HAS_HARDWARE_ROTATION) {
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
return OFFSET_WIDTH;
switch (this->rotation_) {
case display::DISPLAY_ROTATION_90_DEGREES:
return PAD_WIDTH;
case display::DISPLAY_ROTATION_180_DEGREES:
return PAD_HEIGHT;
case display::DISPLAY_ROTATION_270_DEGREES:
return OFFSET_WIDTH;
default:
break;
}
}
return OFFSET_HEIGHT;
}
@@ -396,7 +415,7 @@ class MipiSpi : public display::Display,
this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8);
}
} else {
for (size_t y = 0; y != static_cast<size_t>(h); y++) {
for (size_t y = 0; y != h; y++) {
if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) {
this->write_array(ptr, w);
} else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) {
@@ -492,19 +511,23 @@ class MipiSpi : public display::Display,
* @tparam BUFFERPIXEL Color depth of the buffer
* @tparam DISPLAYPIXEL Color depth of the display
* @tparam BUS_TYPE The type of the interface bus (single, quad, octal)
* @tparam ROTATION The rotation of the display
* @tparam WIDTH Width of the display in pixels
* @tparam HEIGHT Height of the display in pixels
* @tparam OFFSET_WIDTH The x-offset of the display in pixels
* @tparam OFFSET_HEIGHT The y-offset of the display in pixels
* @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width
* @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width
* @tparam MADCTL The base MADCTL value for the display, with no rotation bits set.
* @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation.
* @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer).
* @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even)
*/
template<typename BUFFERTYPE, PixelMode BUFFERPIXEL, bool IS_BIG_ENDIAN, PixelMode DISPLAYPIXEL, BusType BUS_TYPE,
uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, uint16_t MADCTL,
bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING>
class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT,
OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION> {
uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT,
uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING>
class MipiSpiBuffer
: public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT, OFFSET_WIDTH,
OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION> {
public:
// these values define the buffer size needed to write in accordance with the chip pixel alignment
// requirements. If the required rounding does not divide the width and height, we round up to the next multiple and
@@ -515,7 +538,7 @@ class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DIS
void dump_config() override {
MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT,
MADCTL, HAS_HARDWARE_ROTATION>::dump_config();
PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config();
esph_log_config(TAG,
" Rotation: %d°\n"
" Buffer pixels: %d bits\n"
@@ -528,7 +551,7 @@ class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DIS
void setup() override {
MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT,
MADCTL, HAS_HARDWARE_ROTATION>::setup();
PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup();
RAMAllocator<BUFFERTYPE> allocator{};
this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION);
if (this->buffer_ == nullptr) {

View File

@@ -179,6 +179,9 @@ ILI9342 = DriverChip(
# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation
ILI9341.extend(
"M5CORE2",
# Reset native dimensions due to axis swap.
native_width=320,
native_height=240,
width=320,
height=240,
mirror_x=False,
@@ -786,3 +789,28 @@ ST7796.extend(
dc_pin=0,
invert_colors=True,
)
ST7789V.extend(
"GEEKMAGIC-SMALLTV",
data_rate="40MHz",
height=240,
width=240,
offset_width=0,
offset_height=0,
invert_colors=True,
buffer_size=0.125,
reset_pin=2,
dc_pin=0,
)
ST7789V.extend(
"GEEKMAGIC-SMALLTV-PRO",
data_rate="40MHz",
height=240,
width=240,
offset_width=0,
offset_height=0,
invert_colors=True,
buffer_size=0.125,
reset_pin=4,
dc_pin=2,
)

View File

@@ -269,3 +269,16 @@ ST7789V.extend(
cs_pin=14,
dc_pin={"number": 15, "ignore_strapping_warning": True},
)
ST7789V.extend(
"WAVESHARE-ESP32-S3-GEEK",
cs_pin=10,
dc_pin=8,
reset_pin=9,
width=135,
height=240,
offset_width=52,
offset_height=40,
invert_colors=True,
data_rate="40MHz",
)

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

@@ -17,6 +17,11 @@ class SPIDelegateHw : public SPIDelegate {
write_only_(write_only) {
if (!this->release_device_)
add_device_();
if (this->write_only_) {
ESP_LOGV(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)",
Utility::get_pin_no(this->cs_pin_));
}
}
bool is_ready() override { return this->handle_ != nullptr; }
@@ -195,11 +200,8 @@ class SPIDelegateHw : public SPIDelegate {
config.post_cb = nullptr;
if (this->bit_order_ == BIT_ORDER_LSB_FIRST)
config.flags |= SPI_DEVICE_BIT_LSBFIRST;
if (this->write_only_) {
if (this->write_only_)
config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY;
ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)",
Utility::get_pin_no(this->cs_pin_));
}
esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Add device failed - err %X", err);

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.0b1"
__version__ = "2026.6.2"
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
VALID_SUBSTITUTIONS_CHARACTERS = (

View File

@@ -958,6 +958,13 @@ class EsphomeCore:
return build_flag
def add_build_unflag(self, build_unflag: str) -> None:
if self.using_toolchain_esp_idf:
# The native ESP-IDF build generator does not consume build_unflags
_LOGGER.warning(
"Build unflag %s is ignored when building with the native "
"ESP-IDF toolchain",
build_unflag,
)
self.build_unflags.add(build_unflag)
_LOGGER.debug("Adding build unflag: %s", build_unflag)

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

@@ -503,8 +503,58 @@ async def add_includes(includes: list[str], is_c_header: bool = False) -> None:
include_file(path, basename, is_c_header)
def _add_library_str(lib: str) -> None:
if "@" in lib:
name, vers = lib.split("@", 1)
cg.add_library(name, vers)
elif "://" in lib:
# Repository...
if "=" in lib:
name, repo = lib.split("=", 1)
cg.add_library(name, None, repo)
else:
cg.add_library(None, None, lib)
else:
cg.add_library(lib, None)
@coroutine_with_priority(CoroPriority.FINAL)
async def _add_platformio_options(pio_options):
async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None:
if CORE.using_toolchain_esp_idf:
# The native ESP-IDF build doesn't read platformio.ini; honor the
# options with a native equivalent and warn about the rest, which
# would otherwise be silently ignored.
for key, val in pio_options.items():
vals = [val] if isinstance(val, str) else val
if key == CONF_BUILD_FLAGS:
# Deprecated: esphome->build_flags is the native equivalent.
# Remove before 2026.12.0
_LOGGER.warning(
"esphome->platformio_options->build_flags is deprecated; use "
"esphome->build_flags instead. Support for it will be removed "
"in 2026.12.0."
)
for flag in vals:
cg.add_build_flag(flag)
elif key == "lib_deps":
# Routed through the regular library mechanism so the libraries
# are converted to IDF components like any other PIO library
for lib in vals:
_add_library_str(lib)
elif key == "lib_ignore":
# Read by the PIO-library-to-IDF-component conversion
# (generate_idf_components); filters both top-level libraries
# and dependencies discovered during conversion
cg.add_platformio_option(key, vals)
elif key != "upload_speed":
# upload_speed needs no handling: it is read from the raw
# config at upload time (upload_using_esptool)
_LOGGER.warning(
"esphome->platformio_options->%s is ignored when building with "
"the native ESP-IDF toolchain",
key,
)
return
# Add includes at the very end, so that they override everything
for key, val in pio_options.items():
if key in ["build_flags", "lib_ignore"] and not isinstance(val, list):
@@ -655,19 +705,7 @@ async def to_code(config: ConfigType) -> None:
# Libraries
for lib in config[CONF_LIBRARIES]:
if "@" in lib:
name, vers = lib.split("@", 1)
cg.add_library(name, vers)
elif "://" in lib:
# Repository...
if "=" in lib:
name, repo = lib.split("=", 1)
cg.add_library(name, None, repo)
else:
cg.add_library(None, None, lib)
else:
cg.add_library(lib, None)
_add_library_str(lib)
cg.add_build_flag("-Wno-unused-variable")
cg.add_build_flag("-Wno-unused-but-set-variable")

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

@@ -162,7 +162,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None:
# Gates arduino-only components in esphome/idf_component.yml (IDF reads it at
# reconfigure time). Set here -- before the manifest is written/reconfigured.
os.environ["ESPHOME_ARDUINO"] = (
os.environ["ESPHOME_ARDUINO_COMPONENT"] = (
"1" if settings.target_framework == "arduino" else "0"
)

View File

@@ -56,7 +56,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE"
class Source:
def download(self, dir_suffix: str, force: bool = False) -> Path:
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
raise NotImplementedError
@@ -64,10 +64,12 @@ class URLSource(Source):
def __init__(self, url: str):
self.url = url
def download(self, dir_suffix: str, force: bool = False) -> Path:
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
base_dir = Path(CORE.data_dir) / DOMAIN
h = hashlib.new("sha256")
h.update(self.url.encode())
if salt:
h.update(salt.encode())
path = base_dir / h.hexdigest()[:8] / dir_suffix
# Marker file written last to signal a complete extraction. Using a
# marker (instead of just `path.is_dir()`) means an interrupted
@@ -99,12 +101,12 @@ class GitSource(Source):
self.url = url
self.ref = ref
def download(self, dir_suffix: str, force: bool = False) -> Path:
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
path, _ = git.clone_or_update(
url=self.url,
ref=self.ref,
refresh=git.NEVER_REFRESH if not force else None,
domain=DOMAIN,
domain=f"{DOMAIN}/{salt}" if salt else DOMAIN,
submodules=[],
subpath=Path(dir_suffix),
)
@@ -146,14 +148,16 @@ class IDFComponent:
def get_require_name(self):
return self.get_sanitized_name().replace("/", "__")
def download(self, force: bool = False):
def download(self, force: bool = False, salt: str = ""):
"""
The dependency name should match the directory name at the end of the override path.
The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name.
If you want to specify the full name of the component with the namespace, replace / in the component name with __.
@see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html
"""
self.path = self.source.download(self.get_sanitized_name(), force=force)
self.path = self.source.download(
self.get_sanitized_name(), force=force, salt=salt
)
def _apply_extra_script(component: IDFComponent) -> None:
@@ -699,9 +703,33 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
The returned list holds the top-level components (those directly requested);
transitive dependencies are converted too and wired into each component's
generated manifest.
``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by
short name (part after the ``/``), matched against both the top-level
libraries and every dependency discovered during the graph walk.
"""
nodes: dict[str, _LibNode] = {}
lib_ignore = {
name.split("/")[-1].lower()
for name in CORE.platformio_options.get("lib_ignore", [])
}
# The generated CMakeLists.txt/idf_component.yml inside the shared cache
# bake in the dependency wiring, which lib_ignore changes; salt the cache
# path so configs with different lib_ignore values don't fight over (and
# constantly rewrite) the same converted component files.
salt = (
hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8]
if lib_ignore
else ""
)
def is_ignored(name: str | None) -> bool:
if not lib_ignore or name is None:
return False
return name.split("/")[-1].lower() in lib_ignore
def add_spec(name: str | None, version: str | None, repository: str | None) -> str:
key, is_git, locator = _node_key(name, version, repository)
node = nodes.get(key) or _LibNode(key=key, is_git=is_git)
@@ -718,6 +746,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
top_level = [
add_spec(library.name, library.version, library.repository)
for library in libraries
if not is_ignored(library.name)
]
# Collect + resolve to a fixpoint: a node is (re)resolved whenever its
@@ -749,7 +778,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
component = IDFComponent(
_owner_pkgname_to_name(owner, name), version, URLSource(url)
)
component.download()
component.download(salt=salt)
library_json_path = component.path / "library.json"
library_properties_path = component.path / "library.properties"
@@ -787,6 +816,12 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
except InvalidIDFComponent as e:
_LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e))
continue
dep_name = _owner_pkgname_to_name(
dependency.get("owner"), dependency.get("name")
)
if is_ignored(dep_name):
_LOGGER.debug("Skip ignored dependency %s", dep_name)
continue
# The version field may actually be a URL (git/archive dependency).
dep_version = dependency["version"]
dep_url = None
@@ -796,11 +831,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
dep_url, dep_version = dep_version, None
except (TypeError, ValueError):
pass
dep_key = add_spec(
_owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")),
dep_version,
dep_url,
)
dep_key = add_spec(dep_name, dep_version, dep_url)
node.edges.add(dep_key)
worklist.append(dep_key)

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:
@@ -109,4 +109,4 @@ dependencies:
git: https://github.com/FastLED/FastLED.git
version: d44c800a9e876a8394caefc2ce4915dd96dac77b
rules:
- if: "$ESPHOME_ARDUINO == 1"
- if: "$ESPHOME_ARDUINO_COMPONENT == 1"

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

@@ -141,7 +141,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script
; This are common settings for the ESP32 (all variants) using Arduino.
[common:esp32-arduino]
extends = common:arduino
platform = https://github.com/pioarduino/platform-espressif32.git
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip
platform_packages =
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
framework = arduino, espidf ; Arduino as an ESP-IDF component
lib_deps =
@@ -168,12 +171,16 @@ build_flags =
-DAUDIO_NO_SD_FS ; i2s_audio
build_unflags =
${common.build_unflags}
extra_scripts = post:esphome/components/esp32/post_build.py.script
extra_scripts =
pre:esphome/components/esp32/pre_build.py.script
post:esphome/components/esp32/post_build.py.script
; This are common settings for the ESP32 (all variants) using IDF.
[common:esp32-idf]
extends = common:idf
platform = https://github.com/pioarduino/platform-espressif32.git
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip
platform_packages =
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
framework = espidf
lib_deps =
@@ -187,7 +194,9 @@ build_flags =
-DUSE_ESP32_FRAMEWORK_ESP_IDF
build_unflags =
${common.build_unflags}
extra_scripts = post:esphome/components/esp32/post_build.py.script
extra_scripts =
pre:esphome/components/esp32/pre_build.py.script
post:esphome/components/esp32/post_build.py.script
; These are common settings for the RP2040 using Arduino.
[common:rp2040-arduino]
@@ -271,7 +280,6 @@ build_unflags =
[env:esp32-arduino]
extends = common:esp32-arduino
board = esp32dev
board_build.partitions = huge_app.csv
build_flags =
${common:esp32-arduino.build_flags}
${flags:runtime.build_flags}

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

@@ -428,6 +428,33 @@ def fix_menu():
menu[S_EXTENDS].append("display_menu_base.MENU_TYPES")
def fix_lvgl_widgets():
# lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The
# dumper has no cycle detection, so — like fix_menu — hoist the inlined
# widget-type enumeration into a named schema and reference it for both the
# top-level list and each widget's own children, instead of expanding it.
if "lvgl" not in output:
return
schemas = output["lvgl"][S_SCHEMAS]
config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS]
widgets = config_vars.get("widgets")
if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]:
return
# 1. Hoist the (one-level) widget enumeration into a named schema.
schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]}
# 2. Reference it from the top-level widgets: list instead of inlining.
widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}
# 3. Let every widget contain child widgets, via the same named ref.
for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values():
if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget:
widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = {
S_TYPE: S_SCHEMA,
"is_list": True,
"key": "Optional",
S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]},
}
def get_logger_tags():
pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE)
# tags not in components dir
@@ -740,6 +767,7 @@ def build_schema():
add_logger_tags()
shrink()
fix_menu()
fix_lvgl_widgets()
# aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc.
data = {}
@@ -923,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

@@ -0,0 +1,7 @@
esphome:
name: test
esp32:
board: esp32dev
framework:
type: esp-idf

View File

@@ -0,0 +1,9 @@
esphome:
name: test
esp32:
board: esp32dev
flash_mode: qio
flash_frequency: 80MHz
framework:
type: esp-idf

View File

@@ -285,3 +285,29 @@ def test_native_idf_enables_reproducible_build(
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True
def test_flash_mode_sets_sdkconfig_and_pio_option(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""flash_mode/flash_frequency select the esptool flash parameters on both backends."""
generate_main(component_config_path("flash_mode_idf.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True
assert CORE.platformio_options.get("board_build.flash_mode") == "qio"
assert CORE.platformio_options.get("board_build.f_flash") == "80000000L"
def test_flash_mode_unset_leaves_defaults(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Without flash_mode the board/sdkconfig defaults stay untouched."""
generate_main(component_config_path("flash_mode_default.yaml"))
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig)
assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig)
assert "board_build.flash_mode" not in CORE.platformio_options
assert "board_build.f_flash" not in CORE.platformio_options

View File

@@ -314,7 +314,7 @@ def test_native_generation(
main_cpp = generate_main(component_fixture_path("native.yaml"))
assert (
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, true, 1, 1>()"
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, 0, 0, true, 1, 1>()"
in main_cpp
)
assert "set_init_sequence({240, 1, 8, 242" in main_cpp
@@ -330,7 +330,7 @@ def test_lvgl_generation(
main_cpp = generate_main(component_fixture_path("lvgl.yaml"))
assert (
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, true>();"
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, 0, 0, true>();"
in main_cpp
)
assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp

View File

@@ -0,0 +1,434 @@
"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi."""
from __future__ import annotations
from collections.abc import Callable
from pathlib import Path
import pytest
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.mipi_spi.display import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
MODELS,
get_instance,
)
from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def validated_config(config: ConfigType) -> ConfigType:
"""Run schema + final validation and return the validated config."""
config = CONFIG_SCHEMA(config)
FINAL_VALIDATE_SCHEMA(config)
return config
class TestSPIModeCalculation:
"""Test default SPI mode calculation logic."""
@pytest.mark.parametrize(
("bus_mode", "cs_pin", "expected_mode"),
[
pytest.param(
TYPE_OCTAL,
None,
"MODE3",
id="octal_bus_no_cs",
),
pytest.param(
TYPE_OCTAL,
14,
"MODE3",
id="octal_bus_with_cs",
),
pytest.param(
TYPE_SINGLE,
None,
"MODE3",
id="single_bus_no_cs",
),
pytest.param(
TYPE_SINGLE,
14,
"MODE0",
id="single_bus_with_cs",
),
pytest.param(
TYPE_QUAD,
None,
"MODE0",
id="quad_bus_no_cs",
),
pytest.param(
TYPE_QUAD,
14,
"MODE0",
id="quad_bus_with_cs",
),
],
)
def test_default_spi_mode_calculation(
self,
bus_mode: str,
cs_pin: int | None,
expected_mode: str,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that SPI mode is correctly calculated based on bus mode and CS pin."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
config: ConfigType = {
"model": "custom",
"dimensions": {"width": 320, "height": 240},
"init_sequence": [[0xA0, 0x01]],
"bus_mode": bus_mode,
}
# Add dc_pin for modes that require it (single and octal)
# quad mode does not allow dc_pin
if bus_mode != TYPE_QUAD:
config[CONF_DC_PIN] = 11
# Add CS pin if specified
if cs_pin is not None:
config[CONF_CS_PIN] = cs_pin
validated = validated_config(config)
# The validated config should have the correct SPI mode set by model_schema
assert validated.get(CONF_SPI_MODE) == expected_mode
def test_explicit_spi_mode_overrides_default(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that an explicitly configured SPI mode is not overridden."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
# For octal bus, default is MODE3, but we specify MODE0
config = validated_config(
{
"model": "custom",
"dc_pin": 11, # Required for octal mode
"dimensions": {"width": 320, "height": 240},
"init_sequence": [[0xA0, 0x01]],
"bus_mode": TYPE_OCTAL,
"spi_mode": "MODE0", # Explicitly set
}
)
assert config[CONF_SPI_MODE] == "MODE0"
class TestModelWithPaddingDimensions:
"""Test that padding dimensions are correctly returned by models."""
def test_model_get_dimensions_returns_six_values(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that get_dimensions() returns 6 values including padding."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
# Test with a real model
model = MODELS["ST7735"]
config = {"model": "ST7735", "dc_pin": 18}
# Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height)
dimensions = model.get_dimensions(config)
assert len(dimensions) == 6
assert all(isinstance(v, int) for v in dimensions)
def test_custom_model_padding_values(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test padding values for a custom model with explicit offset."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 320,
"offset_width": 20,
"offset_height": 10,
},
"init_sequence": [[0xA0, 0x01]],
}
)
# For custom models, the model is created dynamically from the config
# We can verify the config has the right dimensions
assert config["dimensions"]["width"] == 240
assert config["dimensions"]["height"] == 320
assert config["dimensions"]["offset_width"] == 20
assert config["dimensions"]["offset_height"] == 10
# Padding is not stored in config for custom models (defaults to 0)
assert config["dimensions"].get("offset_width_pad", 0) == 0
assert config["dimensions"].get("offset_height_pad", 0) == 0
class TestNewModelVariants:
"""Test new model variants added in this change."""
def test_m5core2_with_native_dimensions(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test M5CORE2 variant with reset native_width and native_height."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
# M5CORE2 should validate successfully
config = validated_config({"model": "M5CORE2"})
assert config is not None
# Verify the model has correct dimensions
model = MODELS["M5CORE2"]
dimensions = model.get_dimensions(config)
width, height, _, _, _, _ = dimensions
assert width == 320
assert height == 240
def test_geekmagic_smalltv_variant(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test GEEKMAGIC-SMALLTV variant of ST7789V."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# GEEKMAGIC-SMALLTV should validate successfully
config = validated_config({"model": "GEEKMAGIC-SMALLTV"})
assert config is not None
# Verify it's a variant of ST7789V with expected dimensions
model = MODELS["GEEKMAGIC-SMALLTV"]
dimensions = model.get_dimensions(config)
width, height, offset_x, offset_y, _, _ = dimensions
assert width == 240
assert height == 240
assert offset_x == 0
assert offset_y == 0
def test_all_predefined_models_with_new_get_dimensions_signature(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Verify all predefined models work with new 6-value get_dimensions()."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
for name, model in MODELS.items():
# Skip custom model
if name == "custom":
continue
config = {"model": name}
# Try to get dimensions - should return 6 values for all models
dimensions = model.get_dimensions(config)
assert len(dimensions) == 6, (
f"Model {name} should return 6 dimensions, got {len(dimensions)}"
)
class TestTemplateParameterPassing:
"""Test that padding parameters are correctly passed to C++ templates."""
def test_instance_creation_with_padding(
self,
generate_main: Callable[[str | Path], str],
component_fixture_path: Callable[[str], Path],
) -> None:
"""Test that get_instance() correctly passes padding parameters to template."""
main_cpp = generate_main(component_fixture_path("native.yaml"))
# native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer
# (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE,
# WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION,
# FRACTION, ROUNDING)
# The instantiation should include padding values (0, 0 for default)
assert (
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, 0, 0, true, 1, 1>()"
in main_cpp
), (
"Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation"
)
def test_single_mode_with_offset_padding(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that single-mode display with custom offset works with padding."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 320,
"offset_width": 40,
"offset_height": 20,
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
# Should not raise any errors
instance = get_instance(config)
assert instance is not None
class TestUserConfiguredPadding:
"""Test that pad_width and pad_height can be configured in user dimensions."""
def test_explicit_pad_width_and_height_in_dimensions(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that pad_width and pad_height can be explicitly set in dimensions."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 320,
"offset_width": 40,
"offset_height": 20,
"pad_width": 80,
"pad_height": 40,
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
# Config should validate successfully with padding dimensions
assert config is not None
assert config["dimensions"]["pad_width"] == 80
assert config["dimensions"]["pad_height"] == 40
def test_padding_for_native_dimension_calculation(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test that explicit padding allows native dimensions to be calculated."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# A controller that has 320x320 total pixels with:
# - 240x320 active display area
# - offset_width=40, offset_height=20
# - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240, # Active display width
"height": 320, # Active display height
"offset_width": 40,
"offset_height": 0,
"pad_width": 40, # Pixels after width+offset
"pad_height": 0, # Pixels after height+offset
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
# Get instance should work and correctly calculate native dimensions
instance = get_instance(config)
assert instance is not None
def test_padding_without_offset(
self,
set_core_config: SetCoreConfigCallable,
) -> None:
"""Test padding can be used without offset for controllers with top-left-aligned displays."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# A display with no offset but padding on right and bottom
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {
"width": 240,
"height": 240,
"offset_width": 0,
"offset_height": 0,
"pad_width": 0,
"pad_height": 16,
},
"init_sequence": [[0xA0, 0x01]],
"buffer_size": 0.25,
}
)
assert config is not None
assert config["dimensions"]["width"] == 240
assert config["dimensions"]["height"] == 240
assert config["dimensions"]["pad_height"] == 16

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

@@ -4,7 +4,12 @@ from __future__ import annotations
import ast
import importlib.util
import json
from pathlib import Path
import subprocess
import sys
import pytest
from esphome import config_validation as cv
@@ -134,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")
@@ -176,3 +203,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None:
entry = converted["schema"]["config_vars"]["hostname"]
assert "sensitive" not in entry
assert "sensitive_source" not in entry
# ---------------------------------------------------------------------------
# Regression tests for the lvgl schema dump.
#
# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are
# built lazily at validation time, so the static dumper used to emit an empty
# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA
# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise
# the full `build_schema()` and assert the generated lvgl.json carries the data
# the schema_extractor hooks added.
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict:
"""Run the full language-schema build once and return parsed lvgl.json.
The build must run in a fresh interpreter: ``build_language_schema.py``
enables schema extraction *before* importing any esphome component, and the
extraction hooks are no-ops if the components were already imported (as they
are inside the pytest session). Running it as a subprocess mirrors how CI
generates the schema and keeps this test isolated from import order.
"""
out_dir = tmp_path_factory.mktemp("language_schema")
subprocess.run(
[sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)],
check=True,
capture_output=True,
text=True,
)
return json.loads((out_dir / "lvgl.json").read_text())
def _lvgl_config_vars(lvgl_schema: dict) -> dict:
config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"]
# Previously empty (`{}`); the schema_extractor on lvgl_config_schema now
# hands the dumper the composed top-level schema.
assert config_schema["type"] == "schema"
return config_schema["schema"]["config_vars"]
def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None:
config_vars = _lvgl_config_vars(lvgl_schema)
# Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed.
assert len(config_vars) > 100
# A representative spread of top-level options the runtime validates.
for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"):
assert key in config_vars, f"missing top-level lvgl option: {key}"
def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None:
config_vars = _lvgl_config_vars(lvgl_schema)
# The widgets: list is assembled per-value at runtime; the extractor
# enumerates every registered widget type into a named WIDGET_TYPES schema
# which the widgets: list references (recursive, so widgets can nest).
assert "widgets" in config_vars
widgets = config_vars["widgets"]
assert widgets["is_list"] is True
assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"]
widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][
"config_vars"
]
# Every registered widget type should appear as an optional key.
for name in ("obj", "label", "button", "slider", "switch", "arc"):
assert name in widget_types, f"widget type not enumerated: {name}"
# Each enumerated widget carries its own property schema, not an empty stub.
assert widget_types["label"]["type"] == "schema"
assert len(widget_types["label"]["schema"]["config_vars"]) > 0
# Each widget can contain child widgets, via the same named ref — so the
# tree is recursive and the dump stays finite.
nested = widget_types["obj"]["schema"]["config_vars"]["widgets"]
assert nested["is_list"] is True
assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"]
def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None:
schemas = lvgl_schema["lvgl"]["schemas"]
# Importing these into the lvgl __init__ namespace lets the dumper register
# them as named schemas and emit `extends` refs instead of inlining them.
for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"):
assert name in schemas, f"style schema not registered as named: {name}"
# STYLE_SCHEMA must be referenced via `extends`, not inlined at every use
# site. Count the references to prove the dedup actually happened.
refs = 0
def _count(node: object) -> None:
nonlocal refs
if isinstance(node, dict):
extends = node.get("extends")
if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends:
refs += 1
for value in node.values():
_count(value)
elif isinstance(node, list):
for value in node:
_count(value)
_count(lvgl_schema)
assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}"

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

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