Compare commits

..

40 Commits

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

View File

@@ -1 +1 @@
a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c
72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6

View File

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

View File

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

View File

@@ -456,7 +456,7 @@ jobs:
echo "binary=$BINARY" >> $GITHUB_OUTPUT
- name: Run CodSpeed benchmarks
uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # v4.17.5
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0
with:
run: |
. venv/bin/activate

View File

@@ -59,19 +59,6 @@ This document provides essential context for AI models interacting with this pro
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
- Favor descriptive names over abbreviations
* **Python Idioms:**
* **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
```python
# Bad - looks up CONF_BLAH twice
if CONF_BLAH in config:
cg.add(var.set_blah(config[CONF_BLAH]))
# Good - single lookup, value bound inline
if (blah := config.get(CONF_BLAH)) is not None:
cg.add(var.set_blah(blah))
```
The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line.
* **C++ Field Visibility:**
* **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`.
* **Use `private` for safety-critical cases:** Use `private` visibility when direct field access could introduce bugs or violate invariants:

View File

@@ -192,7 +192,6 @@ esphome/components/ft5x06/* @clydebarrow
esphome/components/ft63x6/* @gpambrozio
esphome/components/gcja5/* @gcormier
esphome/components/gdk101/* @Szewcson
esphome/components/generic_image/* @kahrendt
esphome/components/gl_r01_i2c/* @pkejval
esphome/components/globals/* @esphome/core
esphome/components/gp2y1010au0f/* @zry98
@@ -448,7 +447,6 @@ esphome/components/sen21231/* @shreyaskarnik
esphome/components/sen5x/* @martgras
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
esphome/components/sendspin/* @kahrendt
esphome/components/sendspin/generic_image/* @kahrendt
esphome/components/sendspin/media_player/* @kahrendt
esphome/components/sendspin/media_source/* @kahrendt
esphome/components/sendspin/sensor/* @kahrendt

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.7.0-dev
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

@@ -13,7 +13,6 @@ from esphome.components.mipi import (
import esphome.config_validation as cv
from esphome.config_validation import update_interval
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BUSY_PIN,
CONF_CS_PIN,
CONF_DATA_RATE,
@@ -130,23 +129,7 @@ def customise_schema(config):
},
extra=cv.ALLOW_EXTRA,
)(config)
model = MODELS[config[CONF_MODEL]]
config = model_schema(config)(config)
width, height = model.get_dimensions(config)
display.add_metadata(
config[CONF_ID],
width,
height,
has_hardware_rotation=True,
byte_order=cv.UNDEFINED,
has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True
or config.get(CONF_PAGES) is not None
or config.get(CONF_LAMBDA) is not None
or config.get(CONF_SHOW_TEST_CARD) is True,
rotation=config.get(CONF_ROTATION, 0),
draw_rounding=0,
)
return config
return model_schema(config)(config)
CONFIG_SCHEMA = customise_schema
@@ -214,9 +197,6 @@ async def to_code(config):
if busy_pin := config.get(CONF_BUSY_PIN):
busy = await cg.gpio_pin_expression(busy_pin)
cg.add(var.set_busy_pin(busy))
if enable_pin := config.get(CONF_ENABLE_PIN):
enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin]
cg.add(var.set_enable_pins(enable))
cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY]))
if CONF_RESET_DURATION in config:
cg.add(var.set_reset_duration(config[CONF_RESET_DURATION]))

View File

@@ -38,10 +38,6 @@ bool EPaperBase::init_buffer_(size_t buffer_length) {
}
void EPaperBase::setup_pins_() const {
for (auto *pin : this->enable_pins_) {
pin->setup();
pin->digital_write(true);
}
this->dc_pin_->setup(); // OUTPUT
this->dc_pin_->digital_write(false);

View File

@@ -50,7 +50,6 @@ class EPaperBase : public Display,
float get_setup_priority() const override;
void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; }
void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; }
void set_enable_pins(std::vector<GPIOPin *> enable_pins) { this->enable_pins_ = std::move(enable_pins); }
void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; }
void set_transform(uint8_t transform) {
this->transform_ = transform;
@@ -178,7 +177,6 @@ class EPaperBase : public Display,
GPIOPin *dc_pin_{};
GPIOPin *busy_pin_{};
GPIOPin *reset_pin_{};
std::vector<GPIOPin *> enable_pins_{};
bool waiting_for_idle_{};
uint32_t delay_until_{}; // timestamp until which to delay processing
uint16_t next_delay_{}; // milliseconds to delay before next state

View File

@@ -10,11 +10,11 @@ class SSD1677(EpaperModel):
# fmt: off
def get_init_sequence(self, config: dict):
_width, height = self.get_dimensions(config)
width, _height = self.get_dimensions(config)
return (
(0x18, 0x80), # Select internal Temp sensor
(0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2
(0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1)
(0x01, (width - 1) % 256, (width - 1) // 256, 0x02), # Set column gate limit
(0x3C, 0x01), # Set border waveform
(0x11, 3), # Set transform
)
@@ -51,16 +51,3 @@ ssd1677.extend(
height=480,
mirror_x=True,
)
ssd1677.extend(
"seeed-reterminal-sticky",
width=800,
height=480,
mirror_x=True,
enable_pin=47,
cs_pin=15,
dc_pin=16,
reset_pin=17,
busy_pin=18,
data_rate="10MHz",
)

View File

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

View File

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

View File

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

View File

@@ -1,2 +0,0 @@
CODEOWNERS = ["@kahrendt"]
IS_PLATFORM_COMPONENT = True

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

@@ -1,5 +1,4 @@
#include "online_image.h"
#include "esphome/components/runtime_image/image_decoder.h"
#include "esphome/core/log.h"
#include <algorithm>
@@ -182,7 +181,7 @@ void OnlineImage::loop() {
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
if (consumed < 0) {
ESP_LOGE(TAG, "Error decoding image: %s", esphome::runtime_image::decode_error_to_string(consumed));
ESP_LOGE(TAG, "Error decoding image: %d", consumed);
this->end_connection_();
this->download_error_callback_.call();
return;

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

@@ -7,24 +7,8 @@ enum DecodeError : int {
DECODE_ERROR_INVALID_TYPE = -1,
DECODE_ERROR_UNSUPPORTED_FORMAT = -2,
DECODE_ERROR_OUT_OF_MEMORY = -3,
DECODE_ERROR_INTERNAL_DECODER_ERROR = -4,
};
constexpr const char *decode_error_to_string(int error) {
switch (error) {
case DECODE_ERROR_INVALID_TYPE:
return "Invalid type";
case DECODE_ERROR_UNSUPPORTED_FORMAT:
return "Unsupported format";
case DECODE_ERROR_OUT_OF_MEMORY:
return "Out of memory";
case DECODE_ERROR_INTERNAL_DECODER_ERROR:
return "Internal decoder error";
default:
return "Unknown error";
}
}
class RuntimeImage;
/**

View File

@@ -89,21 +89,9 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
return DECODE_ERROR_OUT_OF_MEMORY;
}
if (!this->jpeg_.decode(0, 0, 0)) {
auto error = this->jpeg_.getLastError();
ESP_LOGE(TAG, "Error while decoding: %d", error);
ESP_LOGE(TAG, "Error while decoding.");
this->jpeg_.close();
switch (error) {
case JPEG_ERROR_MEMORY:
return DECODE_ERROR_OUT_OF_MEMORY;
case JPEG_UNSUPPORTED_FEATURE:
return DECODE_ERROR_UNSUPPORTED_FORMAT;
case JPEG_INVALID_FILE:
case JPEG_INVALID_PARAMETER:
return DECODE_ERROR_INVALID_TYPE;
case JPEG_DECODE_ERROR:
default:
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
}
return DECODE_ERROR_UNSUPPORTED_FORMAT;
}
this->decoded_bytes_ = size;
this->jpeg_.close();

View File

@@ -95,7 +95,6 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) {
auto fed = pngle_feed(this->pngle_, buffer, size);
if (fed < 0) {
ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_));
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
} else {
this->decoded_bytes_ += fed;
}

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

@@ -1,4 +1,4 @@
from dataclasses import dataclass, field
from dataclasses import dataclass
from esphome import automation
import esphome.codegen as cg
@@ -6,13 +6,9 @@ from esphome.components import esp32, network, psram, socket, wifi
import esphome.config_validation as cv
from esphome.const import (
CONF_BUFFER_SIZE,
CONF_FORMAT,
CONF_HEIGHT,
CONF_ID,
CONF_SAMPLE_RATE,
CONF_SOURCE,
CONF_TASK_STACK_IN_PSRAM,
CONF_WIDTH,
)
from esphome.core import CORE, ID
from esphome.cpp_generator import TemplateArgsType
@@ -25,7 +21,6 @@ DEPENDENCIES = ["network"]
DOMAIN = "sendspin"
CONF_SENDSPIN_ID = "sendspin_id"
CONF_SLOT = "slot"
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
CONF_FIXED_DELAY = "fixed_delay"
@@ -41,21 +36,9 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP")
SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True)
IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM")
IMAGE_SOURCE_ARTIST = SendspinImageSource.enum("ARTIST")
IMAGE_SOURCE_NONE = SendspinImageSource.enum("NONE")
# Library Structs
AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject")
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig")
ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference")
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
# preference for the player role's transfer buffers.
@@ -93,7 +76,6 @@ class SendspinConfiguration:
player_support: bool = False
visualizer_support: bool = False
artwork_preferences: list[ConfigType] = field(default_factory=list)
player_config: ConfigType | None = None
@@ -128,12 +110,6 @@ def request_visualizer_support() -> None:
_get_data().visualizer_support = True
def register_artwork_preference(config: ConfigType) -> None:
"""Register an artwork slot preference from an image subcomponent."""
request_artwork_support()
_get_data().artwork_preferences.append(config)
def register_player_config(config: ConfigType) -> None:
"""Register the player role config from the media source subcomponent."""
data = _get_data()
@@ -234,27 +210,6 @@ async def to_code(config: ConfigType) -> None:
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
if data.artwork_support:
cg.add_define("USE_SENDSPIN_ARTWORK", True)
preference_structs = [
cg.StructInitializer(
ImageSlotPreference,
("slot", pref[CONF_SLOT]),
("source", pref[CONF_SOURCE]),
("format", pref[CONF_FORMAT]),
("width", pref[CONF_WIDTH]),
("height", pref[CONF_HEIGHT]),
)
for pref in data.artwork_preferences
]
artwork_psram_stack = bool(config.get(CONF_TASK_STACK_IN_PSRAM))
artwork_config = cg.StructInitializer(
ArtworkRoleConfig,
("preferred_formats", preference_structs),
("psram_stack", artwork_psram_stack),
("priority", 2),
)
cg.add(var.set_artwork_config(artwork_config))
else:
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False)

View File

@@ -1,159 +0,0 @@
"""Sendspin generic_image platform."""
from esphome import automation
import esphome.codegen as cg
from esphome.components import runtime_image
from esphome.components.image import CONF_TRANSPARENCY, add_metadata
import esphome.config_validation as cv
from esphome.const import (
CONF_FORMAT,
CONF_HEIGHT,
CONF_ID,
CONF_RESIZE,
CONF_SOURCE,
CONF_TRIGGER_ID,
CONF_TYPE,
CONF_WIDTH,
)
from esphome.core import CORE
from esphome.types import ConfigType
from .. import (
CONF_SENDSPIN_ID,
CONF_SLOT,
IMAGE_FORMAT_BMP,
IMAGE_FORMAT_JPEG,
IMAGE_FORMAT_PNG,
IMAGE_SOURCE_ALBUM,
IMAGE_SOURCE_ARTIST,
IMAGE_SOURCE_NONE,
SendspinHub,
register_artwork_preference,
sendspin_ns,
)
AUTO_LOAD = ["runtime_image"]
CODEOWNERS = ["@kahrendt"]
DEPENDENCIES = ["sendspin"]
MAX_IMAGE_SLOTS = 4
_SLOT_COUNTER_KEY = "sendspin_image_slot_counter"
CONF_ON_IMAGE_DISPLAY = "on_image_display"
CONF_ON_IMAGE_ERROR = "on_image_error"
# Map runtime_image's format string to the sendspin library's SendspinImageFormat enum.
_FORMAT_TO_SENDSPIN_ENUM = {
"JPEG": IMAGE_FORMAT_JPEG,
"PNG": IMAGE_FORMAT_PNG,
"BMP": IMAGE_FORMAT_BMP,
}
IMAGE_SOURCES = {
"ALBUM": IMAGE_SOURCE_ALBUM,
"ARTIST": IMAGE_SOURCE_ARTIST,
"NONE": IMAGE_SOURCE_NONE,
}
SendspinImage = sendspin_ns.class_(
"SendspinImage",
runtime_image.RuntimeImage,
cg.Component,
)
SendspinImageDisplayTrigger = sendspin_ns.class_(
"SendspinImageDisplayTrigger", automation.Trigger.template()
)
SendspinImageErrorTrigger = sendspin_ns.class_(
"SendspinImageErrorTrigger", automation.Trigger.template()
)
def _assign_slot_and_register(config: ConfigType) -> ConfigType:
"""Auto-assign a slot, validate the max count, and register the artwork preference with the hub."""
current = CORE.data.get(_SLOT_COUNTER_KEY, 0)
if current >= MAX_IMAGE_SLOTS:
raise cv.Invalid(
f"Too many Sendspin generic_image components. Maximum is {MAX_IMAGE_SLOTS}."
)
CORE.data[_SLOT_COUNTER_KEY] = current + 1
config[CONF_SLOT] = current
width, height = config[CONF_RESIZE]
register_artwork_preference(
{
CONF_SLOT: current,
CONF_SOURCE: config[CONF_SOURCE],
CONF_FORMAT: _FORMAT_TO_SENDSPIN_ENUM[config[CONF_FORMAT]],
CONF_WIDTH: width,
CONF_HEIGHT: height,
}
)
return config
CONFIG_SCHEMA = cv.All(
runtime_image.runtime_image_schema(SendspinImage).extend(
{
cv.GenerateID(): cv.declare_id(SendspinImage),
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
cv.Required(CONF_RESIZE): cv.dimensions,
cv.Optional(CONF_SOURCE, default="ALBUM"): cv.enum(
IMAGE_SOURCES, upper=True
),
cv.Optional(CONF_ON_IMAGE_DISPLAY): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
SendspinImageDisplayTrigger
),
}
),
cv.Optional(CONF_ON_IMAGE_ERROR): automation.validate_automation(
{
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
SendspinImageErrorTrigger
),
}
),
}
),
runtime_image.validate_runtime_image_settings,
cv.only_on_esp32,
_assign_slot_and_register,
)
async def to_code(config: ConfigType) -> None:
settings = await runtime_image.process_runtime_image_config(config)
add_metadata(
config[CONF_ID],
settings.width,
settings.height,
config[CONF_TYPE],
config[CONF_TRANSPARENCY],
)
var = cg.new_Pvariable(
config[CONF_ID],
settings.width,
settings.height,
settings.format_enum,
settings.image_type_enum,
settings.transparent,
settings.byte_order_big_endian,
settings.placeholder,
)
await cg.register_component(var, config)
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
cg.add(var.set_slot(config[CONF_SLOT]))
cg.add(var.set_image_source(IMAGE_SOURCES[config[CONF_SOURCE]]))
for conf in config.get(CONF_ON_IMAGE_DISPLAY, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)
for conf in config.get(CONF_ON_IMAGE_ERROR, []):
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
await automation.build_automation(trigger, [], conf)

View File

@@ -1,66 +0,0 @@
#include "sendspin_generic_image.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
#include "esphome/core/log.h"
namespace esphome::sendspin_ {
static const char *const TAG = "sendspin.generic_image";
SendspinImage::SendspinImage(int fixed_width, int fixed_height, runtime_image::ImageFormat format,
image::ImageType type, image::Transparency transparency, bool is_big_endian,
image::Image *placeholder)
: runtime_image::RuntimeImage(format, type, transparency, placeholder, is_big_endian, fixed_width, fixed_height) {}
// THREAD CONTEXT: Main loop. The decode callback registered below fires on the artwork
// decode thread; the display and clear callbacks fire on the main loop.
void SendspinImage::setup() {
this->parent_->add_image_decode_callback(
[this](uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) {
if (slot == this->slot_)
this->on_decode_(data, length);
});
this->parent_->add_image_display_callback([this](uint8_t slot) {
if (slot == this->slot_)
this->on_display_();
});
this->parent_->add_image_clear_callback([this](uint8_t slot) {
if (slot == this->slot_)
this->on_clear_();
});
}
// THREAD CONTEXT: Dedicated artwork decode thread (via SendspinHub's decode callback).
// Decode synchronously into the back buffer; heavy CPU work is allowed here.
void SendspinImage::on_decode_(const uint8_t *data, size_t length) {
this->begin_decode(length);
size_t total_consumed = 0;
while (total_consumed < length) {
int consumed = this->feed_data(const_cast<uint8_t *>(data) + total_consumed, length - total_consumed);
if (consumed < 0) {
ESP_LOGE(TAG, "Error decoding image data at offset %zu", total_consumed);
this->image_error_callback_.call();
return;
}
total_consumed += consumed;
}
if (!this->end_decode()) {
ESP_LOGE(TAG, "Failed to finalize image after decoding");
this->image_error_callback_.call();
return;
}
}
// THREAD CONTEXT: Main loop (fired once the server display timestamp is reached)
void SendspinImage::on_display_() { this->image_display_callback_.call(); }
// THREAD CONTEXT: Main loop
void SendspinImage::on_clear_() {
this->release();
this->image_display_callback_.call();
}
} // namespace esphome::sendspin_
#endif

View File

@@ -1,63 +0,0 @@
#pragma once
#include "esphome/core/defines.h"
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
#include "esphome/components/runtime_image/runtime_image.h"
#include "esphome/components/sendspin/sendspin_hub.h"
#include "esphome/core/automation.h"
#include <sendspin/artwork_role.h>
namespace esphome::sendspin_ {
class SendspinImage : public SendspinChild, public runtime_image::RuntimeImage {
public:
SendspinImage(int fixed_width, int fixed_height, runtime_image::ImageFormat format, image::ImageType type,
image::Transparency transparency, bool is_big_endian = false, image::Image *placeholder = nullptr);
void setup() override;
template<typename F> void add_on_image_display_callback(F &&callback) {
this->image_display_callback_.add(std::forward<F>(callback));
}
template<typename F> void add_on_image_error_callback(F &&callback) {
this->image_error_callback_.add(std::forward<F>(callback));
}
void set_image_source(sendspin::SendspinImageSource source) { this->source_ = source; }
void set_slot(uint8_t slot) { this->slot_ = slot; }
protected:
// Artwork thread. Decodes encoded bytes synchronously; buffer is valid only for this call.
void on_decode_(const uint8_t *data, size_t length);
// Main loop thread. Trigger when art should be displayed.
void on_display_();
// Main loop thread. Releases the decoded image and refires the display trigger so listeners re-render.
void on_clear_();
LazyCallbackManager<void()> image_display_callback_{};
LazyCallbackManager<void()> image_error_callback_{};
sendspin::SendspinImageSource source_{sendspin::SendspinImageSource::ALBUM};
uint8_t slot_{0};
};
class SendspinImageDisplayTrigger : public Trigger<> {
public:
explicit SendspinImageDisplayTrigger(SendspinImage *parent) {
parent->add_on_image_display_callback([this]() { this->trigger(); });
}
};
class SendspinImageErrorTrigger : public Trigger<> {
public:
explicit SendspinImageErrorTrigger(SendspinImage *parent) {
parent->add_on_image_error_callback([this]() { this->trigger(); });
}
};
} // namespace esphome::sendspin_
#endif

View File

@@ -37,10 +37,6 @@ void SendspinHub::setup() {
this->client_->set_network_provider(this);
this->client_->set_persistence_provider(this);
#ifdef USE_SENDSPIN_ARTWORK
this->client_->add_artwork(this->artwork_config_).set_listener(this);
#endif
#ifdef USE_SENDSPIN_CONTROLLER
this->controller_role_ = &this->client_->add_controller();
this->controller_role_->set_listener(this);
@@ -176,20 +172,6 @@ std::optional<uint32_t> SendspinHub::load_last_server_hash() {
// --- Sendspin role specific methods/overrides ---
#ifdef USE_SENDSPIN_ARTWORK
// THREAD CONTEXT: Dedicated artwork decode thread; downstream callbacks run here too
void SendspinHub::on_image_decode(uint8_t slot, const uint8_t *data, size_t length,
sendspin::SendspinImageFormat format) {
this->artwork_image_decode_callbacks_.call(slot, data, length, format);
}
// THREAD CONTEXT: Main loop (fired from client_->loop() once the server display timestamp is reached)
void SendspinHub::on_image_display(uint8_t slot) { this->artwork_image_display_callbacks_.call(slot); }
// THREAD CONTEXT: Main loop (fired from client_->loop())
void SendspinHub::on_image_clear(uint8_t slot) { this->artwork_image_clear_callbacks_.call(slot); }
#endif
#ifdef USE_SENDSPIN_CONTROLLER
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components)
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,

View File

@@ -13,9 +13,6 @@
#include <sendspin/config.h>
#include <sendspin/types.h>
#ifdef USE_SENDSPIN_ARTWORK
#include <sendspin/artwork_role.h>
#endif
#ifdef USE_SENDSPIN_CONTROLLER
#include <sendspin/controller_role.h>
#endif
@@ -72,9 +69,6 @@ struct StaticDelayPref {
/// (for services the library pulls; e.g., persistence, network readiness).
/// - User -> library communication uses exposed functions on the client and role objects that the user calls.
class SendspinHub final : public Component,
#ifdef USE_SENDSPIN_ARTWORK
public sendspin::ArtworkRoleListener,
#endif
#ifdef USE_SENDSPIN_CONTROLLER
public sendspin::ControllerRoleListener,
#endif
@@ -127,20 +121,6 @@ class SendspinHub final : public Component,
// --- Sendspin role specific methods ---
#ifdef USE_SENDSPIN_ARTWORK
void set_artwork_config(const sendspin::ArtworkRoleConfig &config) { this->artwork_config_ = config; }
template<typename F> void add_image_decode_callback(F &&callback) {
this->artwork_image_decode_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_image_display_callback(F &&callback) {
this->artwork_image_display_callbacks_.add(std::forward<F>(callback));
}
template<typename F> void add_image_clear_callback(F &&callback) {
this->artwork_image_clear_callbacks_.add(std::forward<F>(callback));
}
#endif
#ifdef USE_SENDSPIN_CONTROLLER
void send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume = std::nullopt,
std::optional<bool> mute = std::nullopt);
@@ -191,23 +171,6 @@ class SendspinHub final : public Component,
// --- Sendspin role specific methods/overrides/member variables ---
#ifdef USE_SENDSPIN_ARTWORK
void on_image_decode(uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) override;
void on_image_display(uint8_t slot) override;
void on_image_clear(uint8_t slot) override;
sendspin::ArtworkRoleConfig artwork_config_{};
// Callback fan-out to child components; they filter by slot as needed.
// decode and display fire from the library's artwork thread; clear fires from the main loop.
CallbackManager<void(uint8_t, const uint8_t *, size_t, sendspin::SendspinImageFormat)>
artwork_image_decode_callbacks_{};
CallbackManager<void(uint8_t)> artwork_image_display_callbacks_{};
CallbackManager<void(uint8_t)> artwork_image_clear_callbacks_{};
#endif
#ifdef USE_SENDSPIN_CONTROLLER
sendspin::ControllerRole *controller_role_{nullptr};

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

@@ -6,13 +6,6 @@
namespace esphome::xpt2046 {
static constexpr uint8_t XPT_READ_Z1 = 0xB0;
static constexpr uint8_t XPT_READ_Z2 = 0xC0;
static constexpr uint8_t XPT_READ_X = 0xD0;
static constexpr uint8_t XPT_READ_Y = 0x90;
static constexpr uint8_t XPT_ADC_ON = 0x01;
static constexpr uint8_t XPT_VREF_ON = 0x02;
static const char *const TAG = "xpt2046";
void XPT2046Component::setup() {
@@ -27,7 +20,7 @@ void XPT2046Component::setup() {
this->attach_interrupt_(this->irq_pin_, gpio::INTERRUPT_FALLING_EDGE);
}
this->spi_setup();
this->read_adc_(XPT_READ_X); // ADC powerdown, enable PENIRQ pin
this->read_adc_(0xD0); // ADC powerdown, enable PENIRQ pin
}
void XPT2046Component::update_touches() {
@@ -36,22 +29,21 @@ void XPT2046Component::update_touches() {
enable();
int16_t touch_pressure_1 = this->read_adc_(XPT_READ_Z1 | XPT_ADC_ON);
int16_t touch_pressure_2 = this->read_adc_(XPT_READ_Z2 | XPT_ADC_ON);
int16_t touch_pressure_1 = this->read_adc_(0xB1 /* touch_pressure_1 */);
int16_t touch_pressure_2 = this->read_adc_(0xC1 /* touch_pressure_2 */);
z_raw = touch_pressure_1 + 0xfff - touch_pressure_2;
ESP_LOGVV(TAG, "Touchscreen Update z = %d", z_raw);
touch = (z_raw >= this->threshold_);
if (touch) {
read_adc_(XPT_READ_X | XPT_ADC_ON); // dummy X measure, 1st is always noisy
// make 3 x-y measurements
data[0] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON);
data[1] = this->read_adc_(XPT_READ_X | XPT_ADC_ON);
data[2] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON);
data[3] = this->read_adc_(XPT_READ_X | XPT_ADC_ON);
data[4] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON);
read_adc_(0xD1 /* X */); // dummy Y measure, 1st is always noisy
data[0] = this->read_adc_(0x91 /* Y */);
data[1] = this->read_adc_(0xD1 /* X */); // make 3 x-y measurements
data[2] = this->read_adc_(0x91 /* Y */);
data[3] = this->read_adc_(0xD1 /* X */);
data[4] = this->read_adc_(0x91 /* Y */);
}
data[5] = this->read_adc_(XPT_READ_X); // Last X touch power down
data[5] = this->read_adc_(0xD0 /* X */); // Last X touch power down
disable();
@@ -103,16 +95,15 @@ int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t v
return reta;
}
int16_t XPT2046Component::read_adc_(uint8_t ctrl) {
uint8_t data[3];
int16_t XPT2046Component::read_adc_(uint8_t ctrl) { // NOLINT
uint8_t data[2];
data[0] = ctrl;
data[1] = 0;
data[2] = 0;
this->write_byte(ctrl);
delay(1);
data[0] = this->read_byte();
data[1] = this->read_byte();
this->transfer_array(data, sizeof(data));
return ((data[1] << 8) | data[2]) >> 3;
return ((data[0] << 8) | data[1]) >> 3;
}
} // namespace esphome::xpt2046

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
pylint==4.0.5
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
ruff==0.15.17 # also change in .pre-commit-config.yaml when updating
ruff==0.15.16 # also change in .pre-commit-config.yaml when updating
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
pre-commit

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

@@ -104,44 +104,6 @@ def set_component_config() -> Callable[[str, Any], None]:
return setter
@pytest.fixture
def choose_variant_with_pins() -> Generator[Callable[[list], None]]:
"""Set the ESP32 variant to the first one on which all the given pins are valid.
For ESP32 only, since the other platforms do not have variants. The core
configuration must already have been set up for an ESP32 target.
Using local imports to avoid importing when ESP32 is not the target.
"""
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS
from esphome.components.esp32.gpio import validate_gpio_pin
from esphome.const import CONF_INPUT, CONF_OUTPUT
from esphome.pins import gpio_pin_schema
def chooser(pins: list) -> None:
for variant in VARIANTS:
try:
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
for pin in pins:
if pin is not None:
pin = gpio_pin_schema(
{
CONF_INPUT: True,
CONF_OUTPUT: True,
},
internal=True,
)(pin)
validate_gpio_pin(pin)
return
except cv.Invalid:
continue
raise cv.Invalid(
f"No compatible variant found for pins: {', '.join(map(str, pins))}"
)
yield chooser
@pytest.fixture
def component_fixture_path(request: pytest.FixtureRequest) -> Callable[[str], Path]:
"""Return a function to get absolute paths relative to the component's fixtures directory."""

View File

@@ -1,24 +0,0 @@
esphome:
name: test
esp32:
board: esp32dev
spi:
clk_pin: GPIO18
mosi_pin: GPIO19
display:
- platform: epaper_spi
id: epaper_display
model: ssd1677
dc_pin: GPIO21
busy_pin: GPIO22
reset_pin: GPIO23
cs_pin: GPIO5
enable_pin:
- GPIO25
- GPIO26
dimensions:
width: 200
height: 200

View File

@@ -1,156 +0,0 @@
"""Tests for display metadata created by the epaper_spi component."""
from collections.abc import Callable
from pathlib import Path
from typing import Any
from esphome import config_validation as cv
from esphome.components.display import get_all_display_metadata, get_display_metadata
from esphome.components.epaper_spi.display import CONFIG_SCHEMA
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.const import PlatformFramework
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
def _base_config(**overrides: Any) -> ConfigType:
"""Build a minimal valid ssd1677 config, allowing field overrides."""
config: ConfigType = {
"id": "test_display",
"model": "ssd1677",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
"dimensions": {"width": 200, "height": 300},
}
config.update(overrides)
return config
def test_metadata_dimensions_and_defaults(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Metadata picks up explicit dimensions and epaper_spi defaults."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = CONFIG_SCHEMA(_base_config())
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 200
assert meta.height == 300
# epaper_spi always reports full hardware rotation
assert meta.has_hardware_rotation is True
# epaper_spi does not declare a byte order
assert meta.byte_order is cv.UNDEFINED
assert meta.draw_rounding == 0
# no drawing methods configured -> no writer
assert meta.has_writer is False
def test_metadata_default_dimensions_from_model(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""A model with built-in dimensions reports those without explicit dimensions."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
# waveshare-4.26in is an ssd1677 derivative with default 800x480 dimensions
config = CONFIG_SCHEMA(
{
"id": "wave_display",
"model": "waveshare-4.26in",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
}
)
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 800
assert meta.height == 480
def test_metadata_has_writer_with_auto_clear(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""A display with auto_clear_enabled reports has_writer=True."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = CONFIG_SCHEMA(_base_config(auto_clear_enabled=True))
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.has_writer is True
def test_metadata_rotation_propagated(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""The configured rotation is stored in the metadata."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = CONFIG_SCHEMA(_base_config(rotation=90))
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.rotation == 90
def test_metadata_multiple_displays_independent(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Each display gets its own independent metadata entry."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
CONFIG_SCHEMA(_base_config(id="disp_a", dimensions={"width": 200, "height": 300}))
CONFIG_SCHEMA(_base_config(id="disp_b", dimensions={"width": 400, "height": 480}))
all_meta = get_all_display_metadata()
assert all_meta["disp_a"].width == 200
assert all_meta["disp_a"].height == 300
assert all_meta["disp_b"].width == 400
assert all_meta["disp_b"].height == 480
def test_metadata_via_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Full code generation registers metadata for the configured display."""
generate_main(component_config_path("enable_pin_test.yaml"))
all_meta = get_all_display_metadata()
assert len(all_meta) == 1
meta = next(iter(all_meta.values()))
# enable_pin_test.yaml: ssd1677 at 200x200
assert meta.width == 200
assert meta.height == 200
assert meta.has_hardware_rotation is True

View File

@@ -1,8 +1,6 @@
"""Tests for epaper_spi configuration validation."""
from collections.abc import Callable
from pathlib import Path
import re
from typing import Any
import pytest
@@ -13,13 +11,17 @@ from esphome.components.epaper_spi.display import (
FINAL_VALIDATE_SCHEMA,
MODELS,
)
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.const import (
CONF_BUSY_PIN,
CONF_CS_PIN,
CONF_DC_PIN,
CONF_DIMENSIONS,
CONF_ENABLE_PIN,
CONF_HEIGHT,
CONF_INIT_SEQUENCE,
CONF_RESET_PIN,
@@ -29,30 +31,6 @@ from esphome.const import (
from esphome.types import ConfigType
from tests.component_tests.types import SetCoreConfigCallable
# Pin options whose values must be valid on the chosen ESP32 variant.
_PIN_CONF_KEYS = (
CONF_CS_PIN,
CONF_DC_PIN,
CONF_RESET_PIN,
CONF_BUSY_PIN,
CONF_ENABLE_PIN,
)
def _pins_for(model: Any, config: ConfigType) -> list:
"""Collect every GPIO the config will actually use (model defaults or injected)."""
pins: list = []
for key in _PIN_CONF_KEYS:
# An injected value in the config takes precedence over the model default.
value = config[key] if key in config else model.get_default(key)
if not value: # get_default returns False for pins the model omits
continue
if isinstance(value, list):
pins.extend(value)
else:
pins.append(value)
return pins
def run_schema_validation(
config: ConfigType, with_final_validate: bool = False
@@ -112,20 +90,29 @@ def test_basic_configuration_errors(
def test_all_predefined_models(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
choose_variant_with_pins: Callable[[list], None],
) -> None:
"""Test all predefined epaper models validate successfully with appropriate defaults."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
# Test all models, providing default values where necessary
for name, model in MODELS.items():
# SEEED models are designed for ESP32-S3 hardware
if name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"):
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
else:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
config = {"model": name}
# Add ID field
@@ -154,10 +141,6 @@ def test_all_predefined_models(
if not model.get_default(CONF_CS_PIN):
config[CONF_CS_PIN] = 5
# Select an ESP32 variant on which all of this model's pins are valid
# (some models default to high-numbered pins only present on the S3).
choose_variant_with_pins(_pins_for(model, config))
run_schema_validation(config)
@@ -169,19 +152,27 @@ def test_individual_models(
model_name: str,
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
choose_variant_with_pins: Callable[[list], None],
) -> None:
"""Test each epaper model individually to ensure it validates correctly."""
model = MODELS[model_name]
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# SEEED models are designed for ESP32-S3 hardware
if model_name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"):
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={
KEY_BOARD: "esp32-s3-devkitc-1",
KEY_VARIANT: VARIANT_ESP32S3,
},
)
else:
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
model = MODELS[model_name]
config: dict[str, Any] = {"model": model_name, "id": "test_display"}
# Add required fields based on model defaults
@@ -204,10 +195,6 @@ def test_individual_models(
if not model.get_default(CONF_CS_PIN):
config[CONF_CS_PIN] = 5
# Select an ESP32 variant on which all of this model's pins are valid
# (some models default to high-numbered pins only present on the S3).
choose_variant_with_pins(_pins_for(model, config))
# This should not raise any exceptions
run_schema_validation(config)
@@ -355,102 +342,3 @@ def test_busy_pin_input_mode_ssd1677(
reset_pin_config = result[CONF_RESET_PIN]
assert "mode" in reset_pin_config
assert reset_pin_config["mode"]["output"] is True
def test_enable_pin_single(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Test that a single enable_pin is accepted and normalised to a list of output pins."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
result = run_schema_validation(
{
"id": "test_display",
"model": "ssd1677",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
"enable_pin": 25,
"dimensions": {
"width": 200,
"height": 200,
},
}
)
# A single pin is normalised to a list by cv.ensure_list
assert CONF_ENABLE_PIN in result
enable_pins = result[CONF_ENABLE_PIN]
assert isinstance(enable_pins, list)
assert len(enable_pins) == 1
# enable pins are configured as outputs
assert enable_pins[0]["mode"]["output"] is True
def test_enable_pin_multiple(
set_core_config: SetCoreConfigCallable,
set_component_config: Callable[[str, Any], None],
) -> None:
"""Test that a list of enable_pins is accepted."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
# Configure SPI component which is required by epaper_spi
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
result = run_schema_validation(
{
"id": "test_display",
"model": "ssd1677",
"dc_pin": 21,
"busy_pin": 22,
"reset_pin": 23,
"cs_pin": 5,
"enable_pin": [25, 26],
"dimensions": {
"width": 200,
"height": 200,
},
}
)
assert CONF_ENABLE_PIN in result
enable_pins = result[CONF_ENABLE_PIN]
assert isinstance(enable_pins, list)
assert len(enable_pins) == 2
assert all(pin["mode"]["output"] is True for pin in enable_pins)
def test_enable_pin_code_generation(
generate_main: Callable[[str | Path], str],
component_config_path: Callable[[str], Path],
) -> None:
"""Test that enable_pins are wired up in the generated C++ code."""
main_cpp = generate_main(component_config_path("enable_pin_test.yaml"))
# Derive the auto-generated pin variable names from the set_pin() lines
# rather than hard-coding them, so the test does not break when unrelated
# codegen details shift the generated IDs.
def pin_var_for(gpio_num: int) -> str:
match = re.search(rf"(\w+)->set_pin\(::GPIO_NUM_{gpio_num}\);", main_cpp)
assert match is not None, (
f"GPIO_NUM_{gpio_num} pin not set up in generated code"
)
return match.group(1)
pin_25 = pin_var_for(25)
pin_26 = pin_var_for(26)
# Both pin objects must be passed to the display via set_enable_pins() as a
# std::vector initializer list, in the configured order.
assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp

View File

@@ -1,10 +1,16 @@
"""Tests for mpip_spi configuration validation."""
from collections.abc import Callable, Generator
from unittest import mock
import pytest
# choose_variant_with_pins is provided by the shared parent conftest.
from esphome import config_validation as cv
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS
from esphome.components.esp32.gpio import validate_gpio_pin
from esphome.const import CONF_INPUT, CONF_OUTPUT
from esphome.core import CORE
from esphome.pins import gpio_pin_schema
@pytest.fixture(autouse=True)
@@ -15,3 +21,34 @@ def mock_spi_final_validate():
return_value=lambda config: None,
):
yield
@pytest.fixture
def choose_variant_with_pins() -> Generator[Callable[[list], None]]:
"""
Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms
do not have variants.
"""
def chooser(pins: list) -> None:
for variant in VARIANTS:
try:
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
for pin in pins:
if pin is not None:
pin = gpio_pin_schema(
{
CONF_INPUT: True,
CONF_OUTPUT: True,
},
internal=True,
)(pin)
validate_gpio_pin(pin)
return
except cv.Invalid:
continue
raise cv.Invalid(
f"No compatible variant found for pins: {', '.join(map(str, pins))}"
)
yield chooser

View File

@@ -1,35 +0,0 @@
<<: !include common.yaml
packages:
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
display:
- platform: ili9xxx
spi_id: spi_bus
id: main_lcd
model: ili9342
cs_pin: 20
dc_pin: 13
reset_pin: 21
invert_colors: true
lambda: |-
it.fill(Color(0, 0, 0));
it.image(0, 0, id(album_art));
generic_image:
- platform: sendspin
id: album_art
format: JPEG
type: RGB565
resize: 240x240
source: ALBUM
on_image_display:
- logger.log: "Album art displayed"
on_image_error:
- logger.log: "Album art error"
- platform: sendspin
id: artist_art
format: PNG
type: RGB565
resize: 96x96
source: ARTIST

View File

@@ -1 +0,0 @@
<<: !include common-generic_image.yaml

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

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

View File

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

View File

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

View File

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

View File

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

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