mirror of
https://github.com/esphome/esphome.git
synced 2026-07-09 16:35:35 +00:00
Compare commits
86 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bfbaaebf3 | |||
| 98b79b132a | |||
| f6221f0007 | |||
| 263b375088 | |||
| a1f819e9b8 | |||
| ca7f50f37f | |||
| 7b92fe95af | |||
| 42ddf0870c | |||
| 0512dd2339 | |||
| 1fc73731a8 | |||
| e47feace11 | |||
| 4472d3b61b | |||
| 06c5bcbc66 | |||
| 6c44775bf5 | |||
| b127363fa0 | |||
| 782b58bbeb | |||
| 5de508ad8c | |||
| 054c8ba485 | |||
| 3b2be021b2 | |||
| 0cbbd64577 | |||
| a618ee11b4 | |||
| 6251c26cc6 | |||
| 4fbe0d87ec | |||
| 24d8e99c50 | |||
| 14b6a0ede1 | |||
| 1793ca5eac | |||
| 62e19bcb27 | |||
| 84d1c34c28 | |||
| f78cbf9200 | |||
| eb711381d3 | |||
| 9a1daa5247 | |||
| f3d61ca3e1 | |||
| 29dfd820c6 | |||
| 8bc5b97298 | |||
| 7a64163c4f | |||
| dfe14f9c3a | |||
| 26cf373ae7 | |||
| 94ccddf176 | |||
| 2ec24505d0 | |||
| 4f7faa7712 | |||
| b3dcaac262 | |||
| ee118d384a | |||
| 8d36167e11 | |||
| 6d559a32df | |||
| bf0d31b3ab | |||
| d8ffb732b7 | |||
| 9ab2a573ab | |||
| 99d1c4eb69 | |||
| b079be756f | |||
| 039a1f063e | |||
| 2354165e41 | |||
| f5697b0ae5 | |||
| fe794a26e8 | |||
| 8d77051b9a | |||
| 9534ab2a19 | |||
| 1b1c8d767d | |||
| e3d68deef9 | |||
| 20cd6a1771 | |||
| d27229a1c7 | |||
| 129aebe8f4 | |||
| a84ad7b1f8 | |||
| 86096b96f5 | |||
| ac5a28301a | |||
| e2157a3d26 | |||
| d934fb3910 | |||
| c4076ec8a9 | |||
| 9ac22f9244 | |||
| 9e7b3e0330 | |||
| 2abe272867 | |||
| db6b9166f4 | |||
| 7ab95ddcb1 | |||
| cdd2bfbc60 | |||
| 41f7f8cccb | |||
| 045de436ba | |||
| 24e276c3f9 | |||
| 9e768bb510 | |||
| 53fd99578a | |||
| 310baab524 | |||
| 0422b581cb | |||
| 0ce89c17ab | |||
| 66be793cd8 | |||
| 1d38498ca7 | |||
| aef9b5b72f | |||
| 9bf35ab8fb | |||
| 33ace9d698 | |||
| 32ab3abd7c |
+1
-1
@@ -1 +1 @@
|
||||
a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c
|
||||
72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.6.0b2
|
||||
PROJECT_NUMBER = 2026.6.5
|
||||
|
||||
# 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
|
||||
|
||||
+9
-11
@@ -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.1.0
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
&& platformio settings set check_platformio_interval 1000000 \
|
||||
|
||||
+42
-13
@@ -20,6 +20,10 @@ TYPE_HA_ADDON = "ha-addon"
|
||||
TYPE_LINT = "lint"
|
||||
TYPES = [TYPE_DOCKER, TYPE_HA_ADDON, TYPE_LINT]
|
||||
|
||||
REGISTRY_GHCR = "ghcr"
|
||||
REGISTRY_DOCKERHUB = "dockerhub"
|
||||
REGISTRIES = [REGISTRY_GHCR, REGISTRY_DOCKERHUB]
|
||||
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
@@ -34,6 +38,12 @@ parser.add_argument(
|
||||
parser.add_argument(
|
||||
"--build-type", choices=TYPES, required=True, help="The type of build to run"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--registry",
|
||||
choices=REGISTRIES,
|
||||
action="append",
|
||||
help="Restrict to specific registries (default: all). May be passed multiple times.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run", action="store_true", help="Don't run any commands, just print them"
|
||||
)
|
||||
@@ -45,6 +55,11 @@ build_parser.add_argument("--push", help="Also push the images", action="store_t
|
||||
build_parser.add_argument(
|
||||
"--load", help="Load the docker image locally", action="store_true"
|
||||
)
|
||||
build_parser.add_argument(
|
||||
"--no-cache-to",
|
||||
help="Don't write the build cache (avoids polluting the shared cache)",
|
||||
action="store_true",
|
||||
)
|
||||
manifest_parser = subparsers.add_parser(
|
||||
"manifest", help="Create a manifest from already pushed images"
|
||||
)
|
||||
@@ -95,11 +110,14 @@ def main():
|
||||
print("Command failed")
|
||||
sys.exit(1)
|
||||
|
||||
registries = args.registry or REGISTRIES
|
||||
|
||||
# detect channel from tag
|
||||
match = re.match(r"^(\d+\.\d+)(?:\.\d+)?(b\d+)?$", args.tag)
|
||||
major_minor_version = None
|
||||
if match is None:
|
||||
channel = CHANNEL_DEV
|
||||
# Custom tag (e.g. a branch name) -- push only the tag itself
|
||||
channel = None
|
||||
elif match.group(2) is None:
|
||||
major_minor_version = match.group(1)
|
||||
channel = CHANNEL_RELEASE
|
||||
@@ -128,11 +146,18 @@ def main():
|
||||
CHANNEL_DEV: "cache-dev",
|
||||
CHANNEL_BETA: "cache-beta",
|
||||
CHANNEL_RELEASE: "cache-latest",
|
||||
}[channel]
|
||||
cache_img = f"ghcr.io/{params.build_to}:{cache_tag}"
|
||||
}.get(channel, "cache-dev")
|
||||
# Cache images live alongside the pushed images; prefer GHCR when it is
|
||||
# one of the selected registries, otherwise fall back to Docker Hub so a
|
||||
# registry-restricted build doesn't need GHCR auth.
|
||||
cache_prefix = "ghcr.io/" if REGISTRY_GHCR in registries else ""
|
||||
cache_img = f"{cache_prefix}{params.build_to}:{cache_tag}"
|
||||
|
||||
imgs = [f"{params.build_to}:{tag}" for tag in tags_to_push]
|
||||
imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push]
|
||||
imgs = []
|
||||
if REGISTRY_DOCKERHUB in registries:
|
||||
imgs += [f"{params.build_to}:{tag}" for tag in tags_to_push]
|
||||
if REGISTRY_GHCR in registries:
|
||||
imgs += [f"ghcr.io/{params.build_to}:{tag}" for tag in tags_to_push]
|
||||
|
||||
# 3. build
|
||||
cmd = [
|
||||
@@ -155,7 +180,9 @@ def main():
|
||||
for img in imgs:
|
||||
cmd += ["--tag", img]
|
||||
if args.push:
|
||||
cmd += ["--push", "--cache-to", f"type=registry,ref={cache_img},mode=max"]
|
||||
cmd += ["--push"]
|
||||
if not args.no_cache_to:
|
||||
cmd += ["--cache-to", f"type=registry,ref={cache_img},mode=max"]
|
||||
if args.load:
|
||||
cmd += ["--load"]
|
||||
|
||||
@@ -163,20 +190,22 @@ def main():
|
||||
elif args.command == "manifest":
|
||||
manifest = DockerParams.for_type_arch(args.build_type, ARCH_AMD64).manifest_to
|
||||
|
||||
targets = [f"{manifest}:{tag}" for tag in tags_to_push]
|
||||
targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push]
|
||||
# 1. Create manifests
|
||||
targets = []
|
||||
if REGISTRY_DOCKERHUB in registries:
|
||||
targets += [f"{manifest}:{tag}" for tag in tags_to_push]
|
||||
if REGISTRY_GHCR in registries:
|
||||
targets += [f"ghcr.io/{manifest}:{tag}" for tag in tags_to_push]
|
||||
# Use buildx imagetools (not `docker manifest`) so the per-arch sources,
|
||||
# which buildx pushes as single-platform manifest lists, are combined
|
||||
# and pushed correctly in one step.
|
||||
for target in targets:
|
||||
cmd = ["docker", "manifest", "create", target]
|
||||
cmd = ["docker", "buildx", "imagetools", "create", "--tag", target]
|
||||
for arch in ARCHS:
|
||||
src = f"{DockerParams.for_type_arch(args.build_type, arch).build_to}:{args.tag}"
|
||||
if target.startswith("ghcr.io"):
|
||||
src = f"ghcr.io/{src}"
|
||||
cmd.append(src)
|
||||
run_command(*cmd)
|
||||
# 2. Push manifests
|
||||
for target in targets:
|
||||
run_command("docker", "manifest", "push", target)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -27,4 +27,12 @@ if [[ -d /build ]]; then
|
||||
export ESPHOME_BUILD_PATH=/build
|
||||
fi
|
||||
|
||||
# The default CMD is "dashboard /config". Route the dashboard to the new
|
||||
# Device Builder, but pass every other subcommand (compile, run, config,
|
||||
# logs, ...) straight through to the esphome CLI so direct CLI use keeps working.
|
||||
if [[ "$1" == "dashboard" ]]; then
|
||||
shift
|
||||
exec esphome-device-builder "$@"
|
||||
fi
|
||||
|
||||
exec esphome "$@"
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/with-contenv bashio
|
||||
# ==============================================================================
|
||||
# Installs the latest prerelease of esphome-device-builder when the
|
||||
# `use_new_device_builder` config option is enabled.
|
||||
# This is a temporary install-on-boot step until esphome-device-builder
|
||||
# becomes a direct dependency of esphome.
|
||||
# ==============================================================================
|
||||
|
||||
if ! bashio::config.true 'use_new_device_builder'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
bashio::log.info "Installing latest prerelease of esphome-device-builder..."
|
||||
if command -v uv > /dev/null; then
|
||||
uv pip install --system --no-cache-dir --prerelease=allow --upgrade \
|
||||
esphome-device-builder ||
|
||||
bashio::exit.nok "Failed installing esphome-device-builder."
|
||||
else
|
||||
pip install --no-cache-dir --pre --upgrade esphome-device-builder ||
|
||||
bashio::exit.nok "Failed installing esphome-device-builder."
|
||||
fi
|
||||
bashio::log.info "Installed esphome-device-builder."
|
||||
@@ -1,96 +0,0 @@
|
||||
types {
|
||||
text/html html htm shtml;
|
||||
text/css css;
|
||||
text/xml xml;
|
||||
image/gif gif;
|
||||
image/jpeg jpeg jpg;
|
||||
application/javascript js;
|
||||
application/atom+xml atom;
|
||||
application/rss+xml rss;
|
||||
|
||||
text/mathml mml;
|
||||
text/plain txt;
|
||||
text/vnd.sun.j2me.app-descriptor jad;
|
||||
text/vnd.wap.wml wml;
|
||||
text/x-component htc;
|
||||
|
||||
image/png png;
|
||||
image/svg+xml svg svgz;
|
||||
image/tiff tif tiff;
|
||||
image/vnd.wap.wbmp wbmp;
|
||||
image/webp webp;
|
||||
image/x-icon ico;
|
||||
image/x-jng jng;
|
||||
image/x-ms-bmp bmp;
|
||||
|
||||
font/woff woff;
|
||||
font/woff2 woff2;
|
||||
|
||||
application/java-archive jar war ear;
|
||||
application/json json;
|
||||
application/mac-binhex40 hqx;
|
||||
application/msword doc;
|
||||
application/pdf pdf;
|
||||
application/postscript ps eps ai;
|
||||
application/rtf rtf;
|
||||
application/vnd.apple.mpegurl m3u8;
|
||||
application/vnd.google-earth.kml+xml kml;
|
||||
application/vnd.google-earth.kmz kmz;
|
||||
application/vnd.ms-excel xls;
|
||||
application/vnd.ms-fontobject eot;
|
||||
application/vnd.ms-powerpoint ppt;
|
||||
application/vnd.oasis.opendocument.graphics odg;
|
||||
application/vnd.oasis.opendocument.presentation odp;
|
||||
application/vnd.oasis.opendocument.spreadsheet ods;
|
||||
application/vnd.oasis.opendocument.text odt;
|
||||
application/vnd.openxmlformats-officedocument.presentationml.presentation
|
||||
pptx;
|
||||
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
||||
xlsx;
|
||||
application/vnd.openxmlformats-officedocument.wordprocessingml.document
|
||||
docx;
|
||||
application/vnd.wap.wmlc wmlc;
|
||||
application/x-7z-compressed 7z;
|
||||
application/x-cocoa cco;
|
||||
application/x-java-archive-diff jardiff;
|
||||
application/x-java-jnlp-file jnlp;
|
||||
application/x-makeself run;
|
||||
application/x-perl pl pm;
|
||||
application/x-pilot prc pdb;
|
||||
application/x-rar-compressed rar;
|
||||
application/x-redhat-package-manager rpm;
|
||||
application/x-sea sea;
|
||||
application/x-shockwave-flash swf;
|
||||
application/x-stuffit sit;
|
||||
application/x-tcl tcl tk;
|
||||
application/x-x509-ca-cert der pem crt;
|
||||
application/x-xpinstall xpi;
|
||||
application/xhtml+xml xhtml;
|
||||
application/xspf+xml xspf;
|
||||
application/zip zip;
|
||||
|
||||
application/octet-stream bin exe dll;
|
||||
application/octet-stream deb;
|
||||
application/octet-stream dmg;
|
||||
application/octet-stream iso img;
|
||||
application/octet-stream msi msp msm;
|
||||
|
||||
audio/midi mid midi kar;
|
||||
audio/mpeg mp3;
|
||||
audio/ogg ogg;
|
||||
audio/x-m4a m4a;
|
||||
audio/x-realaudio ra;
|
||||
|
||||
video/3gpp 3gpp 3gp;
|
||||
video/mp2t ts;
|
||||
video/mp4 mp4;
|
||||
video/mpeg mpeg mpg;
|
||||
video/quicktime mov;
|
||||
video/webm webm;
|
||||
video/x-flv flv;
|
||||
video/x-m4v m4v;
|
||||
video/x-mng mng;
|
||||
video/x-ms-asf asx asf;
|
||||
video/x-ms-wmv wmv;
|
||||
video/x-msvideo avi;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
proxy_http_version 1.1;
|
||||
proxy_ignore_client_abort off;
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_redirect off;
|
||||
proxy_send_timeout 86400s;
|
||||
proxy_max_temp_file_size 0;
|
||||
|
||||
proxy_set_header Accept-Encoding "";
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-NginX-Proxy true;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header Authorization "";
|
||||
@@ -1,8 +0,0 @@
|
||||
root /dev/null;
|
||||
server_name $hostname;
|
||||
|
||||
client_max_body_size 512m;
|
||||
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header X-Robots-Tag none;
|
||||
@@ -1,8 +0,0 @@
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_prefer_server_ciphers off;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_session_timeout 10m;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_tickets off;
|
||||
ssl_stapling on;
|
||||
ssl_stapling_verify on;
|
||||
@@ -1,3 +0,0 @@
|
||||
upstream esphome {
|
||||
server unix:/var/run/esphome.sock;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
daemon off;
|
||||
user root;
|
||||
pid /var/run/nginx.pid;
|
||||
worker_processes 1;
|
||||
error_log /proc/1/fd/1 error;
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/includes/mime.types;
|
||||
|
||||
access_log off;
|
||||
default_type application/octet-stream;
|
||||
gzip on;
|
||||
keepalive_timeout 65;
|
||||
sendfile on;
|
||||
server_tokens off;
|
||||
|
||||
tcp_nodelay on;
|
||||
tcp_nopush on;
|
||||
|
||||
map $http_upgrade $connection_upgrade {
|
||||
default upgrade;
|
||||
'' close;
|
||||
}
|
||||
|
||||
include /etc/nginx/includes/upstream.conf;
|
||||
include /etc/nginx/servers/*.conf;
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Without requirements or design, programming is the art of adding bugs to an empty text file. (Louis Srygley)
|
||||
@@ -1,28 +0,0 @@
|
||||
server {
|
||||
{{ if not .ssl }}
|
||||
listen 6052 default_server;
|
||||
{{ else }}
|
||||
listen 6052 default_server ssl http2;
|
||||
{{ end }}
|
||||
|
||||
include /etc/nginx/includes/server_params.conf;
|
||||
include /etc/nginx/includes/proxy_params.conf;
|
||||
|
||||
{{ if .ssl }}
|
||||
include /etc/nginx/includes/ssl_params.conf;
|
||||
|
||||
ssl_certificate /ssl/{{ .certfile }};
|
||||
ssl_certificate_key /ssl/{{ .keyfile }};
|
||||
|
||||
# Redirect http requests to https on the same port.
|
||||
# https://rageagainstshell.com/2016/11/redirect-http-to-https-on-the-same-port-in-nginx/
|
||||
error_page 497 https://$http_host$request_uri;
|
||||
{{ end }}
|
||||
|
||||
# Clear Home Assistant Ingress header
|
||||
proxy_set_header X-HA-Ingress "";
|
||||
|
||||
location / {
|
||||
proxy_pass http://esphome;
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
server {
|
||||
listen 127.0.0.1:{{ .port }} default_server;
|
||||
listen {{ .interface }}:{{ .port }} default_server;
|
||||
|
||||
include /etc/nginx/includes/server_params.conf;
|
||||
include /etc/nginx/includes/proxy_params.conf;
|
||||
|
||||
# Set Home Assistant Ingress header
|
||||
proxy_set_header X-HA-Ingress "YES";
|
||||
|
||||
location / {
|
||||
allow 172.30.32.2;
|
||||
allow 127.0.0.1;
|
||||
deny all;
|
||||
|
||||
proxy_pass http://esphome;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ fi
|
||||
|
||||
port=$(bashio::addon.ingress_port)
|
||||
|
||||
# Wait for NGINX to become available
|
||||
# Wait for the ESPHome Device Builder to become available
|
||||
bashio::net.wait_for "${port}" "127.0.0.1" 300
|
||||
|
||||
config=$(\
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# shellcheck shell=bash
|
||||
# ==============================================================================
|
||||
# Home Assistant Community Add-on: ESPHome
|
||||
# Take down the S6 supervision tree when ESPHome dashboard fails
|
||||
# Take down the S6 supervision tree when ESPHome Device Builder fails
|
||||
# ==============================================================================
|
||||
declare exit_code
|
||||
readonly exit_code_container=$(</run/s6-linux-init-container-results/exitcode)
|
||||
@@ -10,7 +10,7 @@ readonly exit_code_service="${1}"
|
||||
readonly exit_code_signal="${2}"
|
||||
|
||||
bashio::log.info \
|
||||
"Service ESPHome dashboard exited with code ${exit_code_service}" \
|
||||
"Service ESPHome Device Builder exited with code ${exit_code_service}" \
|
||||
"(by signal ${exit_code_signal})"
|
||||
|
||||
if [[ "${exit_code_service}" -eq 256 ]]; then
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# shellcheck shell=bash
|
||||
# ==============================================================================
|
||||
# Community Hass.io Add-ons: ESPHome
|
||||
# Runs the ESPHome dashboard
|
||||
# Runs the ESPHome Device Builder
|
||||
# ==============================================================================
|
||||
readonly pio_cache_base=/data/cache/platformio
|
||||
|
||||
@@ -49,12 +49,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)" \
|
||||
"$@"
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/command/with-contenv bashio
|
||||
# shellcheck shell=bash
|
||||
# ==============================================================================
|
||||
# Community Hass.io Add-ons: ESPHome
|
||||
# Configures NGINX for use with ESPHome
|
||||
# ==============================================================================
|
||||
|
||||
# When the new device builder is enabled it serves HA ingress directly,
|
||||
# so nginx is not used at all -- skip configuration.
|
||||
if bashio::config.true 'use_new_device_builder'; then
|
||||
bashio::log.info "Skipping NGINX setup: new device builder serves ingress directly."
|
||||
bashio::exit.ok
|
||||
fi
|
||||
|
||||
mkdir -p /var/log/nginx
|
||||
|
||||
# Generate Ingress configuration
|
||||
bashio::var.json \
|
||||
interface "$(bashio::addon.ip_address)" \
|
||||
port "^$(bashio::addon.ingress_port)" \
|
||||
| tempio \
|
||||
-template /etc/nginx/templates/ingress.gtpl \
|
||||
-out /etc/nginx/servers/ingress.conf
|
||||
|
||||
# Generate direct access configuration, if enabled.
|
||||
if bashio::var.has_value "$(bashio::addon.port 6052)"; then
|
||||
bashio::config.require.ssl
|
||||
bashio::var.json \
|
||||
certfile "$(bashio::config 'certfile')" \
|
||||
keyfile "$(bashio::config 'keyfile')" \
|
||||
ssl "^$(bashio::config 'ssl')" \
|
||||
| tempio \
|
||||
-template /etc/nginx/templates/direct.gtpl \
|
||||
-out /etc/nginx/servers/direct.conf
|
||||
fi
|
||||
@@ -1 +0,0 @@
|
||||
oneshot
|
||||
@@ -1 +0,0 @@
|
||||
/etc/s6-overlay/s6-rc.d/init-nginx/run
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/command/with-contenv bashio
|
||||
# ==============================================================================
|
||||
# Community Hass.io Add-ons: ESPHome
|
||||
# Take down the S6 supervision tree when NGINX fails
|
||||
# ==============================================================================
|
||||
declare exit_code
|
||||
readonly exit_code_container=$(</run/s6-linux-init-container-results/exitcode)
|
||||
readonly exit_code_service="${1}"
|
||||
readonly exit_code_signal="${2}"
|
||||
|
||||
bashio::log.info \
|
||||
"Service NGINX exited with code ${exit_code_service}" \
|
||||
"(by signal ${exit_code_signal})"
|
||||
|
||||
if [[ "${exit_code_service}" -eq 256 ]]; then
|
||||
if [[ "${exit_code_container}" -eq 0 ]]; then
|
||||
echo $((128 + $exit_code_signal)) > /run/s6-linux-init-container-results/exitcode
|
||||
fi
|
||||
[[ "${exit_code_signal}" -eq 15 ]] && exec /run/s6/basedir/bin/halt
|
||||
elif [[ "${exit_code_service}" -ne 0 ]]; then
|
||||
if [[ "${exit_code_container}" -eq 0 ]]; then
|
||||
echo "${exit_code_service}" > /run/s6-linux-init-container-results/exitcode
|
||||
fi
|
||||
exec /run/s6/basedir/bin/halt
|
||||
fi
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/command/with-contenv bashio
|
||||
# shellcheck shell=bash
|
||||
# ==============================================================================
|
||||
# Community Hass.io Add-ons: ESPHome
|
||||
# Runs the NGINX proxy
|
||||
# ==============================================================================
|
||||
|
||||
# The new device builder handles HA ingress itself, so nginx is bypassed.
|
||||
# Block the longrun so s6 keeps the dependency satisfied, but exit 0 on
|
||||
# SIGTERM instead of being signal-killed; a 256/15 exit makes nginx/finish
|
||||
# stamp the container exit 143, which trips the Supervisor's SIGTERM check.
|
||||
if bashio::config.true 'use_new_device_builder'; then
|
||||
bashio::log.info "NGINX bypassed: new device builder serves ingress directly."
|
||||
trap 'exit 0' TERM
|
||||
sleep infinity &
|
||||
wait
|
||||
exit 0
|
||||
fi
|
||||
|
||||
bashio::log.info "Waiting for ESPHome dashboard to come up..."
|
||||
|
||||
while [[ ! -S /var/run/esphome.sock ]]; do
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
bashio::log.info "Starting NGINX..."
|
||||
exec nginx
|
||||
@@ -1 +0,0 @@
|
||||
longrun
|
||||
+43
-5
@@ -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
|
||||
|
||||
@@ -1464,12 +1470,29 @@ _LEGACY_REDACTION_REMOVAL = "2026.12.0"
|
||||
|
||||
def _redact_with_legacy_fallback(output: str) -> str:
|
||||
unmarked: set[str] = set()
|
||||
# Track the top-level ``substitutions:`` block. Its keys are arbitrary
|
||||
# user-chosen names with no schema validator, so the ``cv.sensitive(...)``
|
||||
# migration named in the warning can't be applied to them. Their values are
|
||||
# still redacted, but emitting the (unactionable) deprecation warning would
|
||||
# only confuse users.
|
||||
in_substitutions = False
|
||||
|
||||
def _replace(m: re.Match[str]) -> str:
|
||||
unmarked.add(m.group("key"))
|
||||
return f"{m.group('key')}: \\033[8m{m.group('val')}\\033[28m"
|
||||
|
||||
output = _LEGACY_REDACTION_RE.sub(_replace, output)
|
||||
lines = output.split("\n")
|
||||
for i, line in enumerate(lines):
|
||||
# A non-indented, non-blank line is a top-level key that opens or
|
||||
# closes the substitutions block.
|
||||
if line and not line[0].isspace():
|
||||
in_substitutions = line.startswith(f"{CONF_SUBSTITUTIONS}:")
|
||||
m = _LEGACY_REDACTION_RE.search(line)
|
||||
if m is None:
|
||||
continue
|
||||
if not in_substitutions:
|
||||
unmarked.add(m.group("key"))
|
||||
lines[i] = (
|
||||
f"{line[: m.start()]}{m.group('key')}: "
|
||||
f"\\033[8m{m.group('val')}\\033[28m{line[m.end() :]}"
|
||||
)
|
||||
output = "\n".join(lines)
|
||||
for key in sorted(unmarked):
|
||||
_LOGGER.warning(
|
||||
"Field '%s' is being redacted by a legacy substring heuristic. "
|
||||
@@ -1765,6 +1788,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",
|
||||
|
||||
@@ -66,15 +66,18 @@ float ADCSensor::sample() {
|
||||
}
|
||||
|
||||
uint8_t pin = this->pin_->get_pin();
|
||||
#ifdef CYW43_USES_VSYS_PIN
|
||||
#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
if (pin == PICO_VSYS_PIN) {
|
||||
// Measuring VSYS on Raspberry Pico W needs to be wrapped with
|
||||
// `cyw43_thread_enter()`/`cyw43_thread_exit()` as discussed in
|
||||
// https://github.com/raspberrypi/pico-sdk/issues/1222, since Wifi chip and
|
||||
// VSYS ADC both share GPIO29
|
||||
// VSYS ADC both share GPIO29.
|
||||
// The USE_WIFI guard is required because CYW43_USES_VSYS_PIN can be defined
|
||||
// transitively (e.g. via lwip_wrap.h) even on non-WiFi boards where the CYW43
|
||||
// driver is never initialized; calling cyw43_thread_enter() there hard-faults.
|
||||
cyw43_thread_enter();
|
||||
}
|
||||
#endif // CYW43_USES_VSYS_PIN
|
||||
#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
|
||||
adc_gpio_init(pin);
|
||||
adc_select_input(pin - 26);
|
||||
@@ -84,11 +87,11 @@ float ADCSensor::sample() {
|
||||
aggr.add_sample(raw);
|
||||
}
|
||||
|
||||
#ifdef CYW43_USES_VSYS_PIN
|
||||
#if defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
if (pin == PICO_VSYS_PIN) {
|
||||
cyw43_thread_exit();
|
||||
}
|
||||
#endif // CYW43_USES_VSYS_PIN
|
||||
#endif // defined(CYW43_USES_VSYS_PIN) && defined(USE_WIFI)
|
||||
|
||||
if (this->output_raw_) {
|
||||
return aggr.aggregate();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -68,11 +68,15 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener,
|
||||
void loop() override;
|
||||
esp32_ble_tracker::AdvertisementParserType get_advertisement_parser_type() override;
|
||||
|
||||
void register_connection(BluetoothConnection *connection) {
|
||||
// maybe_unused: in a passive proxy (active: false) MAX is 0, the body below is removed, and connection is unused.
|
||||
void register_connection([[maybe_unused]] BluetoothConnection *connection) {
|
||||
// Guard the always-false comparison (-Wtype-limits) in a passive proxy (active: false), where MAX is 0.
|
||||
#if BLUETOOTH_PROXY_MAX_CONNECTIONS > 0
|
||||
if (this->connection_count_ < BLUETOOTH_PROXY_MAX_CONNECTIONS) {
|
||||
this->connections_[this->connection_count_++] = connection;
|
||||
connection->proxy_ = this;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
void bluetooth_device_request(const api::BluetoothDeviceRequest &msg);
|
||||
|
||||
@@ -173,8 +173,14 @@ bool ES8388::set_mute_state_(bool mute_state) {
|
||||
ES8388_ERROR_CHECK(this->read_byte(ES8388_DACCONTROL3, &value));
|
||||
ESP_LOGV(TAG, "Read ES8388_DACCONTROL3: 0x%02X", value);
|
||||
|
||||
// Only toggle the DACMute bit; the other bits of this register hold unrelated
|
||||
// DAC settings that must be preserved. Previously muting overwrote the whole
|
||||
// register with 0x3C and unmuting never cleared the bit, so once muted the DAC
|
||||
// could not be unmuted again.
|
||||
if (mute_state) {
|
||||
value = 0x3C;
|
||||
value |= ES8388_DACCONTROL3_DAC_MUTE;
|
||||
} else {
|
||||
value &= ~ES8388_DACCONTROL3_DAC_MUTE;
|
||||
}
|
||||
|
||||
ESP_LOGV(TAG, "Setting ES8388_DACCONTROL3 to 0x%02X (muted: %s)", value, YESNO(mute_state));
|
||||
|
||||
@@ -38,6 +38,7 @@ static const uint8_t ES8388_ADCCONTROL14 = 0x16;
|
||||
static const uint8_t ES8388_DACCONTROL1 = 0x17;
|
||||
static const uint8_t ES8388_DACCONTROL2 = 0x18;
|
||||
static const uint8_t ES8388_DACCONTROL3 = 0x19;
|
||||
static const uint8_t ES8388_DACCONTROL3_DAC_MUTE = 0x04; // DACMute, bit 2 of DACCONTROL3
|
||||
static const uint8_t ES8388_DACCONTROL4 = 0x1a;
|
||||
static const uint8_t ES8388_DACCONTROL5 = 0x1b;
|
||||
static const uint8_t ES8388_DACCONTROL6 = 0x1c;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Callable, Iterable
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import itertools
|
||||
@@ -6,6 +7,7 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome import yaml_util
|
||||
import esphome.codegen as cg
|
||||
@@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.espidf.component import generate_idf_components
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
from esphome.writer import clean_build, clean_cmake_cache
|
||||
|
||||
@@ -496,6 +499,32 @@ def get_esp32_variant(core_obj=None):
|
||||
return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT]
|
||||
|
||||
|
||||
def variant_filtered_enum(
|
||||
by_variant: dict[str, Iterable[Any]], **kwargs: Any
|
||||
) -> Callable[[Any], Any]:
|
||||
"""Build a ``one_of`` validator whose valid set depends on the active variant.
|
||||
|
||||
``by_variant`` maps each ESP32 variant constant to the iterable of values that
|
||||
are valid on that variant. At validation time the value is checked against the
|
||||
set allowed for the current target variant. For schema extraction the inverted
|
||||
``{value: [variants, ...]}`` map is returned instead, so the language-schema
|
||||
dump can tag every option with the variants that accept it and frontends can
|
||||
filter to the user's selected variant.
|
||||
"""
|
||||
by_value: dict[str, list[str]] = {}
|
||||
for variant, values in by_variant.items():
|
||||
for value in values:
|
||||
by_value.setdefault(str(value), []).append(variant)
|
||||
|
||||
@schema_extractor("variant_enum")
|
||||
def validator(value: Any) -> Any:
|
||||
if value is SCHEMA_EXTRACT:
|
||||
return by_value
|
||||
return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def get_board(core_obj=None):
|
||||
return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD]
|
||||
|
||||
@@ -2287,14 +2316,14 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", True)
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_SECURE_BOOT_SIGNING_KEY",
|
||||
str(signed_ota[CONF_SIGNING_KEY].resolve()),
|
||||
signed_ota[CONF_SIGNING_KEY].resolve().as_posix(),
|
||||
)
|
||||
else:
|
||||
# Public key mode — verification only, external signing required
|
||||
add_idf_sdkconfig_option("CONFIG_SECURE_BOOT_BUILD_SIGNED_BINARIES", False)
|
||||
add_idf_sdkconfig_option(
|
||||
"CONFIG_SECURE_BOOT_VERIFICATION_KEY",
|
||||
str(signed_ota[CONF_VERIFICATION_KEY].resolve()),
|
||||
signed_ota[CONF_VERIFICATION_KEY].resolve().as_posix(),
|
||||
)
|
||||
|
||||
cg.add_define("USE_OTA_SIGNED_VERIFICATION")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -77,13 +77,15 @@ template<typename... Ts> class BLECharacteristicSetValueAction : public Action<T
|
||||
// Set initial value
|
||||
this->parent_->set_value(this->buffer_.value(x...));
|
||||
// Set the listener for read events
|
||||
this->parent_->on_read([this, x...](uint16_t id) {
|
||||
// ``mutable`` keeps by-copy captures non-const for triggers passing args by reference
|
||||
// (e.g. climate on_control's ClimateCall&). See #17142.
|
||||
this->parent_->on_read([this, x...](uint16_t id) mutable {
|
||||
// Set the value of the characteristic every time it is read
|
||||
this->parent_->set_value(this->buffer_.value(x...));
|
||||
});
|
||||
// Set the listener in the global manager so only one BLECharacteristicSetValueAction is set for each characteristic
|
||||
BLECharacteristicSetValueActionManager::get_instance()->set_listener(
|
||||
this->parent_, [this, x...]() { this->parent_->set_value(this->buffer_.value(x...)); });
|
||||
this->parent_, [this, x...]() mutable { this->parent_->set_value(this->buffer_.value(x...)); });
|
||||
}
|
||||
|
||||
protected:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -28,9 +28,6 @@ namespace esphome::espnow {
|
||||
|
||||
static constexpr const char *TAG = "espnow";
|
||||
|
||||
static const esp_err_t CONFIG_ESPNOW_WAKE_WINDOW = 50;
|
||||
static const esp_err_t CONFIG_ESPNOW_WAKE_INTERVAL = 100;
|
||||
|
||||
ESPNowComponent *global_esp_now = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
static const LogString *espnow_error_to_str(esp_err_t error) {
|
||||
@@ -97,6 +94,15 @@ void on_send_report(const uint8_t *mac_addr, esp_now_send_status_t status)
|
||||
}
|
||||
|
||||
void on_data_received(const esp_now_recv_info_t *info, const uint8_t *data, int size) {
|
||||
// Drop oversized frames before copying. ESP-NOW v2 peers (IDF >= 5.4 builds a
|
||||
// v2 stack with no opt-out) can send up to ESP_NOW_MAX_DATA_LEN_V2 (1470 B),
|
||||
// but our receive buffer is ESP_NOW_MAX_DATA_LEN (250 B); copying a larger
|
||||
// frame would overflow packet_.receive.data.
|
||||
if (size < 0 || size > ESP_NOW_MAX_DATA_LEN) {
|
||||
global_esp_now->receive_packet_queue_.increment_dropped_count();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allocate an event from the pool
|
||||
ESPNowPacket *packet = global_esp_now->receive_packet_pool_.allocate();
|
||||
if (packet == nullptr) {
|
||||
@@ -204,11 +210,6 @@ void ESPNowComponent::enable_() {
|
||||
|
||||
esp_wifi_get_mac(WIFI_IF_STA, this->own_address_);
|
||||
|
||||
#ifdef USE_DEEP_SLEEP
|
||||
esp_now_set_wake_window(CONFIG_ESPNOW_WAKE_WINDOW);
|
||||
esp_wifi_connectionless_module_set_wake_interval(CONFIG_ESPNOW_WAKE_INTERVAL);
|
||||
#endif
|
||||
|
||||
this->state_ = ESPNOW_STATE_ENABLED;
|
||||
|
||||
for (auto peer : this->peers_) {
|
||||
@@ -311,7 +312,9 @@ void ESPNowComponent::loop() {
|
||||
ESP_LOGV(TAG, ">>> [%s] %s", addr_buf, LOG_STR_ARG(espnow_error_to_str(packet->packet_.sent.status)));
|
||||
#endif
|
||||
if (this->current_send_packet_ != nullptr) {
|
||||
this->current_send_packet_->callback_(packet->packet_.sent.status);
|
||||
if (this->current_send_packet_->callback_ != nullptr) {
|
||||
this->current_send_packet_->callback_(packet->packet_.sent.status);
|
||||
}
|
||||
this->send_packet_pool_.release(this->current_send_packet_);
|
||||
this->current_send_packet_ = nullptr; // Reset current packet after sending
|
||||
}
|
||||
@@ -333,13 +336,13 @@ void ESPNowComponent::loop() {
|
||||
// Log dropped received packets periodically
|
||||
uint16_t received_dropped = this->receive_packet_queue_.get_and_reset_dropped_count();
|
||||
if (received_dropped > 0) {
|
||||
ESP_LOGW(TAG, "Dropped %u received packets due to buffer overflow", received_dropped);
|
||||
ESP_LOGW(TAG, "Dropped %u received packets (queue full or oversized frame)", received_dropped);
|
||||
}
|
||||
|
||||
// Log dropped send packets periodically
|
||||
uint16_t send_dropped = this->send_packet_queue_.get_and_reset_dropped_count();
|
||||
if (send_dropped > 0) {
|
||||
ESP_LOGW(TAG, "Dropped %u send packets due to buffer overflow", send_dropped);
|
||||
ESP_LOGW(TAG, "Dropped %u send packets (queue full)", send_dropped);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import light, output
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B
|
||||
from esphome.const import CONF_OUTPUT_ID, CONF_PIN_A, CONF_PIN_B, CONF_UPDATE_INTERVAL
|
||||
|
||||
from .. import hbridge_ns
|
||||
|
||||
CODEOWNERS = ["@DotNetDann"]
|
||||
|
||||
HBridgeLightOutput = hbridge_ns.class_(
|
||||
"HBridgeLightOutput", cg.Component, light.LightOutput
|
||||
"HBridgeLightOutput", cg.PollingComponent, light.LightOutput
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend(
|
||||
@@ -16,12 +16,14 @@ CONFIG_SCHEMA = light.RGB_LIGHT_SCHEMA.extend(
|
||||
cv.GenerateID(CONF_OUTPUT_ID): cv.declare_id(HBridgeLightOutput),
|
||||
cv.Required(CONF_PIN_A): cv.use_id(output.FloatOutput),
|
||||
cv.Required(CONF_PIN_B): cv.use_id(output.FloatOutput),
|
||||
cv.Optional(CONF_UPDATE_INTERVAL, default="8ms"): cv.update_interval,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config):
|
||||
var = cg.new_Pvariable(config[CONF_OUTPUT_ID])
|
||||
cg.add(var.set_update_interval(config.pop(CONF_UPDATE_INTERVAL)))
|
||||
await cg.register_component(var, config)
|
||||
await light.register_light(var, config)
|
||||
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
#include "esphome/components/light/light_output.h"
|
||||
#include "esphome/components/output/float_output.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
|
||||
namespace esphome::hbridge {
|
||||
|
||||
class HBridgeLightOutput : public Component, public light::LightOutput {
|
||||
class HBridgeLightOutput final : public PollingComponent, public light::LightOutput {
|
||||
public:
|
||||
void set_pina_pin(output::FloatOutput *pina_pin) { this->pina_pin_ = pina_pin; }
|
||||
void set_pinb_pin(output::FloatOutput *pinb_pin) { this->pinb_pin_ = pinb_pin; }
|
||||
@@ -20,11 +19,12 @@ class HBridgeLightOutput : public Component, public light::LightOutput {
|
||||
return traits;
|
||||
}
|
||||
|
||||
void setup() override { this->disable_loop(); }
|
||||
void setup() override { this->stop_poller(); }
|
||||
|
||||
void loop() override {
|
||||
// Only called when both channels are active — alternate H-bridge direction
|
||||
// each iteration to multiplex cold and warm white.
|
||||
void update() override {
|
||||
// Flip the H-bridge direction to multiplex cold/warm white. update_interval must stay
|
||||
// slower than the output's PWM period (flipping faster collapses the output onto one
|
||||
// channel) but fast enough to avoid flicker (issue #17030).
|
||||
if (!this->forward_direction_) {
|
||||
this->pina_pin_->set_level(this->pina_duty_);
|
||||
this->pinb_pin_->set_level(0);
|
||||
@@ -46,13 +46,17 @@ class HBridgeLightOutput : public Component, public light::LightOutput {
|
||||
this->pinb_duty_ = new_pinb;
|
||||
|
||||
if (new_pina != 0.0f && new_pinb != 0.0f) {
|
||||
// Both channels active — need loop to alternate H-bridge direction
|
||||
this->high_freq_.start();
|
||||
this->enable_loop();
|
||||
// Both channels active — multiplex the H-bridge direction via the poller.
|
||||
if (!this->multiplexing_) {
|
||||
this->multiplexing_ = true;
|
||||
this->start_poller();
|
||||
}
|
||||
} else {
|
||||
// Zero or one channel active — drive pins directly, no multiplexing needed
|
||||
this->high_freq_.stop();
|
||||
this->disable_loop();
|
||||
// Zero or one channel active — drive pins directly, no multiplexing needed.
|
||||
if (this->multiplexing_) {
|
||||
this->multiplexing_ = false;
|
||||
this->stop_poller();
|
||||
}
|
||||
this->pina_pin_->set_level(new_pina);
|
||||
this->pinb_pin_->set_level(new_pinb);
|
||||
}
|
||||
@@ -64,7 +68,7 @@ class HBridgeLightOutput : public Component, public light::LightOutput {
|
||||
float pina_duty_{0};
|
||||
float pinb_duty_{0};
|
||||
bool forward_direction_{false};
|
||||
HighFrequencyLoopRequester high_freq_;
|
||||
bool multiplexing_{false};
|
||||
};
|
||||
|
||||
} // namespace esphome::hbridge
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -120,6 +120,7 @@ CSCON = 0xF0
|
||||
PWCTR6 = 0xF6
|
||||
ADJCTL3 = 0xF7
|
||||
PAGESEL = 0xFE
|
||||
PAGESEL1 = 0xFF
|
||||
|
||||
MADCTL_MY = 0x80 # Bit 7 Bottom to top
|
||||
MADCTL_MX = 0x40 # Bit 6 Right to left
|
||||
@@ -387,6 +388,16 @@ class DriverChip:
|
||||
return {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY}
|
||||
return {CONF_MIRROR_X, CONF_MIRROR_Y}
|
||||
|
||||
def has_hardware_transform(self, config) -> bool:
|
||||
"""
|
||||
Check if the model supports hardware transforms for the given configuration.
|
||||
"""
|
||||
return config.get(CONF_TRANSFORM) != CONF_DISABLED and self.transforms == {
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_SWAP_XY,
|
||||
}
|
||||
|
||||
def option(self, name, fallback=False) -> cv.Optional:
|
||||
return cv.Optional(name, default=self.get_default(name, fallback))
|
||||
|
||||
@@ -417,10 +428,15 @@ class DriverChip:
|
||||
:return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height).
|
||||
"""
|
||||
|
||||
transform = self.get_transform(config)
|
||||
if CONF_DIMENSIONS in config:
|
||||
# Explicit dimensions, just use as is
|
||||
dimensions = config[CONF_DIMENSIONS]
|
||||
if isinstance(dimensions, dict):
|
||||
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
|
||||
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
|
||||
if transform.get(CONF_SWAP_XY) is True:
|
||||
native_width, native_height = native_height, native_width
|
||||
width = dimensions[CONF_WIDTH]
|
||||
height = dimensions[CONF_HEIGHT]
|
||||
offset_width = dimensions[CONF_OFFSET_WIDTH]
|
||||
@@ -428,23 +444,19 @@ class DriverChip:
|
||||
if CONF_PAD_WIDTH in dimensions:
|
||||
pad_width = dimensions[CONF_PAD_WIDTH]
|
||||
native_width = width + offset_width + pad_width
|
||||
elif native_width == 0:
|
||||
pad_width = 0
|
||||
native_width = width + offset_width
|
||||
else:
|
||||
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
|
||||
if native_width == 0:
|
||||
pad_width = 0
|
||||
native_width = width + offset_width
|
||||
else:
|
||||
pad_width = native_width - width - offset_width
|
||||
pad_width = native_width - width - offset_width
|
||||
if CONF_PAD_HEIGHT in dimensions:
|
||||
pad_height = dimensions[CONF_PAD_HEIGHT]
|
||||
native_height = height + offset_height + pad_height
|
||||
elif native_height == 0:
|
||||
pad_height = 0
|
||||
native_height = height + offset_height
|
||||
else:
|
||||
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
|
||||
if native_height == 0:
|
||||
pad_height = 0
|
||||
native_height = height + offset_height
|
||||
else:
|
||||
pad_height = native_height - height - offset_height
|
||||
pad_height = native_height - height - offset_height
|
||||
if (
|
||||
pad_width + offset_width >= native_width
|
||||
or pad_height + offset_height >= native_height
|
||||
@@ -460,7 +472,6 @@ class DriverChip:
|
||||
return width, height, 0, 0, 0, 0
|
||||
|
||||
# Default dimensions, use model defaults
|
||||
transform = self.get_transform(config)
|
||||
|
||||
width = self.get_default(CONF_WIDTH)
|
||||
height = self.get_default(CONF_HEIGHT)
|
||||
|
||||
@@ -71,6 +71,7 @@ DriverChip(
|
||||
swap_xy=cv.UNDEFINED,
|
||||
color_order="RGB",
|
||||
initsequence=[
|
||||
(0x01,),
|
||||
(0x60, 0x71, 0x23, 0xa2),
|
||||
(0x60, 0x71, 0x23, 0xa3),
|
||||
(0x60, 0x71, 0x23, 0xa4),
|
||||
|
||||
@@ -17,6 +17,8 @@ from esphome.components.mipi import (
|
||||
MADCTL,
|
||||
MODE_BGR,
|
||||
MODE_RGB,
|
||||
PAGESEL,
|
||||
PAGESEL1,
|
||||
PIXFMT,
|
||||
DriverChip,
|
||||
dimension_schema,
|
||||
@@ -172,13 +174,19 @@ def model_schema(config):
|
||||
if bus_mode == TYPE_SINGLE:
|
||||
other_options.append(CONF_SPI_16)
|
||||
# Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise.
|
||||
spi_mode = model.get_default(CONF_SPI_MODE)
|
||||
spi_mode = (
|
||||
cv.UNDEFINED if CONF_SPI_MODE in config else model.get_default(CONF_SPI_MODE)
|
||||
)
|
||||
if not spi_mode:
|
||||
if bus_mode == TYPE_OCTAL or (
|
||||
bus_mode == TYPE_SINGLE
|
||||
and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN))
|
||||
and config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN)) is False
|
||||
):
|
||||
spi_mode = "MODE3"
|
||||
if bus_mode == TYPE_SINGLE:
|
||||
LOGGER.warning(
|
||||
"No SPI mode specified, defaulting to MODE3 due to lack of CS pin. If you experience issues, try setting SPI mode explicitly to MODE0 or MODE3."
|
||||
)
|
||||
else:
|
||||
spi_mode = "MODE0"
|
||||
|
||||
@@ -270,14 +278,16 @@ def customise_schema(config):
|
||||
# Check for invalid combinations of MADCTL config
|
||||
if init_sequence := config.get(CONF_INIT_SEQUENCE):
|
||||
commands = [x[0] for x in init_sequence]
|
||||
if MADCTL in commands and CONF_TRANSFORM in config:
|
||||
raise cv.Invalid(
|
||||
f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence"
|
||||
)
|
||||
if PIXFMT in commands:
|
||||
raise cv.Invalid(
|
||||
f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically"
|
||||
)
|
||||
# If there is page swapping, we can't rely on recognising common commands
|
||||
if PAGESEL not in commands and PAGESEL1 not in commands:
|
||||
if MADCTL in commands and CONF_TRANSFORM in config:
|
||||
raise cv.Invalid(
|
||||
f"transform is not supported when MADCTL ({MADCTL:#X}) is in the init sequence"
|
||||
)
|
||||
if PIXFMT in commands:
|
||||
raise cv.Invalid(
|
||||
f"PIXFMT ({PIXFMT:#X}) should not be in the init sequence, it will be set automatically"
|
||||
)
|
||||
|
||||
if bus_mode == TYPE_QUAD and CONF_DC_PIN in config:
|
||||
raise cv.Invalid("DC pin is not supported in quad mode")
|
||||
@@ -285,13 +295,7 @@ def customise_schema(config):
|
||||
raise cv.Invalid(f"DC pin is required in {bus_mode} mode")
|
||||
denominator(config)
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
has_hardware_transform = config.get(
|
||||
CONF_TRANSFORM
|
||||
) != CONF_DISABLED and model.transforms == {
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_SWAP_XY,
|
||||
}
|
||||
has_hardware_transform = model.has_hardware_transform(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config, not has_hardware_transform)
|
||||
)
|
||||
@@ -356,13 +360,7 @@ def get_instance(config):
|
||||
:return: type, template arguments
|
||||
"""
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
has_hardware_transform = config.get(
|
||||
CONF_TRANSFORM
|
||||
) != CONF_DISABLED and model.transforms == {
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_SWAP_XY,
|
||||
}
|
||||
has_hardware_transform = model.has_hardware_transform(config)
|
||||
width, height, offset_width, offset_height, pad_width, pad_height = (
|
||||
model.get_dimensions(config, not has_hardware_transform)
|
||||
)
|
||||
@@ -427,6 +425,8 @@ async def to_code(config):
|
||||
dc_pin = await cg.gpio_pin_expression(dc_pin)
|
||||
cg.add(var.set_dc_pin(dc_pin))
|
||||
|
||||
if config.get(CONF_INVERT_COLORS):
|
||||
cg.add(var.set_invert_colors(True))
|
||||
if lamb := config.get(CONF_LAMBDA):
|
||||
lambda_ = await cg.process_lambda(
|
||||
lamb, [(display.DisplayRef, "it")], return_type=cg.void
|
||||
|
||||
@@ -151,6 +151,9 @@ class MipiSpi : public display::Display,
|
||||
this->reset_pin_->digital_write(false);
|
||||
delay(5);
|
||||
this->reset_pin_->digital_write(true);
|
||||
} else {
|
||||
// no reset pin, send software reset command
|
||||
this->write_command_(SW_RESET_CMD);
|
||||
}
|
||||
|
||||
// need to know when the display is ready for SLPOUT command - will be 120ms after reset
|
||||
@@ -176,7 +179,6 @@ class MipiSpi : public display::Display,
|
||||
this->mark_failed();
|
||||
return;
|
||||
}
|
||||
auto arg_byte = vec[index];
|
||||
switch (cmd) {
|
||||
case SLEEP_OUT: {
|
||||
// are we ready, boots?
|
||||
@@ -187,13 +189,6 @@ class MipiSpi : public display::Display,
|
||||
}
|
||||
} break;
|
||||
|
||||
case INVERT_ON:
|
||||
this->invert_colors_ = true;
|
||||
break;
|
||||
case BRIGHTNESS:
|
||||
this->brightness_ = arg_byte;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -24,13 +24,11 @@ from esphome.components.mipi import (
|
||||
PWSET,
|
||||
PWSETN,
|
||||
SETEXTC,
|
||||
SWRESET,
|
||||
VMCTR,
|
||||
VMCTR1,
|
||||
VMCTR2,
|
||||
VSCRSADD,
|
||||
DriverChip,
|
||||
delay,
|
||||
)
|
||||
from esphome.components.spi import TYPE_OCTAL
|
||||
|
||||
@@ -367,7 +365,6 @@ ST7796 = DriverChip(
|
||||
width=320,
|
||||
height=480,
|
||||
initsequence=(
|
||||
(SWRESET,),
|
||||
(CSCON, 0xC3),
|
||||
(CSCON, 0x96),
|
||||
(VMCTR1, 0x1C),
|
||||
@@ -728,8 +725,6 @@ DriverChip(
|
||||
width=128,
|
||||
height=160,
|
||||
initsequence=(
|
||||
SWRESET,
|
||||
delay(10),
|
||||
(FRMCTR1, 0x01, 0x2C, 0x2D),
|
||||
(FRMCTR2, 0x01, 0x2C, 0x2D),
|
||||
(FRMCTR3, 0x01, 0x2C, 0x2D, 0x01, 0x2C, 0x2D),
|
||||
@@ -786,7 +781,7 @@ ST7796.extend(
|
||||
bus_mode=TYPE_OCTAL,
|
||||
mirror_x=True,
|
||||
reset_pin=4,
|
||||
dc_pin=0,
|
||||
dc_pin={"number": 0, "ignore_strapping_warning": True},
|
||||
invert_colors=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ struct IPAddress {
|
||||
IPAddress(const std::string &in_address) { ipaddr_aton(in_address.c_str(), &ip_addr_); }
|
||||
IPAddress(ip4_addr_t *other_ip) {
|
||||
memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip4_addr_t));
|
||||
#if USE_ESP32 && LWIP_IPV6
|
||||
#if LWIP_IPV6
|
||||
ip_addr_.type = IPADDR_TYPE_V4;
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -7,9 +7,13 @@ static const char *const TAG = "opentherm.output";
|
||||
|
||||
void opentherm::OpenthermOutput::write_state(float state) {
|
||||
ESP_LOGD(TAG, "Received state: %.2f. Min value: %.2f, max value: %.2f", state, min_value_, max_value_);
|
||||
this->state = state < 0.003 && this->zero_means_zero_
|
||||
? 0.0
|
||||
: clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_);
|
||||
#ifdef USE_OUTPUT_FLOAT_POWER_SCALING
|
||||
bool zero_means_zero = this->zero_means_zero_;
|
||||
#else
|
||||
bool zero_means_zero = false;
|
||||
#endif
|
||||
this->state =
|
||||
state < 0.003 && zero_means_zero ? 0.0 : clamp(std::lerp(min_value_, max_value_, state), min_value_, max_value_);
|
||||
this->has_state_ = true;
|
||||
ESP_LOGD(TAG, "Output %s set to %.2f", this->id_, this->state);
|
||||
}
|
||||
|
||||
@@ -227,7 +227,7 @@ bool OpenThreadComponent::teardown() {
|
||||
ESP_LOGW(TAG, "Failed to acquire OpenThread lock during teardown, leaking memory");
|
||||
return true;
|
||||
}
|
||||
otInstance *instance = lock->get_instance();
|
||||
otInstance *instance = lock.get_instance();
|
||||
otSrpClientClearHostAndServices(instance);
|
||||
otSrpClientBuffersFreeAllServices(instance);
|
||||
global_openthread_component = nullptr;
|
||||
|
||||
@@ -86,19 +86,32 @@ class OpenThreadSrpComponent : public Component {
|
||||
void *pool_alloc_(size_t size);
|
||||
};
|
||||
|
||||
// RAII guard for the OpenThread API lock. Modeled on std::unique_lock: the
|
||||
// guard may or may not own the lock (try_acquire can fail), so check it with
|
||||
// operator bool before use. Non-copyable and non-movable: the factories return
|
||||
// by value via guaranteed copy elision, so a guard is never duplicated and the
|
||||
// lock is released exactly once, when the owning guard goes out of scope.
|
||||
class InstanceLock {
|
||||
public:
|
||||
static std::optional<InstanceLock> try_acquire(int delay);
|
||||
// May fail to acquire within delay ms; check the returned guard with operator bool.
|
||||
static InstanceLock try_acquire(int delay);
|
||||
// Blocks until the lock is held.
|
||||
static InstanceLock acquire();
|
||||
InstanceLock(const InstanceLock &) = delete;
|
||||
InstanceLock(InstanceLock &&) = delete;
|
||||
InstanceLock &operator=(const InstanceLock &) = delete;
|
||||
InstanceLock &operator=(InstanceLock &&) = delete;
|
||||
~InstanceLock();
|
||||
|
||||
// Returns the global openthread instance guarded by this lock
|
||||
explicit operator bool() const { return this->owns_; }
|
||||
|
||||
// Returns the global openthread instance. Only valid on an owning guard
|
||||
// (operator bool is true); the instance must not be used without the lock held.
|
||||
otInstance *get_instance();
|
||||
|
||||
private:
|
||||
// Use a private constructor in order to force the handling
|
||||
// of acquisition failure
|
||||
InstanceLock() {}
|
||||
explicit InstanceLock(bool owns) : owns_(owns) {}
|
||||
bool owns_;
|
||||
};
|
||||
|
||||
} // namespace esphome::openthread
|
||||
|
||||
@@ -216,14 +216,11 @@ network::IPAddresses OpenThreadComponent::get_ip_addresses() {
|
||||
// not thread safe, only use in read-only use cases
|
||||
otInstance *OpenThreadComponent::get_openthread_instance_() { return esp_openthread_get_instance(); }
|
||||
|
||||
std::optional<InstanceLock> InstanceLock::try_acquire(int delay) {
|
||||
InstanceLock InstanceLock::try_acquire(int delay) {
|
||||
if (!global_openthread_component->is_lock_initialized()) {
|
||||
return {};
|
||||
return InstanceLock(false);
|
||||
}
|
||||
if (esp_openthread_lock_acquire(delay)) {
|
||||
return InstanceLock();
|
||||
}
|
||||
return {};
|
||||
return InstanceLock(esp_openthread_lock_acquire(delay));
|
||||
}
|
||||
|
||||
InstanceLock InstanceLock::acquire() {
|
||||
@@ -242,12 +239,16 @@ InstanceLock InstanceLock::acquire() {
|
||||
while (!esp_openthread_lock_acquire(100)) {
|
||||
esp_task_wdt_reset();
|
||||
}
|
||||
return InstanceLock();
|
||||
return InstanceLock(true);
|
||||
}
|
||||
|
||||
otInstance *InstanceLock::get_instance() { return esp_openthread_get_instance(); }
|
||||
|
||||
InstanceLock::~InstanceLock() { esp_openthread_lock_release(); }
|
||||
InstanceLock::~InstanceLock() {
|
||||
if (this->owns_) {
|
||||
esp_openthread_lock_release();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace esphome::openthread
|
||||
#endif
|
||||
|
||||
@@ -17,7 +17,7 @@ class OpenThreadInstancePollingComponent : public PollingComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this->update_instance(lock->get_instance());
|
||||
this->update_instance(lock.get_instance());
|
||||
}
|
||||
float get_setup_priority() const override { return setup_priority::AFTER_WIFI; }
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@ from esphome.components.esp32 import (
|
||||
add_idf_sdkconfig_option,
|
||||
get_esp32_variant,
|
||||
idf_version,
|
||||
variant_filtered_enum,
|
||||
)
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
@@ -29,6 +30,7 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import CORE
|
||||
import esphome.final_validate as fv
|
||||
from esphome.types import ConfigType
|
||||
|
||||
CODEOWNERS = ["@esphome/core"]
|
||||
DOMAIN = "psram"
|
||||
@@ -70,6 +72,11 @@ SPIRAM_SPEEDS = {
|
||||
VARIANT_ESP32P4: (20, 100, 200),
|
||||
}
|
||||
|
||||
SPIRAM_SPEEDS_MHZ = {
|
||||
variant: tuple(f"{speed}MHZ" for speed in speeds)
|
||||
for variant, speeds in SPIRAM_SPEEDS.items()
|
||||
}
|
||||
|
||||
|
||||
def supported() -> bool:
|
||||
if not CORE.is_esp32:
|
||||
@@ -145,15 +152,23 @@ def validate_psram_mode(config):
|
||||
return config
|
||||
|
||||
|
||||
def get_config_schema(config):
|
||||
def _set_variant_defaults(config: ConfigType) -> ConfigType:
|
||||
"""Resolve variant-dependent defaults before the static schema validates.
|
||||
|
||||
The set of valid ``mode``/``speed`` values is variant-specific (enforced by
|
||||
``variant_filtered_enum`` in the schema below); this only supplies the default
|
||||
when the user omits the option. ``mode`` has no single default on chips that
|
||||
support more than one mode, so selection is required there.
|
||||
"""
|
||||
variant = get_esp32_variant()
|
||||
speeds = [f"{s}MHZ" for s in SPIRAM_SPEEDS.get(variant, [])]
|
||||
if not speeds:
|
||||
modes = SPIRAM_MODES.get(variant)
|
||||
speeds = SPIRAM_SPEEDS.get(variant)
|
||||
if not modes or not speeds:
|
||||
raise cv.Invalid("PSRAM is not supported on this chip")
|
||||
modes = SPIRAM_MODES[variant]
|
||||
if CONF_MODE not in config and len(modes) != 1:
|
||||
raise (
|
||||
cv.Invalid(
|
||||
config = config.copy()
|
||||
if CONF_MODE not in config:
|
||||
if len(modes) != 1:
|
||||
raise cv.Invalid(
|
||||
textwrap.dedent(
|
||||
f"""
|
||||
{variant} requires PSRAM mode selection; one of {", ".join(modes)}
|
||||
@@ -161,20 +176,27 @@ def get_config_schema(config):
|
||||
"""
|
||||
)
|
||||
)
|
||||
)
|
||||
return cv.Schema(
|
||||
config[CONF_MODE] = modes[0]
|
||||
if CONF_SPEED not in config:
|
||||
config[CONF_SPEED] = f"{speeds[0]}MHZ"
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
_set_variant_defaults,
|
||||
cv.Schema(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(PsramComponent),
|
||||
cv.Optional(CONF_MODE, default=modes[0]): cv.one_of(*modes, lower=True),
|
||||
cv.Optional(CONF_MODE): variant_filtered_enum(SPIRAM_MODES, lower=True),
|
||||
cv.Optional(CONF_ENABLE_ECC, default=False): cv.boolean,
|
||||
cv.Optional(CONF_SPEED, default=speeds[0]): cv.one_of(*speeds, upper=True),
|
||||
cv.Optional(CONF_SPEED): variant_filtered_enum(
|
||||
SPIRAM_SPEEDS_MHZ, upper=True
|
||||
),
|
||||
cv.Optional(CONF_DISABLED, default=False): cv.boolean,
|
||||
cv.Optional(CONF_IGNORE_NOT_FOUND, default=True): cv.boolean,
|
||||
}
|
||||
)(config)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = get_config_schema
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _store_psram_guaranteed(config):
|
||||
|
||||
@@ -47,7 +47,7 @@ class RuntimeStatsCollector {
|
||||
// overhead between Phase A and stats belongs to "residual").
|
||||
// Residual overhead at log time = active − Σ(component) − before − tail,
|
||||
// which captures per-iteration inter-component bookkeeping (set_current_component,
|
||||
// WarnIfComponentBlockingGuard construction/destruction, feed_wdt_with_time calls,
|
||||
// LoopBlockingGuard construction/destruction, feed_wdt_with_time calls,
|
||||
// the for-loop itself).
|
||||
void record_loop_active(uint32_t active_us, uint32_t before_us, uint32_t tail_us) {
|
||||
this->period_active_count_++;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <tuple>
|
||||
#include "esphome/core/application.h"
|
||||
#include "esphome/core/automation.h"
|
||||
#include "esphome/core/component.h"
|
||||
#include "esphome/core/helpers.h"
|
||||
@@ -57,6 +58,14 @@ template<typename... Ts> class Script : public ScriptLogger, public Trigger<Ts..
|
||||
this->execute(std::get<S>(tuple)...);
|
||||
}
|
||||
|
||||
// Run the action chain with this script's name published as the current source (RAII save/restore,
|
||||
// so nesting composes), so deferred work inside the script is attributed to it in blocking
|
||||
// warnings. Force-inlined to fold into the always-inlined trigger chain (no extra stack frame).
|
||||
inline void run_actions_(const Ts &...x) ESPHOME_ALWAYS_INLINE {
|
||||
ScopedSourceGuard source_guard{this->name_};
|
||||
this->trigger(x...);
|
||||
}
|
||||
|
||||
const LogString *name_{nullptr};
|
||||
};
|
||||
|
||||
@@ -74,7 +83,7 @@ template<typename... Ts> class SingleScript : public Script<Ts...> {
|
||||
return;
|
||||
}
|
||||
|
||||
this->trigger(x...);
|
||||
this->run_actions_(x...);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -91,7 +100,7 @@ template<typename... Ts> class RestartScript : public Script<Ts...> {
|
||||
this->stop_action();
|
||||
}
|
||||
|
||||
this->trigger(x...);
|
||||
this->run_actions_(x...);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -136,7 +145,7 @@ template<typename... Ts> class QueueingScript : public Script<Ts...>, public Com
|
||||
return;
|
||||
}
|
||||
|
||||
this->trigger(x...);
|
||||
this->run_actions_(x...);
|
||||
// Check if the trigger was immediate and we can continue right away.
|
||||
this->loop();
|
||||
}
|
||||
@@ -175,7 +184,7 @@ template<typename... Ts> class QueueingScript : public Script<Ts...>, public Com
|
||||
}
|
||||
|
||||
template<size_t... S> void trigger_tuple_(const std::tuple<Ts...> &tuple, std::index_sequence<S...> /*unused*/) {
|
||||
this->trigger(std::get<S>(tuple)...);
|
||||
this->run_actions_(std::get<S>(tuple)...);
|
||||
}
|
||||
|
||||
int num_queued_ = 0; // Number of queued instances (not including currently running)
|
||||
@@ -197,7 +206,7 @@ template<typename... Ts> class ParallelScript : public Script<Ts...> {
|
||||
LOG_STR_ARG(this->name_));
|
||||
return;
|
||||
}
|
||||
this->trigger(x...);
|
||||
this->run_actions_(x...);
|
||||
}
|
||||
void set_max_runs(int max_runs) { max_runs_ = max_runs; }
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ 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(
|
||||
|
||||
@@ -218,9 +218,18 @@ network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() {
|
||||
return {};
|
||||
network::IPAddresses addresses;
|
||||
uint8_t index = 0;
|
||||
// addrList enumerates all lwIP netifs, including the SoftAP / fallback hotspot. Filter out
|
||||
// the AP address so the STA address is reported as the device IP (see issue #17181).
|
||||
struct ip_info ap_ip {};
|
||||
wifi_get_ip_info(SOFTAP_IF, &ap_ip);
|
||||
network::IPAddress ap_address(&ap_ip.ip);
|
||||
bool filter_ap = ap_address.is_set();
|
||||
for (auto &addr : addrList) {
|
||||
network::IPAddress ip(addr.ipFromNetifNum());
|
||||
if (filter_ap && ip == ap_address)
|
||||
continue;
|
||||
assert(index < addresses.size());
|
||||
addresses[index++] = addr.ipFromNetifNum();
|
||||
addresses[index++] = ip;
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
@@ -179,12 +179,53 @@ void WiFiComponent::wifi_lazy_init_() {
|
||||
// nor re-register the default WiFi handlers.
|
||||
if (s_sta_netif == nullptr)
|
||||
s_sta_netif = esp_netif_create_default_wifi_sta();
|
||||
if (s_sta_netif == nullptr) {
|
||||
// Allocation failed; leave wifi_initialized_ false so a later enable() retries.
|
||||
ESP_LOGE(TAG, "esp_netif_create_default_wifi_sta failed");
|
||||
return;
|
||||
}
|
||||
|
||||
#ifdef USE_WIFI_AP
|
||||
if (s_ap_netif == nullptr)
|
||||
s_ap_netif = esp_netif_create_default_wifi_ap();
|
||||
#endif // USE_WIFI_AP
|
||||
|
||||
// The WiFi driver was started (e.g. by ESP-NOW with the wifi component disabled at
|
||||
// boot) before our STA netif existed. The default WIFI_EVENT_STA_START handler
|
||||
// therefore ran with no netif and never called esp_wifi_register_if_rxcb() -- the
|
||||
// only thing that points the driver's RX path at a netif (it sets
|
||||
// s_wifi_netifs[WIFI_IF_STA]). A bare esp_netif_action_start() would stop the
|
||||
// immediate crash (#17232) but leaves RX unbound, so the first association
|
||||
// associates at L2 yet never receives DHCP replies and times out (#17239). Restart
|
||||
// the driver now that the netif exists so STA_START re-runs the default handler and
|
||||
// wires RX correctly. ESP-NOW survives the stop/start (its peer state persists).
|
||||
// This also matches a self-retry: if esp_wifi_set_storage() below failed on a
|
||||
// previous wifi_lazy_init_() it returned without setting wifi_initialized_, and
|
||||
// esp_wifi_init() has since run, so esp_wifi_get_mode() now succeeds here too.
|
||||
wifi_mode_t mode;
|
||||
if (esp_wifi_get_mode(&mode) == ESP_OK) {
|
||||
ESP_LOGD(TAG, "WiFi driver already started without STA netif; restarting to bind it");
|
||||
esp_err_t err = esp_wifi_stop();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_stop failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
// Re-apply RAM storage; the normal init path does this, but it is skipped on
|
||||
// the self-retry case above, which would otherwise let the driver persist
|
||||
// credentials to NVS for the rest of the boot.
|
||||
err = esp_wifi_set_storage(WIFI_STORAGE_RAM);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "esp_wifi_set_storage failed: %s", esp_err_to_name(err));
|
||||
}
|
||||
err = esp_wifi_start();
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "esp_wifi_start failed: %s", esp_err_to_name(err));
|
||||
return;
|
||||
}
|
||||
s_wifi_started = true;
|
||||
this->wifi_initialized_ = true;
|
||||
return;
|
||||
}
|
||||
|
||||
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
|
||||
if (global_preferences->nvs_handle == 0) {
|
||||
ESP_LOGW(TAG, "starting wifi without nvs");
|
||||
|
||||
@@ -1455,9 +1455,7 @@ def ipv6address(value):
|
||||
def ipv4address_multi_broadcast(value):
|
||||
address = ipv4address(value)
|
||||
if not (address.is_multicast or (address == IPv4Address("255.255.255.255"))):
|
||||
raise Invalid(
|
||||
f"{value} is not a multicasst address nor local broadcast address"
|
||||
)
|
||||
raise Invalid(f"{value} is not a multicast address nor local broadcast address")
|
||||
return address
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.6.0b2"
|
||||
__version__ = "2026.6.5"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -104,9 +104,13 @@ class Application {
|
||||
void register_area(Area *area) { this->areas_.push_back(area); }
|
||||
#endif
|
||||
|
||||
void set_current_component(Component *component) { this->current_component_ = component; }
|
||||
Component *get_current_component() { return this->current_component_; }
|
||||
|
||||
// Owning script of the action chain currently executing (nullptr when none); used to attribute
|
||||
// blocking warnings for deferred work to the script that scheduled it.
|
||||
void set_current_source(const LogString *source) { this->current_source_ = source; }
|
||||
const LogString *get_current_source() { return this->current_source_; }
|
||||
|
||||
// Entity register methods (generated from entity_types.h).
|
||||
// Each entity type gets two overloads:
|
||||
// - register_<entity>(obj) — bare push_back
|
||||
@@ -393,6 +397,7 @@ class Application {
|
||||
protected:
|
||||
friend Component;
|
||||
friend class Scheduler;
|
||||
friend class LoopBlockingGuard;
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
friend class runtime_stats::RuntimeStatsCollector;
|
||||
#endif
|
||||
@@ -402,6 +407,14 @@ class Application {
|
||||
/// Freshen the cached loop component start time. Called by Scheduler before each dispatch.
|
||||
void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; }
|
||||
|
||||
// Publish the running unit's identity (component + source) and dispatch time together, so a
|
||||
// dispatch site can't set one without the others. Friend-only (Scheduler).
|
||||
void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) {
|
||||
this->current_component_ = component;
|
||||
this->current_source_ = source;
|
||||
this->set_loop_component_start_time_(now);
|
||||
}
|
||||
|
||||
/// Walk all registered components looking for any whose component_state_
|
||||
/// has the given flag set. Used by Component::status_clear_*_slow_path_()
|
||||
/// (which is a friend) to decide whether to clear the corresponding bit on
|
||||
@@ -482,6 +495,7 @@ class Application {
|
||||
|
||||
// Pointer-sized members first
|
||||
Component *current_component_{nullptr};
|
||||
const LogString *current_source_{nullptr};
|
||||
|
||||
// std::vector (3 pointers each: begin, end, capacity)
|
||||
// Partitioned vector design for looping components
|
||||
@@ -554,6 +568,76 @@ class Application {
|
||||
/// Global storage of Application pointer - only one Application can exist.
|
||||
extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the
|
||||
/// previous value on exit, attributing deferred work scheduled inside to that source.
|
||||
class ScopedSourceGuard {
|
||||
public:
|
||||
explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) {
|
||||
App.set_current_source(source);
|
||||
}
|
||||
~ScopedSourceGuard() { App.set_current_source(this->prev_); }
|
||||
ScopedSourceGuard(const ScopedSourceGuard &) = delete;
|
||||
ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete;
|
||||
|
||||
private:
|
||||
const LogString *prev_;
|
||||
};
|
||||
|
||||
// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the
|
||||
// main loop too long. The constructor publishes the unit's identity + dispatch time to App;
|
||||
// finish()/the cold warning path read them back, so the guard stores no copy.
|
||||
//
|
||||
// Guards must not nest: the constructor publishes to App but never restores on destruction, so a
|
||||
// nested guard would clobber the outer's context. Safe because the two dispatch sites (component
|
||||
// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback.
|
||||
class LoopBlockingGuard {
|
||||
public:
|
||||
// Publish the unit's identity + dispatch time, then start timing. The millis start lives in App,
|
||||
// so only the runtime-stats micros stamp is kept here.
|
||||
LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) {
|
||||
App.set_current_execution_context_(component, source, now);
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
this->started_us_ = micros();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Finish the timing operation and return the current time (millis)
|
||||
// Inlined: the fast path is just millis() + subtract + compare
|
||||
inline uint32_t HOT finish() {
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t elapsed_us = micros() - this->started_us_;
|
||||
// Delays have no component; accumulate into the global counter so loop() can subtract them.
|
||||
Component *component = App.get_current_component();
|
||||
if (component != nullptr) {
|
||||
component->runtime_stats_.record_time(elapsed_us);
|
||||
} else {
|
||||
ComponentRuntimeStats::global_recorded_us += elapsed_us;
|
||||
}
|
||||
#endif
|
||||
uint32_t curr_time = MillisInternal::get();
|
||||
#ifndef USE_BENCHMARK
|
||||
// Fast path: compare against constant threshold in ms (computed at compile time from centiseconds)
|
||||
static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast<uint32_t>(WARN_IF_BLOCKING_OVER_CS) * 10U;
|
||||
uint32_t blocking_time = curr_time - App.get_loop_component_start_time();
|
||||
if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] {
|
||||
warn_blocking(blocking_time);
|
||||
}
|
||||
#endif
|
||||
return curr_time;
|
||||
}
|
||||
|
||||
~LoopBlockingGuard() = default;
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
protected:
|
||||
uint32_t started_us_;
|
||||
#endif
|
||||
|
||||
private:
|
||||
// Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit.
|
||||
static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time);
|
||||
};
|
||||
|
||||
// Phase A: drain wake notifications and run the scheduler. Invoked on every
|
||||
// Application::loop() tick regardless of whether a component phase runs, so
|
||||
// scheduler items fire at their requested cadence even when the caller has
|
||||
@@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
// before/tail splits recorded below.
|
||||
uint32_t loop_active_start_us = micros();
|
||||
// Snapshot the cumulative component-recorded time so we can subtract the
|
||||
// slice that the scheduler spends inside its own WarnIfComponentBlockingGuard
|
||||
// slice that the scheduler spends inside its own LoopBlockingGuard
|
||||
// (scheduler.cpp) — that time is already counted in per-component stats,
|
||||
// so charging it again to "before" would double-count.
|
||||
uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us;
|
||||
@@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
this->current_loop_index_++) {
|
||||
Component *component = this->looping_components_[this->current_loop_index_];
|
||||
|
||||
// Update the cached time before each component runs
|
||||
this->loop_component_start_time_ = last_op_end_time;
|
||||
|
||||
{
|
||||
this->set_current_component(component);
|
||||
WarnIfComponentBlockingGuard guard{component, last_op_end_time};
|
||||
// Guard publishes this component (no script source) + dispatch time, then times loop().
|
||||
LoopBlockingGuard guard{component, nullptr, last_op_end_time};
|
||||
component->loop();
|
||||
// Use the finish method to get the current time as the end time
|
||||
last_op_end_time = guard.finish();
|
||||
|
||||
@@ -201,7 +201,10 @@ template<typename... Ts> class DelayAction : public Action<Ts...> {
|
||||
/* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER,
|
||||
/* static_name= */ reinterpret_cast<const char *>(this), /* hash_or_id= */ 0, this->delay_.value(),
|
||||
[this]() { this->play_next_(); },
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1,
|
||||
// Record the owning script (if any) so the blocking warning can name it; propagates across
|
||||
// chained delays via the scheduler.
|
||||
/* source= */ App.get_current_source());
|
||||
} else {
|
||||
// For delays with arguments, capture by value to preserve argument values
|
||||
// Arguments must be copied because original references may be invalid after delay
|
||||
@@ -212,7 +215,9 @@ template<typename... Ts> class DelayAction : public Action<Ts...> {
|
||||
/* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER,
|
||||
/* static_name= */ reinterpret_cast<const char *>(this), /* hash_or_id= */ 0, this->delay_.value(x...),
|
||||
std::move(f),
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1,
|
||||
// See the no-argument branch above: record the owning script for log attribution.
|
||||
/* source= */ App.get_current_source());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-11
@@ -258,9 +258,11 @@ void Component::call() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bool Component::should_warn_of_blocking(uint32_t blocking_time) {
|
||||
bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) {
|
||||
// Convert centisecond threshold to milliseconds for comparison
|
||||
uint32_t threshold_ms = static_cast<uint32_t>(this->warn_if_blocking_over_) * 10U;
|
||||
// Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate.
|
||||
threshold_ms_out = threshold_ms;
|
||||
if (blocking_time > threshold_ms) {
|
||||
// Set new threshold: blocking_time + increment, converted back to centiseconds
|
||||
uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS;
|
||||
@@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int
|
||||
uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
#endif
|
||||
|
||||
void __attribute__((noinline, cold))
|
||||
WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) {
|
||||
bool should_warn;
|
||||
void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) {
|
||||
// Identity is published on App by the caller before the guard is built; read it back here.
|
||||
Component *component = App.get_current_component();
|
||||
// Component-less path always warns (the caller already checked the constant threshold).
|
||||
uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS;
|
||||
if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) {
|
||||
return; // Component's (possibly ratcheted) threshold not exceeded yet
|
||||
}
|
||||
// Component name if any, else the published source (owning script), else a generic label.
|
||||
const LogString *name;
|
||||
if (component != nullptr) {
|
||||
should_warn = component->should_warn_of_blocking(blocking_time);
|
||||
name = component->get_component_log_str();
|
||||
} else {
|
||||
should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller
|
||||
}
|
||||
if (should_warn) {
|
||||
ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms",
|
||||
component == nullptr ? LOG_STR_LITERAL("<null>") : LOG_STR_ARG(component->get_component_log_str()),
|
||||
blocking_time);
|
||||
name = App.get_current_source();
|
||||
if (name == nullptr)
|
||||
name = LOG_STR("a scheduled task");
|
||||
}
|
||||
ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name),
|
||||
blocking_time, threshold_ms);
|
||||
}
|
||||
|
||||
#ifdef USE_SETUP_PRIORITY_OVERRIDE
|
||||
|
||||
@@ -118,7 +118,7 @@ struct ComponentRuntimeStats {
|
||||
|
||||
// Cumulative sum of every record_time() duration since boot, across all
|
||||
// components. Used by Application::loop() to snapshot time spent inside
|
||||
// WarnIfComponentBlockingGuard (including guards constructed by the
|
||||
// LoopBlockingGuard (including guards constructed by the
|
||||
// scheduler at scheduler.cpp) so main-loop overhead accounting can
|
||||
// subtract scheduled-callback time from the before_loop_tasks_ wall time.
|
||||
static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -326,7 +326,7 @@ class Component {
|
||||
return component_source_lookup(this->component_source_index_);
|
||||
}
|
||||
|
||||
bool should_warn_of_blocking(uint32_t blocking_time);
|
||||
bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out);
|
||||
|
||||
protected:
|
||||
friend class Application;
|
||||
@@ -571,7 +571,7 @@ class Component {
|
||||
volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
friend class runtime_stats::RuntimeStatsCollector;
|
||||
friend class WarnIfComponentBlockingGuard;
|
||||
friend class LoopBlockingGuard;
|
||||
ComponentRuntimeStats runtime_stats_;
|
||||
#endif
|
||||
};
|
||||
@@ -619,59 +619,7 @@ class PollingComponent : public Component {
|
||||
uint32_t update_interval_;
|
||||
};
|
||||
|
||||
// millis() and micros() are available via hal.h
|
||||
|
||||
class WarnIfComponentBlockingGuard {
|
||||
public:
|
||||
WarnIfComponentBlockingGuard(Component *component, uint32_t start_time)
|
||||
: started_(start_time),
|
||||
component_(component)
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
,
|
||||
started_us_(micros())
|
||||
#endif
|
||||
{
|
||||
}
|
||||
|
||||
// Finish the timing operation and return the current time (millis)
|
||||
// Inlined: the fast path is just millis() + subtract + compare
|
||||
inline uint32_t HOT finish() {
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t elapsed_us = micros() - this->started_us_;
|
||||
// component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...))
|
||||
if (this->component_ != nullptr) {
|
||||
this->component_->runtime_stats_.record_time(elapsed_us);
|
||||
} else {
|
||||
// Still accumulate into the global counter so Application::loop() can subtract
|
||||
// this time from before_loop_tasks_ wall time.
|
||||
ComponentRuntimeStats::global_recorded_us += elapsed_us;
|
||||
}
|
||||
#endif
|
||||
uint32_t curr_time = MillisInternal::get();
|
||||
#ifndef USE_BENCHMARK
|
||||
// Fast path: compare against constant threshold in ms (computed at compile time from centiseconds)
|
||||
static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast<uint32_t>(WARN_IF_BLOCKING_OVER_CS) * 10U;
|
||||
uint32_t blocking_time = curr_time - this->started_;
|
||||
if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] {
|
||||
warn_blocking(this->component_, blocking_time);
|
||||
}
|
||||
#endif
|
||||
return curr_time;
|
||||
}
|
||||
|
||||
~WarnIfComponentBlockingGuard() = default;
|
||||
|
||||
protected:
|
||||
uint32_t started_;
|
||||
Component *component_;
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t started_us_;
|
||||
#endif
|
||||
|
||||
private:
|
||||
// Cold path for blocking warning - defined in component.cpp
|
||||
static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time);
|
||||
};
|
||||
// LoopBlockingGuard lives in application.h because it reads its state from App.
|
||||
|
||||
// Function to clear setup priority overrides after all components are set up
|
||||
// Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined
|
||||
|
||||
+11
-1
@@ -407,6 +407,17 @@ def preload_core_config(config, result) -> str:
|
||||
|
||||
CORE.name = conf[CONF_NAME]
|
||||
CORE.friendly_name = conf.get(CONF_FRIENDLY_NAME)
|
||||
# Record the node's area name now (substitutions are already resolved at this
|
||||
# point). storage.json is written before to_code() runs, so deferring this to
|
||||
# to_code() left the area as null in storage.json. The value here is the raw
|
||||
# post-substitution form (a plain string or a {name: ...} mapping). Assign
|
||||
# unconditionally (like friendly_name) so a config without an area never
|
||||
# inherits a stale value from a previous load in a long-running process, and
|
||||
# use .get() so a malformed mapping surfaces later as a proper validation
|
||||
# error rather than a KeyError here. to_code() sets it again from the
|
||||
# validated config, which yields the same name.
|
||||
area = conf.get(CONF_AREA)
|
||||
CORE.area = area.get(CONF_NAME) if isinstance(area, dict) else area
|
||||
CORE.data[KEY_CORE] = {}
|
||||
|
||||
if CONF_BUILD_PATH not in conf:
|
||||
@@ -760,7 +771,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
# Process areas
|
||||
all_areas: list[dict[str, str | core.ID]] = []
|
||||
if CONF_AREA in config:
|
||||
CORE.area = config[CONF_AREA][CONF_NAME]
|
||||
all_areas.append(config[CONF_AREA])
|
||||
all_areas.extend(config[CONF_AREAS])
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace esphome {
|
||||
|
||||
// Friend-gated accessor for a fast millis() variant intended only for
|
||||
// known task-context callers on the main loop hot path (Application::loop()
|
||||
// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context
|
||||
// and LoopBlockingGuard::finish()). It skips the ISR-context
|
||||
// dispatch that the public esphome::millis() pays on ESP32 and libretiny.
|
||||
//
|
||||
// MUST NOT be called from ISR context: on ESP32 and libretiny it calls the
|
||||
@@ -50,7 +50,7 @@ class MillisInternal {
|
||||
#endif
|
||||
}
|
||||
friend class Application;
|
||||
friend class WarnIfComponentBlockingGuard;
|
||||
friend class LoopBlockingGuard;
|
||||
};
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t
|
||||
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
|
||||
void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type,
|
||||
const char *static_name, uint32_t hash_or_id, uint32_t delay,
|
||||
std::function<void()> &&func, bool is_retry, bool skip_cancel) {
|
||||
std::function<void()> &&func, bool is_retry, bool skip_cancel,
|
||||
const LogString *source) {
|
||||
if (delay == SCHEDULER_DONT_RUN) {
|
||||
// Still need to cancel existing timer if we have a name/id
|
||||
if (!skip_cancel) {
|
||||
@@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
|
||||
|
||||
// Create and populate the scheduler item
|
||||
SchedulerItem *item = this->get_item_from_pool_locked_();
|
||||
item->component = component;
|
||||
// SELF_POINTER items store the source name (owning script) in the union slot instead of a component.
|
||||
if (name_type == NameType::SELF_POINTER) {
|
||||
item->source_name = source;
|
||||
} else {
|
||||
item->component = component;
|
||||
}
|
||||
item->set_name(name_type, static_name, hash_or_id);
|
||||
item->type = type;
|
||||
// Use destroy + placement-new instead of move-assignment.
|
||||
@@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
// Not reached timeout yet, done for this call
|
||||
break;
|
||||
}
|
||||
// Don't run on failed components
|
||||
if (item->component != nullptr && item->component->is_failed()) {
|
||||
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
|
||||
if (this->is_item_failed_(item)) {
|
||||
LockGuard guard{this->lock_};
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
continue;
|
||||
@@ -790,10 +796,21 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() {
|
||||
|
||||
// Helper to execute a scheduler item
|
||||
uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) {
|
||||
App.set_current_component(item->component);
|
||||
// Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time.
|
||||
App.set_loop_component_start_time_(now);
|
||||
WarnIfComponentBlockingGuard guard{item->component, now};
|
||||
// Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared
|
||||
// union slot with a single name-type check. Self-keyed items have no owning component; their slot
|
||||
// holds the source name (e.g. the owning script), published so deferred work chained inside the
|
||||
// callback re-captures it and the blocking warning can name the script instead of "<null>".
|
||||
Component *component;
|
||||
const LogString *source;
|
||||
if (item->get_name_type() == NameType::SELF_POINTER) {
|
||||
component = nullptr;
|
||||
source = item->source_name;
|
||||
} else {
|
||||
component = item->component;
|
||||
source = nullptr;
|
||||
}
|
||||
// Guard publishes the item's identity + dispatch time, then times the callback.
|
||||
LoopBlockingGuard guard{component, source, now};
|
||||
item->callback();
|
||||
uint32_t end = guard.finish();
|
||||
// Feed the watchdog after each scheduled item (both main heap and defer
|
||||
|
||||
+28
-11
@@ -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.
|
||||
|
||||
+22
-12
@@ -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
|
||||
@@ -332,16 +337,19 @@ print(".".join([str(x) for x in sys.version_info]))
|
||||
|
||||
|
||||
_GITHUB_SHORTHAND_RE = re.compile(
|
||||
r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:@([a-zA-Z0-9\-_.\./]+))?$"
|
||||
r"^github://([a-zA-Z0-9\-]+)/([a-zA-Z0-9\-\._]+?)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$"
|
||||
)
|
||||
_GITHUB_HTTPS_RE = re.compile(
|
||||
r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:@([a-zA-Z0-9\-_.\./]+))?$"
|
||||
r"^(https://github\.com/[a-zA-Z0-9\-]+/[a-zA-Z0-9\-\._]+?\.git)(?:[@#]([a-zA-Z0-9\-_.\./]+))?$"
|
||||
)
|
||||
|
||||
|
||||
def _parse_git_source(source_url: str) -> tuple[str, str | None] | None:
|
||||
"""Return ``(url, ref)`` for ``github://owner/repo[@ref]`` or
|
||||
``https://github.com/owner/repo.git[@ref]``, else ``None``."""
|
||||
``https://github.com/owner/repo.git[@ref]``, else ``None``.
|
||||
|
||||
The ref may be separated with ``@`` or ``#``; ``#`` matches the PlatformIO
|
||||
convention used for ``platform_version`` URLs."""
|
||||
if m := _GITHUB_SHORTHAND_RE.match(source_url):
|
||||
owner, repo, ref = m.group(1), m.group(2), m.group(3)
|
||||
# Tolerate a trailing ".git" on the shorthand repo so the
|
||||
@@ -604,14 +612,16 @@ def _check_esphome_idf_framework_install(
|
||||
install = True
|
||||
if _check_stamp(env_stamp_file, stamp_info):
|
||||
_LOGGER.info("Checking ESP-IDF %s framework installation ...", version)
|
||||
cmd = [
|
||||
get_system_python_path(),
|
||||
str(idf_tools_path),
|
||||
"--non-interactive",
|
||||
"check",
|
||||
]
|
||||
if run_command_ok(cmd, msg=f"ESP-IDF {version} check", env=env):
|
||||
# Validate via the managed tool-path resolution, not ``idf_tools.py check``:
|
||||
# ``check`` probes tools on the system PATH and aborts if any fail to run (e.g. a
|
||||
# broken Homebrew openocd), which forced a toolchain reinstall on every build.
|
||||
try:
|
||||
_get_idf_tool_paths(framework_path, env)
|
||||
install = False
|
||||
except RuntimeError as err:
|
||||
_LOGGER.debug(
|
||||
"ESP-IDF %s tool resolution failed, reinstalling: %s", version, err
|
||||
)
|
||||
|
||||
# 4. Install framework tools if not installed or needs update
|
||||
if install:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import MutableMapping
|
||||
from contextlib import suppress
|
||||
import ipaddress
|
||||
import logging
|
||||
@@ -374,6 +375,26 @@ def is_ha_addon():
|
||||
return get_bool_env("ESPHOME_IS_HA_ADDON")
|
||||
|
||||
|
||||
def add_git_ceiling_directory(env: MutableMapping[str, str], directory: Path) -> None:
|
||||
"""Add ``directory`` to ``env``'s ``GIT_CEILING_DIRECTORIES`` list.
|
||||
|
||||
Git stops walking up the directory tree to find a repository once it reaches
|
||||
a ceiling directory, so this caps the search at ``directory`` (the ESPHome
|
||||
project root). Without it, an uninitialized or corrupt git repo in a parent
|
||||
directory makes the ``git describe`` that build toolchains run for the app
|
||||
version error out and fail the whole build.
|
||||
|
||||
``GIT_CEILING_DIRECTORIES`` is an ``os.pathsep``-joined list of absolute
|
||||
paths; any existing entries are preserved and duplicates are skipped.
|
||||
"""
|
||||
ceiling = str(directory)
|
||||
existing = env.get("GIT_CEILING_DIRECTORIES", "")
|
||||
parts = existing.split(os.pathsep) if existing else []
|
||||
if ceiling not in parts:
|
||||
parts.append(ceiling)
|
||||
env["GIT_CEILING_DIRECTORIES"] = os.pathsep.join(parts)
|
||||
|
||||
|
||||
def rmtree(path: Path | str) -> None:
|
||||
"""Remove a directory tree, handling read-only files on Windows.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ dependencies:
|
||||
esphome/micro-flac:
|
||||
version: 0.2.0
|
||||
esphome/micro-mp3:
|
||||
version: 0.2.1
|
||||
version: 0.2.3
|
||||
esphome/micro-opus:
|
||||
version: 0.4.1
|
||||
esphome/micro-wav:
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ esptool==5.3.0
|
||||
click==8.3.3
|
||||
esphome-dashboard==20260425.0
|
||||
aioesphomeapi==45.3.1
|
||||
zeroconf==0.149.16
|
||||
zeroconf==0.150.0
|
||||
puremagic==1.30
|
||||
ruamel.yaml==0.19.1 # dashboard_import
|
||||
ruamel.yaml.clib==0.2.15 # dashboard_import
|
||||
|
||||
+15
-6
@@ -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:
|
||||
|
||||
@@ -951,6 +951,15 @@ def convert(schema, config_var, path):
|
||||
elif schema_type == "enum":
|
||||
config_var[S_TYPE] = "enum"
|
||||
config_var["values"] = dict.fromkeys(list(data.keys()))
|
||||
elif schema_type == "variant_enum":
|
||||
# Per-variant enum (e.g. psram mode/speed): each value carries the
|
||||
# list of variants that accept it so clients can filter to the
|
||||
# user's selected variant. Additive to the plain enum format —
|
||||
# consumers that ignore the metadata still see every option.
|
||||
config_var[S_TYPE] = "enum"
|
||||
config_var["values"] = {
|
||||
value: {"variants": variants} for value, variants in data.items()
|
||||
}
|
||||
elif schema_type == "maybe":
|
||||
# maybe_simple_value: either a scalar shorthand (mapped to the key in
|
||||
# data[1]) or the full wrapped schema. The wrapped schema is usually a
|
||||
|
||||
@@ -306,6 +306,50 @@ def test_all_predefined_models(
|
||||
run_schema_validation(config)
|
||||
|
||||
|
||||
def test_single_bus_no_cs_no_mode_warns(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A single-bus display with no CS pin and no explicit SPI mode warns about MODE3 default."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
run_schema_validation({"model": "ili9488", "dc_pin": 14})
|
||||
|
||||
assert "defaulting to MODE3 due to lack of CS pin" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"config",
|
||||
[
|
||||
pytest.param(
|
||||
{"model": "ili9488", "dc_pin": 14, "cs_pin": 0},
|
||||
id="cs_pin_provided",
|
||||
),
|
||||
pytest.param(
|
||||
{"model": "ili9488", "dc_pin": 14, "spi_mode": "mode0"},
|
||||
id="spi_mode_provided",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_single_bus_no_mode_warning_suppressed(
|
||||
config: ConfigType,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""No MODE3 warning when a CS pin or an explicit SPI mode is provided."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
run_schema_validation(config)
|
||||
|
||||
assert "defaulting to MODE3 due to lack of CS pin" not in caplog.text
|
||||
|
||||
|
||||
def test_native_generation(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_fixture_path: Callable[[str], Path],
|
||||
@@ -333,6 +377,6 @@ def test_lvgl_generation(
|
||||
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, 0, 0, true>();"
|
||||
in main_cpp
|
||||
)
|
||||
assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp
|
||||
assert "set_init_sequence({177, 3, 1, 44, 45, 178" in main_cpp
|
||||
assert "show_test_card();" not in main_cpp
|
||||
assert "set_auto_clear(false);" in main_cpp
|
||||
|
||||
@@ -13,6 +13,16 @@ from esphome.components.esp32 import (
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
from esphome.components.mipi import (
|
||||
CONF_DIMENSIONS,
|
||||
CONF_HEIGHT,
|
||||
CONF_MIRROR_X,
|
||||
CONF_MIRROR_Y,
|
||||
CONF_OFFSET_HEIGHT,
|
||||
CONF_OFFSET_WIDTH,
|
||||
CONF_SWAP_XY,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.components.mipi_spi.display import (
|
||||
CONFIG_SCHEMA,
|
||||
FINAL_VALIDATE_SCHEMA,
|
||||
@@ -20,7 +30,13 @@ from esphome.components.mipi_spi.display import (
|
||||
get_instance,
|
||||
)
|
||||
from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
|
||||
from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework
|
||||
from esphome.const import (
|
||||
CONF_CS_PIN,
|
||||
CONF_DC_PIN,
|
||||
CONF_DISABLED,
|
||||
CONF_TRANSFORM,
|
||||
PlatformFramework,
|
||||
)
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
@@ -432,3 +448,152 @@ class TestUserConfiguredPadding:
|
||||
assert config["dimensions"]["width"] == 240
|
||||
assert config["dimensions"]["height"] == 240
|
||||
assert config["dimensions"]["pad_height"] == 16
|
||||
|
||||
|
||||
class TestHasHardwareTransform:
|
||||
"""Test DriverChip.has_hardware_transform()."""
|
||||
|
||||
def test_full_transform_model_without_transform_key(self) -> None:
|
||||
"""A model supporting swap_xy uses a hardware transform by default."""
|
||||
model = MODELS["ST7789V"]
|
||||
assert model.has_hardware_transform({}) is True
|
||||
|
||||
def test_full_transform_model_with_transform_dict(self) -> None:
|
||||
"""A configured (non-disabled) transform still uses the hardware path."""
|
||||
model = MODELS["ST7789V"]
|
||||
assert (
|
||||
model.has_hardware_transform({CONF_TRANSFORM: {CONF_SWAP_XY: True}}) is True
|
||||
)
|
||||
|
||||
def test_full_transform_model_with_transform_disabled(self) -> None:
|
||||
"""Disabling the transform falls back to software transforms."""
|
||||
model = MODELS["ST7789V"]
|
||||
assert model.has_hardware_transform({CONF_TRANSFORM: CONF_DISABLED}) is False
|
||||
|
||||
def test_model_without_swap_xy_support(self) -> None:
|
||||
"""Models that cannot swap axes never use a hardware transform."""
|
||||
# AXS15231 only supports mirror_x/mirror_y, not swap_xy.
|
||||
model = MODELS["AXS15231"]
|
||||
assert model.transforms == {CONF_MIRROR_X, CONF_MIRROR_Y}
|
||||
assert model.has_hardware_transform({}) is False
|
||||
|
||||
|
||||
class TestSwapXYNativeDimensions:
|
||||
"""Test that native dimensions are swapped when a swap_xy transform is active.
|
||||
|
||||
When explicit dimensions are given in the swapped (rotated) orientation and the
|
||||
model applies a hardware swap_xy transform, the model's native_width/native_height
|
||||
defaults must be swapped to match, otherwise padding is computed against the wrong
|
||||
axis and validation fails.
|
||||
"""
|
||||
|
||||
def test_explicit_swapped_dimensions_with_swap_xy_transform(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Explicit landscape dimensions on a portrait-native model with swap_xy."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# ST7789V is natively 240x320 (portrait). Provide landscape dimensions
|
||||
# together with a swap_xy transform.
|
||||
model = MODELS["ST7789V"]
|
||||
assert model.get_default("native_width") == 240
|
||||
assert model.get_default("native_height") == 320
|
||||
|
||||
config = {
|
||||
"model": "ST7789V",
|
||||
CONF_DIMENSIONS: {
|
||||
CONF_WIDTH: 320,
|
||||
CONF_HEIGHT: 240,
|
||||
CONF_OFFSET_WIDTH: 0,
|
||||
CONF_OFFSET_HEIGHT: 0,
|
||||
},
|
||||
CONF_TRANSFORM: {
|
||||
CONF_SWAP_XY: True,
|
||||
CONF_MIRROR_X: False,
|
||||
CONF_MIRROR_Y: False,
|
||||
},
|
||||
}
|
||||
|
||||
# swap=False because the buffer is laid out in the requested orientation.
|
||||
width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions(
|
||||
config, swap=False
|
||||
)
|
||||
# Native dims are swapped to 320x240, so padding works out to zero rather
|
||||
# than going negative (which previously raised "Invalid offsets").
|
||||
assert (width, height) == (320, 240)
|
||||
assert (offset_w, offset_h) == (0, 0)
|
||||
assert (pad_w, pad_h) == (0, 0)
|
||||
|
||||
def test_explicit_dimensions_without_swap_keeps_native_orientation(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Without swap_xy the native dimensions keep their original orientation."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
model = MODELS["ST7789V"]
|
||||
config = {
|
||||
"model": "ST7789V",
|
||||
CONF_DIMENSIONS: {
|
||||
CONF_WIDTH: 240,
|
||||
CONF_HEIGHT: 320,
|
||||
CONF_OFFSET_WIDTH: 0,
|
||||
CONF_OFFSET_HEIGHT: 0,
|
||||
},
|
||||
CONF_TRANSFORM: {
|
||||
CONF_SWAP_XY: False,
|
||||
CONF_MIRROR_X: False,
|
||||
CONF_MIRROR_Y: False,
|
||||
},
|
||||
}
|
||||
|
||||
width, height, offset_w, offset_h, pad_w, pad_h = model.get_dimensions(
|
||||
config, swap=False
|
||||
)
|
||||
assert (width, height) == (240, 320)
|
||||
assert (offset_w, offset_h) == (0, 0)
|
||||
assert (pad_w, pad_h) == (0, 0)
|
||||
|
||||
def test_swapped_native_dimensions_compute_padding(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Padding is derived from the swapped native size when swap_xy is active."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# ILI9341 is natively 240x320. Request a 300x240 area in landscape; the
|
||||
# swapped native size is 320x240, leaving 20px of horizontal padding.
|
||||
model = MODELS["ILI9341"]
|
||||
assert model.get_default("native_width") == 240
|
||||
assert model.get_default("native_height") == 320
|
||||
|
||||
config = {
|
||||
"model": "ILI9341",
|
||||
CONF_DIMENSIONS: {
|
||||
CONF_WIDTH: 300,
|
||||
CONF_HEIGHT: 240,
|
||||
CONF_OFFSET_WIDTH: 0,
|
||||
CONF_OFFSET_HEIGHT: 0,
|
||||
},
|
||||
CONF_TRANSFORM: {
|
||||
CONF_SWAP_XY: True,
|
||||
CONF_MIRROR_X: False,
|
||||
CONF_MIRROR_Y: False,
|
||||
},
|
||||
}
|
||||
|
||||
width, height, _, _, pad_w, pad_h = model.get_dimensions(config, swap=False)
|
||||
assert (width, height) == (300, 240)
|
||||
# native_width swapped to 320 -> pad_width = 320 - 300 - 0 = 20
|
||||
assert pad_w == 20
|
||||
assert pad_h == 0
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Combined tests for PAGESEL/PAGESEL1 behaviour with MADCTL/PIXFMT.
|
||||
|
||||
Covers both the suppression behaviour (when PAGESEL or PAGESEL1 are present)
|
||||
and the error behaviour when neither page-selection command is present.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.components.mipi import MADCTL, PAGESEL, PAGESEL1, PIXFMT
|
||||
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import PlatformFramework
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def validated_config(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Run schema + final validation and return the validated config."""
|
||||
cfg = CONFIG_SCHEMA(config)
|
||||
FINAL_VALIDATE_SCHEMA(cfg)
|
||||
return cfg
|
||||
|
||||
|
||||
def test_madctl_error_suppressed_when_pagesel_present(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""If PAGESEL is present in init_sequence, MADCTL presence must not raise an error."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
cfg = {
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
"transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False},
|
||||
"init_sequence": [[PAGESEL, 0x00], [MADCTL, 0x01]],
|
||||
}
|
||||
|
||||
# Should not raise
|
||||
validated = validated_config(cfg)
|
||||
assert validated is not None
|
||||
|
||||
|
||||
def test_pixfmt_error_suppressed_when_pagesel1_present(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""If PAGESEL1 is present in init_sequence, PIXFMT presence must not raise an error."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
cfg = {
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
"init_sequence": [[PAGESEL1, 0x00], [PIXFMT, 0x01]],
|
||||
}
|
||||
|
||||
# Should not raise
|
||||
validated = validated_config(cfg)
|
||||
assert validated is not None
|
||||
|
||||
|
||||
def test_madctl_raises_without_pagesel(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""MADCTL in the init_sequence should raise when a transform is configured and
|
||||
no PAGESEL/PAGESEL1 is present.
|
||||
"""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
cfg: dict[str, Any] = {
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
"transform": {"mirror_x": True, "mirror_y": True, "swap_xy": False},
|
||||
"init_sequence": [[MADCTL, 0x01]],
|
||||
}
|
||||
|
||||
with pytest.raises(cv.Invalid, match=r"MADCTL .* in the init sequence"):
|
||||
CONFIG_SCHEMA(cfg)
|
||||
|
||||
|
||||
def test_pixfmt_raises_without_pagesel1(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""PIXFMT in the init_sequence should raise when no PAGESEL/PAGESEL1 is present."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
cfg: dict[str, Any] = {
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
"init_sequence": [[PIXFMT, 0x01]],
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
cv.Invalid, match=r"PIXFMT .* should not be in the init sequence"
|
||||
):
|
||||
CONFIG_SCHEMA(cfg)
|
||||
@@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants(
|
||||
FINAL_VALIDATE_SCHEMA(config)
|
||||
|
||||
|
||||
def test_psram_applies_single_mode_default(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""On a single-mode variant the omitted mode/speed fall back to defaults."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_VARIANT: VARIANT_ESP32},
|
||||
full_config={CONF_ESPHOME: {}},
|
||||
)
|
||||
from esphome.components.psram import CONFIG_SCHEMA
|
||||
|
||||
config = CONFIG_SCHEMA({})
|
||||
assert config["mode"] == "quad"
|
||||
assert config["speed"] == "40MHZ"
|
||||
assert config["disabled"] is False
|
||||
assert config["ignore_not_found"] is True
|
||||
|
||||
|
||||
def test_psram_requires_mode_on_multi_mode_variant(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A variant with multiple modes requires an explicit mode selection."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_VARIANT: VARIANT_ESP32S3},
|
||||
full_config={CONF_ESPHOME: {}},
|
||||
)
|
||||
from esphome.components.psram import CONFIG_SCHEMA
|
||||
|
||||
with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"):
|
||||
CONFIG_SCHEMA({})
|
||||
|
||||
|
||||
def test_psram_rejects_mode_invalid_for_variant(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A mode not supported by the active variant is rejected by the schema."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_VARIANT: VARIANT_ESP32},
|
||||
full_config={CONF_ESPHOME: {}},
|
||||
)
|
||||
from esphome.components.psram import CONFIG_SCHEMA
|
||||
|
||||
with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"):
|
||||
CONFIG_SCHEMA({"mode": "octal"})
|
||||
|
||||
|
||||
def _setup_psram_final_validation_test(
|
||||
esp32_config: dict,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
|
||||
@@ -77,3 +77,34 @@ esp32_ble_server:
|
||||
id: test_change_descriptor
|
||||
value:
|
||||
data: [0x01, 0x02, 0x03]
|
||||
|
||||
# Regression test for #17142: the set_value action used from a trigger that passes
|
||||
# its argument by reference (climate on_control supplies ClimateCall&) previously
|
||||
# failed to compile.
|
||||
sensor:
|
||||
- platform: template
|
||||
id: ble_test_temp
|
||||
lambda: "return 20.0;"
|
||||
|
||||
output:
|
||||
- platform: template
|
||||
id: ble_test_output
|
||||
type: float
|
||||
write_action:
|
||||
- logger.log: "out"
|
||||
|
||||
climate:
|
||||
- platform: pid
|
||||
name: "BLE Test Climate"
|
||||
id: ble_test_climate
|
||||
sensor: ble_test_temp
|
||||
default_target_temperature: 20
|
||||
heat_output: ble_test_output
|
||||
control_parameters:
|
||||
kp: 0.1
|
||||
ki: 0.001
|
||||
kd: 0.1
|
||||
on_control:
|
||||
- ble_server.characteristic.set_value:
|
||||
id: test_notify_characteristic
|
||||
value: !lambda "return std::vector<uint8_t>{0, 1, 2};"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses
|
||||
# octal; this exercises the other branch of the per-variant mode enum (quad) and
|
||||
# lets speed fall back to its 40MHz default.
|
||||
psram:
|
||||
mode: quad
|
||||
@@ -0,0 +1,4 @@
|
||||
# Config-only: with no options the single-mode ESP32 resolves mode -> quad and
|
||||
# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here,
|
||||
# so this only runs through `esphome config`.
|
||||
psram:
|
||||
@@ -0,0 +1,4 @@
|
||||
# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz).
|
||||
# With no options it resolves mode -> hex and speed -> 20MHz, exercising the
|
||||
# P4-specific default branch of the per-variant enums.
|
||||
psram:
|
||||
@@ -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);
|
||||
@@ -0,0 +1,22 @@
|
||||
esphome:
|
||||
name: scheduler-blocking-warning
|
||||
on_boot:
|
||||
then:
|
||||
- script.execute: blocking_script
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
# The busy-block runs in the second delay's continuation; the warning must name the script. Two
|
||||
# delays verify the source survives chained delays (the scheduler republishes it each continuation).
|
||||
script:
|
||||
- id: blocking_script
|
||||
then:
|
||||
- delay: 10ms
|
||||
- delay: 10ms
|
||||
- lambda: |-
|
||||
const uint32_t start = millis();
|
||||
while (millis() - start < 80) {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
esphome:
|
||||
name: scheduler-blocking-generic
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
globals:
|
||||
- id: done
|
||||
type: bool
|
||||
restore_value: false
|
||||
initial_value: "false"
|
||||
|
||||
# A delay in a plain (non-script) automation has no owning script, so the block must log the
|
||||
# generic "a scheduled task" label, not a script name.
|
||||
interval:
|
||||
- interval: 100ms
|
||||
id: gen_interval
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: "return !id(done);"
|
||||
then:
|
||||
- lambda: "id(done) = true;"
|
||||
- delay: 10ms
|
||||
- lambda: |-
|
||||
const uint32_t start = millis();
|
||||
while (millis() - start < 80) {
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
esphome:
|
||||
name: scheduler-delay-failed
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
globals:
|
||||
- id: started
|
||||
type: bool
|
||||
restore_value: false
|
||||
initial_value: "false"
|
||||
|
||||
# The interval marks itself failed, then schedules a delay. The delay must still fire: a failed
|
||||
# component must not drop it, since the SELF_POINTER scheduler item has no owning component.
|
||||
interval:
|
||||
- interval: 100ms
|
||||
id: host_interval
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: "return !id(started);"
|
||||
then:
|
||||
- lambda: |-
|
||||
id(started) = true;
|
||||
id(host_interval)->mark_failed();
|
||||
- delay: 200ms
|
||||
- logger.log: "DELAY_FIRED_AFTER_FAIL"
|
||||
@@ -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)}"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -139,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None:
|
||||
assert "foo" in config_var["schema"]["config_vars"]
|
||||
|
||||
|
||||
def test_convert_emits_variant_enum() -> None:
|
||||
"""A per-variant enum is dumped with each value tagged by its variants."""
|
||||
from esphome.components.esp32 import (
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32S3,
|
||||
variant_filtered_enum,
|
||||
)
|
||||
|
||||
validator = variant_filtered_enum(
|
||||
{VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")},
|
||||
lower=True,
|
||||
)
|
||||
config_var: dict = {}
|
||||
_bls.convert(validator, config_var, "/test")
|
||||
|
||||
assert config_var["type"] == "enum"
|
||||
assert config_var["values"] == {
|
||||
"quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]},
|
||||
"octal": {"variants": [VARIANT_ESP32S3]},
|
||||
}
|
||||
|
||||
|
||||
def test_convert_keys_emits_heuristic_sensitive_marker() -> None:
|
||||
converted: dict = {}
|
||||
_bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user