mirror of
https://github.com/esphome/esphome.git
synced 2026-06-25 02:49:13 +00:00
Compare commits
49 Commits
2026.6.0b1
...
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
94b248527d | ||
|
|
a46aa594b3 | ||
|
|
99425e3a97 | ||
|
|
f83e3ad6a6 | ||
|
|
c768e2eabc | ||
|
|
9ffd350095 | ||
|
|
26ccaf70db | ||
|
|
20925b3220 | ||
|
|
83504d2de2 |
@@ -1 +1 @@
|
||||
442b8197be00e6fee6b1b64b07a0e3b3558188fddf1d9c510565da884687c451
|
||||
72f02816e288b68ff4ef4b3d6fb66432c893b187a80ad3ebaa29afa443ff9ea6
|
||||
|
||||
7
.github/actions/build-image/action.yaml
vendored
7
.github/actions/build-image/action.yaml
vendored
@@ -15,11 +15,6 @@ inputs:
|
||||
description: "Version to build"
|
||||
required: true
|
||||
example: "2023.12.0"
|
||||
base_os:
|
||||
description: "Base OS to use"
|
||||
required: false
|
||||
default: "debian"
|
||||
example: "debian"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
@@ -60,7 +55,6 @@ runs:
|
||||
build-args: |
|
||||
BUILD_TYPE=${{ inputs.build_type }}
|
||||
BUILD_VERSION=${{ inputs.version }}
|
||||
BUILD_OS=${{ inputs.base_os }}
|
||||
outputs: |
|
||||
type=image,name=ghcr.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
@@ -86,7 +80,6 @@ runs:
|
||||
build-args: |
|
||||
BUILD_TYPE=${{ inputs.build_type }}
|
||||
BUILD_VERSION=${{ inputs.version }}
|
||||
BUILD_OS=${{ inputs.base_os }}
|
||||
outputs: |
|
||||
type=image,name=docker.io/${{ steps.tags.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
|
||||
|
||||
|
||||
84
.github/workflows/ci-docker.yml
vendored
84
.github/workflows/ci-docker.yml
vendored
@@ -22,7 +22,7 @@ on:
|
||||
- "script/platformio_install_deps.py"
|
||||
|
||||
permissions:
|
||||
contents: read # actions/checkout only; the build does not push images
|
||||
contents: read # actions/checkout only
|
||||
|
||||
concurrency:
|
||||
# yamllint disable-line rule:line-length
|
||||
@@ -33,6 +33,9 @@ jobs:
|
||||
check-docker:
|
||||
name: Build docker containers
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read # actions/checkout to load Dockerfile and build context
|
||||
packages: write # push branch-tagged images to ghcr.io for local testing
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -41,6 +44,9 @@ jobs:
|
||||
- "ha-addon"
|
||||
- "docker"
|
||||
# - "lint"
|
||||
outputs:
|
||||
tag: ${{ steps.tag.outputs.tag }}
|
||||
push: ${{ steps.tag.outputs.push }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Python
|
||||
@@ -50,14 +56,82 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Set TAG
|
||||
- name: Determine tag and whether to push
|
||||
id: tag
|
||||
run: |
|
||||
echo "TAG=check" >> $GITHUB_ENV
|
||||
# Sanitize the branch name into a valid docker tag: replace invalid
|
||||
# characters, ensure the first character is valid (tags must start
|
||||
# with [A-Za-z0-9_]), and cap the length at 128 characters.
|
||||
branch="${{ github.head_ref || github.ref_name }}"
|
||||
tag="${branch//[^a-zA-Z0-9_.-]/-}"
|
||||
case "$tag" in
|
||||
[a-zA-Z0-9_]*) ;;
|
||||
*) tag="pr-${tag}" ;;
|
||||
esac
|
||||
tag="${tag:0:128}"
|
||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
# Only push branch images for same-repo pull requests. Push events
|
||||
# only fire for dev/beta/release, whose images are owned by the
|
||||
# release pipeline -- never overwrite those from here.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] \
|
||||
&& [ "${{ github.repository }}" = "esphome/esphome" ] \
|
||||
&& [ "${{ github.event.pull_request.head.repo.full_name }}" = "esphome/esphome" ]; then
|
||||
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
if: steps.tag.outputs.push == 'true'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Run build
|
||||
run: |
|
||||
docker/build.py \
|
||||
--tag "${TAG}" \
|
||||
--tag "${{ steps.tag.outputs.tag }}" \
|
||||
--arch "${{ matrix.os == 'ubuntu-24.04-arm' && 'aarch64' || 'amd64' }}" \
|
||||
--build-type "${{ matrix.build_type }}" \
|
||||
build
|
||||
--registry ghcr \
|
||||
build ${{ steps.tag.outputs.push == 'true' && '--push --no-cache-to' || '' }}
|
||||
|
||||
manifest:
|
||||
name: Push ${{ matrix.build_type }} manifest to ghcr.io
|
||||
needs: [check-docker]
|
||||
if: needs.check-docker.outputs.push == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read # actions/checkout to run docker/build.py
|
||||
packages: write # buildx imagetools writes the multi-arch tag to ghcr.io
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
build_type:
|
||||
- "ha-addon"
|
||||
- "docker"
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Log in to the GitHub container registry
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest
|
||||
run: |
|
||||
docker/build.py \
|
||||
--tag "${{ needs.check-docker.outputs.tag }}" \
|
||||
--build-type "${{ matrix.build_type }}" \
|
||||
--registry ghcr \
|
||||
manifest
|
||||
|
||||
2
Doxyfile
2
Doxyfile
@@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome
|
||||
# could be handy for archiving the generated documentation or if some version
|
||||
# control system is used.
|
||||
|
||||
PROJECT_NUMBER = 2026.6.0b1
|
||||
PROJECT_NUMBER = 2026.6.2
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
ARG BUILD_VERSION=dev
|
||||
ARG BUILD_OS=alpine
|
||||
ARG BUILD_BASE_VERSION=2025.04.0
|
||||
ARG BUILD_BASE_VERSION=2026.06.0
|
||||
ARG BUILD_TYPE=docker
|
||||
|
||||
FROM ghcr.io/esphome/docker-base:${BUILD_OS}-${BUILD_BASE_VERSION} AS base-source-docker
|
||||
FROM ghcr.io/esphome/docker-base:${BUILD_OS}-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon
|
||||
FROM ghcr.io/esphome/docker-base:debian-${BUILD_BASE_VERSION} AS base-source-docker
|
||||
FROM ghcr.io/esphome/docker-base:debian-ha-addon-${BUILD_BASE_VERSION} AS base-source-ha-addon
|
||||
|
||||
ARG BUILD_TYPE
|
||||
FROM base-source-${BUILD_TYPE} AS base
|
||||
@@ -18,13 +17,9 @@ RUN git config --system --add safe.directory "*" \
|
||||
# validate openocd-esp32 (it dynamically links libusb-1.0.so.0); without
|
||||
# it idf_tools.py rejects the openocd install with exit 127 and aborts
|
||||
# the whole framework setup.
|
||||
RUN if command -v apk > /dev/null; then \
|
||||
apk add --no-cache build-base libusb; \
|
||||
else \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*; \
|
||||
fi
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends build-essential libusb-1.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
ENV PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
@@ -36,6 +31,9 @@ RUN \
|
||||
uv pip install --no-cache-dir \
|
||||
-r /requirements.txt
|
||||
|
||||
# Install the ESPHome Device Builder dashboard.
|
||||
RUN uv pip install --no-cache-dir esphome-device-builder==1.0.12
|
||||
|
||||
RUN \
|
||||
platformio settings set enable_telemetry No \
|
||||
&& platformio settings set check_platformio_interval 1000000 \
|
||||
|
||||
@@ -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
|
||||
@@ -504,6 +504,12 @@ def has_resolvable_address() -> bool:
|
||||
if has_ip_address():
|
||||
return True
|
||||
|
||||
# The dashboard pre-resolves the device and passes the IPs via
|
||||
# --mdns-address-cache/--dns-address-cache; honor a cached address even when the
|
||||
# device has mDNS disabled (e.g. a .local host found via ping).
|
||||
if CORE.address_cache and CORE.address_cache.get_addresses(CORE.address):
|
||||
return True
|
||||
|
||||
if has_mdns():
|
||||
return True
|
||||
|
||||
@@ -1765,6 +1771,21 @@ def command_update_all(args: ArgsProtocol) -> int | None:
|
||||
def command_idedata(args: ArgsProtocol, config: ConfigType) -> int:
|
||||
import json
|
||||
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# Native ESP-IDF derives idedata from the build's compile_commands.json,
|
||||
# so the configuration must already be compiled.
|
||||
from esphome.espidf import toolchain as espidf_toolchain
|
||||
|
||||
idedata = espidf_toolchain.get_idedata()
|
||||
if idedata is None:
|
||||
_LOGGER.error(
|
||||
"No idedata available; compile the configuration first",
|
||||
)
|
||||
return 1
|
||||
|
||||
print(json.dumps(idedata, indent=2) + "\n")
|
||||
return 0
|
||||
|
||||
if not CORE.using_toolchain_platformio:
|
||||
_LOGGER.error(
|
||||
"The idedata command is not compatible with %s toolchain",
|
||||
|
||||
@@ -395,7 +395,7 @@ async def to_code(config):
|
||||
)
|
||||
if data.mp3_support:
|
||||
cg.add_define("USE_AUDIO_MP3_SUPPORT")
|
||||
add_idf_component(name="esphome/micro-mp3", ref="0.2.1")
|
||||
add_idf_component(name="esphome/micro-mp3", ref="0.2.3")
|
||||
_emit_memory_pair(
|
||||
data.mp3.buffer_memory,
|
||||
"CONFIG_MP3_DECODER_PREFER_PSRAM",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from collections.abc import Callable, Iterable
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
import itertools
|
||||
@@ -6,6 +7,7 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from esphome import yaml_util
|
||||
import esphome.codegen as cg
|
||||
@@ -52,6 +54,7 @@ from esphome.coroutine import CoroPriority, coroutine_with_priority
|
||||
from esphome.espidf.component import generate_idf_components
|
||||
import esphome.final_validate as fv
|
||||
from esphome.helpers import copy_file_if_changed, rmtree, write_file_if_changed
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.types import ConfigType
|
||||
from esphome.writer import clean_build, clean_cmake_cache
|
||||
|
||||
@@ -496,6 +499,32 @@ def get_esp32_variant(core_obj=None):
|
||||
return (core_obj or CORE).data[KEY_ESP32][KEY_VARIANT]
|
||||
|
||||
|
||||
def variant_filtered_enum(
|
||||
by_variant: dict[str, Iterable[Any]], **kwargs: Any
|
||||
) -> Callable[[Any], Any]:
|
||||
"""Build a ``one_of`` validator whose valid set depends on the active variant.
|
||||
|
||||
``by_variant`` maps each ESP32 variant constant to the iterable of values that
|
||||
are valid on that variant. At validation time the value is checked against the
|
||||
set allowed for the current target variant. For schema extraction the inverted
|
||||
``{value: [variants, ...]}`` map is returned instead, so the language-schema
|
||||
dump can tag every option with the variants that accept it and frontends can
|
||||
filter to the user's selected variant.
|
||||
"""
|
||||
by_value: dict[str, list[str]] = {}
|
||||
for variant, values in by_variant.items():
|
||||
for value in values:
|
||||
by_value.setdefault(str(value), []).append(variant)
|
||||
|
||||
@schema_extractor("variant_enum")
|
||||
def validator(value: Any) -> Any:
|
||||
if value is SCHEMA_EXTRACT:
|
||||
return by_value
|
||||
return cv.one_of(*by_variant.get(get_esp32_variant(), ()), **kwargs)(value)
|
||||
|
||||
return validator
|
||||
|
||||
|
||||
def get_board(core_obj=None):
|
||||
return (core_obj or CORE).data[KEY_ESP32][KEY_BOARD]
|
||||
|
||||
@@ -1615,8 +1644,14 @@ FLASH_SIZES = [
|
||||
]
|
||||
|
||||
CONF_FLASH_SIZE = "flash_size"
|
||||
CONF_FLASH_MODE = "flash_mode"
|
||||
CONF_FLASH_FREQUENCY = "flash_frequency"
|
||||
CONF_CPU_FREQUENCY = "cpu_frequency"
|
||||
CONF_PARTITIONS = "partitions"
|
||||
FLASH_MODES = ["qio", "qout", "dio", "dout", "opi"]
|
||||
FLASH_FREQUENCIES = [
|
||||
f"{freq}MHZ" for freq in (120, 80, 64, 60, 48, 40, 32, 30, 26, 24, 20, 16)
|
||||
]
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
cv.Schema(
|
||||
{
|
||||
@@ -1630,6 +1665,10 @@ CONFIG_SCHEMA = cv.All(
|
||||
cv.Optional(CONF_FLASH_SIZE, default="4MB"): cv.one_of(
|
||||
*FLASH_SIZES, upper=True
|
||||
),
|
||||
cv.Optional(CONF_FLASH_MODE): cv.one_of(*FLASH_MODES, lower=True),
|
||||
cv.Optional(CONF_FLASH_FREQUENCY): cv.one_of(
|
||||
*FLASH_FREQUENCIES, upper=True
|
||||
),
|
||||
cv.Optional(CONF_PARTITIONS): cv.Any(
|
||||
cv.file_,
|
||||
cv.ensure_list(
|
||||
@@ -1866,6 +1905,12 @@ async def to_code(config):
|
||||
"board_upload.maximum_size",
|
||||
int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024,
|
||||
)
|
||||
if flash_mode := config.get(CONF_FLASH_MODE):
|
||||
cg.add_platformio_option("board_build.flash_mode", flash_mode)
|
||||
if flash_frequency := config.get(CONF_FLASH_FREQUENCY):
|
||||
cg.add_platformio_option(
|
||||
"board_build.f_flash", f"{flash_frequency[:-3]}000000L"
|
||||
)
|
||||
|
||||
if CONF_SOURCE in conf:
|
||||
cg.add_platformio_option("platform_packages", [conf[CONF_SOURCE]])
|
||||
@@ -2016,6 +2061,14 @@ async def to_code(config):
|
||||
add_idf_sdkconfig_option(
|
||||
f"CONFIG_ESPTOOLPY_FLASHSIZE_{config[CONF_FLASH_SIZE]}", True
|
||||
)
|
||||
if flash_mode := config.get(CONF_FLASH_MODE):
|
||||
add_idf_sdkconfig_option(
|
||||
f"CONFIG_ESPTOOLPY_FLASHMODE_{flash_mode.upper()}", True
|
||||
)
|
||||
if flash_frequency := config.get(CONF_FLASH_FREQUENCY):
|
||||
add_idf_sdkconfig_option(
|
||||
f"CONFIG_ESPTOOLPY_FLASHFREQ_{flash_frequency[:-3]}M", True
|
||||
)
|
||||
|
||||
# ESP32-P4: ESP-IDF 5.5.3 changed the default of ESP32P4_SELECTS_REV_LESS_V3
|
||||
# from y to n. PlatformIO uses sections.ld.in (for rev <3) or
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import os
|
||||
|
||||
Import("env") # noqa: F821
|
||||
|
||||
# Remove custom_sdkconfig from the board config as it causes
|
||||
@@ -7,3 +9,8 @@ if "espidf.custom_sdkconfig" in board:
|
||||
del board._manifest["espidf"]["custom_sdkconfig"]
|
||||
if not board._manifest["espidf"]:
|
||||
del board._manifest["espidf"]
|
||||
|
||||
# Referenced by rules in esphome/idf_component.yml; an unset env var is a
|
||||
# fatal error there. Always 0: in PlatformIO builds arduino is not a managed
|
||||
# IDF component.
|
||||
os.environ.setdefault("ESPHOME_ARDUINO_COMPONENT", "0")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -492,6 +492,15 @@ def _parse_register(config, regex, line):
|
||||
STACKTRACE_ESP8266_EXCEPTION_TYPE_RE = re.compile(r"[eE]xception \((\d+)\):")
|
||||
STACKTRACE_ESP8266_PC_RE = re.compile(r"epc1=0x(4[0-9a-fA-F]{7})")
|
||||
STACKTRACE_ESP8266_EXCVADDR_RE = re.compile(r"excvaddr=0x(4[0-9a-fA-F]{7})")
|
||||
# Structured crash handler output (crash_handler.cpp) from a previous boot:
|
||||
# PC: 0x40220060
|
||||
# EXCVADDR: 0x0000008A
|
||||
# BT0: 0x40212345
|
||||
STACKTRACE_ESP8266_CRASH_PC_RE = re.compile(r".*PC\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})")
|
||||
STACKTRACE_ESP8266_CRASH_EXCVADDR_RE = re.compile(
|
||||
r".*EXCVADDR\s*:\s*(?:0x)?(4[0-9a-fA-F]{7})"
|
||||
)
|
||||
STACKTRACE_ESP8266_CRASH_BT_RE = re.compile(r"BT\d+:\s*0x([0-9a-fA-F]{8})")
|
||||
STACKTRACE_BAD_ALLOC_RE = re.compile(
|
||||
r"^last failed alloc call: (4[0-9a-fA-F]{7})\((\d+)\)$"
|
||||
)
|
||||
@@ -508,10 +517,17 @@ def process_stacktrace(config, line, backtrace_state):
|
||||
"Exception type: %s", ESP8266_EXCEPTION_CODES.get(code, "unknown")
|
||||
)
|
||||
|
||||
# ESP8266 PC/EXCVADDR
|
||||
# ESP8266 PC/EXCVADDR (legacy Arduino postmortem)
|
||||
_parse_register(config, STACKTRACE_ESP8266_PC_RE, line)
|
||||
_parse_register(config, STACKTRACE_ESP8266_EXCVADDR_RE, line)
|
||||
|
||||
# ESP8266 structured crash handler (crash_handler.cpp) from previous boot
|
||||
_parse_register(config, STACKTRACE_ESP8266_CRASH_PC_RE, line)
|
||||
_parse_register(config, STACKTRACE_ESP8266_CRASH_EXCVADDR_RE, line)
|
||||
match = re.search(STACKTRACE_ESP8266_CRASH_BT_RE, line)
|
||||
if match is not None:
|
||||
_decode_pc(config, match.group(1))
|
||||
|
||||
# bad alloc
|
||||
match = re.match(STACKTRACE_BAD_ALLOC_RE, line)
|
||||
if match is not None:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -47,6 +47,7 @@ from esphome.core import CORE, ID, Lambda
|
||||
from esphome.cpp_generator import MockObj
|
||||
from esphome.final_validate import full_config
|
||||
from esphome.helpers import write_file_if_changed
|
||||
from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor
|
||||
from esphome.writer import clean_build
|
||||
from esphome.yaml_util import load_yaml
|
||||
|
||||
@@ -75,10 +76,14 @@ from .schemas import (
|
||||
BASE_PROPS,
|
||||
DISP_BG_SCHEMA,
|
||||
FULL_STYLE_SCHEMA,
|
||||
SET_STATE_SCHEMA,
|
||||
STATE_SCHEMA,
|
||||
STYLE_REMAP,
|
||||
STYLE_SCHEMA,
|
||||
WIDGET_TYPES,
|
||||
any_widget_schema,
|
||||
container_schema,
|
||||
container_schema_value,
|
||||
obj_dict,
|
||||
)
|
||||
from .styles import styles_to_code, theme_to_code
|
||||
@@ -113,6 +118,14 @@ from .widgets.page import ( # page_spec used in LVGL_SCHEMA
|
||||
page_spec,
|
||||
)
|
||||
|
||||
# These style schemas live in .schemas but are imported here so they land in
|
||||
# this module's namespace, where script/build_language_schema.py registers them
|
||||
# as *named* schemas and emits `extends` references — instead of inlining the
|
||||
# ~80-property STYLE_SCHEMA at every widget x part x state, which bloated the
|
||||
# dumped lvgl schema ~23x (17 MB vs ~750 KB). They are not otherwise used in
|
||||
# this file; this tuple keeps the imports live (and self-documents why).
|
||||
_SCHEMA_DUMPER_NAMED_SCHEMAS = (STYLE_SCHEMA, STATE_SCHEMA, SET_STATE_SCHEMA)
|
||||
|
||||
# Widget registration happens via WidgetType.__init__ in individual widget files
|
||||
# The imports below trigger creation of the widget types
|
||||
# Action registration (lvgl.{widget}.update) happens automatically
|
||||
@@ -559,94 +572,106 @@ def _theme_schema(value: dict) -> dict:
|
||||
|
||||
FINAL_VALIDATE_SCHEMA = final_validation
|
||||
|
||||
LVGL_SCHEMA = cv.All(
|
||||
container_schema(
|
||||
obj_spec,
|
||||
cv.polling_component_schema("1s")
|
||||
.extend(
|
||||
{
|
||||
**{
|
||||
cv.Optional(event): validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
Trigger.template(lv_obj_t_ptr, lv_event_t_ptr)
|
||||
),
|
||||
}
|
||||
)
|
||||
for event in df.LV_SCREEN_EVENT_TRIGGERS
|
||||
+ df.LV_DISPLAY_EVENT_TRIGGERS
|
||||
},
|
||||
cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent),
|
||||
cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t),
|
||||
cv.GenerateID(df.CONF_DISPLAYS): display_schema,
|
||||
cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16),
|
||||
cv.Optional(
|
||||
df.CONF_DEFAULT_FONT, default="montserrat_14"
|
||||
): lvalid.lv_font,
|
||||
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
|
||||
cv.Optional(
|
||||
df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False
|
||||
): cv.boolean,
|
||||
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
|
||||
cv.Optional(CONF_ROTATION): validate_rotation,
|
||||
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
|
||||
*df.LV_LOG_LEVELS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
|
||||
"big_endian", "little_endian", lower=True
|
||||
),
|
||||
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
|
||||
cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend(
|
||||
FULL_STYLE_SCHEMA
|
||||
)
|
||||
),
|
||||
cv.Optional(CONF_ON_IDLE): validate_automation(
|
||||
# The options accepted at the top level of an `lvgl:` block, on top of the base
|
||||
# object schema that `container_schema(obj_spec, ...)` supplies. Held in a
|
||||
# module-level name (rather than inline) so the schema-extractor wrapper on
|
||||
# CONFIG_SCHEMA below can hand the language-schema dumper the same composed
|
||||
# schema the runtime validates against.
|
||||
LVGL_TOP_LEVEL_SCHEMA = (
|
||||
cv.polling_component_schema("1s")
|
||||
.extend(
|
||||
{
|
||||
**{
|
||||
cv.Optional(event): validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger),
|
||||
cv.Required(CONF_TIMEOUT): cv.templatable(
|
||||
cv.positive_time_period_milliseconds
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
Trigger.template(lv_obj_t_ptr, lv_event_t_ptr)
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)),
|
||||
**{
|
||||
cv.Optional(x): validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger),
|
||||
},
|
||||
single=True,
|
||||
)
|
||||
for x in SIMPLE_TRIGGERS
|
||||
},
|
||||
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA),
|
||||
cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool,
|
||||
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
|
||||
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec),
|
||||
cv.Optional(
|
||||
df.CONF_TRANSPARENCY_KEY, default=0x000400
|
||||
): lvalid.lv_color,
|
||||
cv.Optional(df.CONF_THEME): _theme_schema,
|
||||
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
|
||||
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
|
||||
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,
|
||||
cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG,
|
||||
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
|
||||
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
.extend(DISP_BG_SCHEMA),
|
||||
),
|
||||
)
|
||||
for event in df.LV_SCREEN_EVENT_TRIGGERS + df.LV_DISPLAY_EVENT_TRIGGERS
|
||||
},
|
||||
cv.GenerateID(CONF_ID): cv.declare_id(LvglComponent),
|
||||
cv.GenerateID(CONF_ALIGN_TO_LAMBDA_ID): cv.declare_id(lv_lambda_t),
|
||||
cv.GenerateID(df.CONF_DISPLAYS): display_schema,
|
||||
cv.Optional(CONF_COLOR_DEPTH, default=16): cv.one_of(16),
|
||||
cv.Optional(df.CONF_DEFAULT_FONT, default="montserrat_14"): lvalid.lv_font,
|
||||
cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean,
|
||||
cv.Optional(df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False): cv.boolean,
|
||||
cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int,
|
||||
cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage,
|
||||
cv.Optional(CONF_ROTATION): validate_rotation,
|
||||
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
|
||||
*df.LV_LOG_LEVELS, upper=True
|
||||
),
|
||||
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
|
||||
"big_endian", "little_endian", lower=True
|
||||
),
|
||||
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(
|
||||
cv.Schema({cv.Required(CONF_ID): cv.declare_id(lv_style_t)}).extend(
|
||||
FULL_STYLE_SCHEMA
|
||||
)
|
||||
),
|
||||
cv.Optional(CONF_ON_IDLE): validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(IdleTrigger),
|
||||
cv.Required(CONF_TIMEOUT): cv.templatable(
|
||||
cv.positive_time_period_milliseconds
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_PAGES): cv.ensure_list(container_schema(page_spec)),
|
||||
**{
|
||||
cv.Optional(x): validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(PlainTrigger),
|
||||
},
|
||||
single=True,
|
||||
)
|
||||
for x in SIMPLE_TRIGGERS
|
||||
},
|
||||
cv.Optional(df.CONF_MSGBOXES): cv.ensure_list(MSGBOX_SCHEMA),
|
||||
cv.Optional(df.CONF_PAGE_WRAP, default=True): lv_bool,
|
||||
cv.Optional(df.CONF_TOP_LAYER): container_schema(obj_spec),
|
||||
cv.Optional(df.CONF_BOTTOM_LAYER): container_schema(obj_spec),
|
||||
cv.Optional(df.CONF_TRANSPARENCY_KEY, default=0x000400): lvalid.lv_color,
|
||||
cv.Optional(df.CONF_THEME): _theme_schema,
|
||||
cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA,
|
||||
cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema,
|
||||
cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG,
|
||||
cv.Optional(df.CONF_KEYPADS, default=None): KEYPADS_CONFIG,
|
||||
cv.GenerateID(df.CONF_DEFAULT_GROUP): cv.declare_id(lv_group_t),
|
||||
cv.Optional(df.CONF_RESUME_ON_INPUT, default=True): cv.boolean,
|
||||
}
|
||||
)
|
||||
.extend(DISP_BG_SCHEMA)
|
||||
)
|
||||
|
||||
|
||||
LVGL_SCHEMA = cv.All(
|
||||
container_schema(obj_spec, LVGL_TOP_LEVEL_SCHEMA),
|
||||
cv.has_at_most_one_key(CONF_PAGES, df.CONF_LAYOUT),
|
||||
add_hello_world,
|
||||
)
|
||||
|
||||
|
||||
@schema_extractor("schema")
|
||||
def lvgl_config_schema(config):
|
||||
"""
|
||||
Can't use cv.ensure_list here because it converts an empty config to an empty list,
|
||||
rather than a default config.
|
||||
"""
|
||||
if config is SCHEMA_EXTRACT:
|
||||
# CONFIG_SCHEMA is this callable wrapping `cv.All` over a container_schema
|
||||
# closure, so the language-schema dumper can't see the top-level `lvgl:`
|
||||
# fields (it would emit an empty schema). Hand it the same composed
|
||||
# obj + top-level schema the runtime validates against, plus the
|
||||
# `widgets:` key (added per-value by append_layout_schema at runtime, so
|
||||
# otherwise invisible to the dumper). Validation of real configs (the
|
||||
# branches below) is unchanged.
|
||||
return container_schema_value(obj_spec, LVGL_TOP_LEVEL_SCHEMA).extend(
|
||||
{cv.Optional(df.CONF_WIDGETS): any_widget_schema()}
|
||||
)
|
||||
if not config or isinstance(config, dict):
|
||||
return [LVGL_SCHEMA(config)]
|
||||
return cv.Schema([LVGL_SCHEMA])(config)
|
||||
|
||||
@@ -22,7 +22,11 @@ from esphome.const import (
|
||||
)
|
||||
from esphome.core import TimePeriod
|
||||
from esphome.core.config import StartupTrigger
|
||||
from esphome.schema_extractors import EnableSchemaExtraction
|
||||
from esphome.schema_extractors import (
|
||||
SCHEMA_EXTRACT,
|
||||
EnableSchemaExtraction,
|
||||
schema_extractor,
|
||||
)
|
||||
|
||||
from . import defines as df, lv_validation as lvalid
|
||||
from .defines import (
|
||||
@@ -627,6 +631,25 @@ _CONTAINER_SCHEMA_CACHE: dict[
|
||||
] = {}
|
||||
|
||||
|
||||
def container_schema_value(widget_type: WidgetType, extras: Any = None) -> cv.Schema:
|
||||
"""
|
||||
Build the static schema that :func:`container_schema` validates against, i.e.
|
||||
everything except the value-dependent ``append_layout_schema`` applied at
|
||||
validation time.
|
||||
|
||||
Factored out and exposed so the language-schema dumper can extract a
|
||||
representative schema for a widget — and for the top-level ``lvgl:`` block,
|
||||
whose ``CONFIG_SCHEMA`` is a callable that otherwise hides this behind the
|
||||
:func:`container_schema` validator closure.
|
||||
"""
|
||||
schema = obj_schema(widget_type).extend(
|
||||
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
|
||||
)
|
||||
if extras:
|
||||
schema = schema.extend(extras)
|
||||
return schema.extend(widget_type.schema)
|
||||
|
||||
|
||||
def container_schema(
|
||||
widget_type: WidgetType, extras: Any = None
|
||||
) -> Callable[[Any], Any]:
|
||||
@@ -649,12 +672,7 @@ def container_schema(
|
||||
def get_schema() -> cv.Schema:
|
||||
nonlocal cached_schema
|
||||
if cached_schema is None:
|
||||
schema = obj_schema(widget_type).extend(
|
||||
{cv.GenerateID(): cv.declare_id(widget_type.w_type)}
|
||||
)
|
||||
if extras:
|
||||
schema = schema.extend(extras)
|
||||
cached_schema = schema.extend(widget_type.schema)
|
||||
cached_schema = container_schema_value(widget_type, extras)
|
||||
return cached_schema
|
||||
|
||||
def validator(value: Any) -> Any:
|
||||
@@ -678,7 +696,23 @@ def any_widget_schema(extras=None):
|
||||
:return: A validator for the Widgets key
|
||||
"""
|
||||
|
||||
@schema_extractor("schema")
|
||||
def validator(value):
|
||||
if value is SCHEMA_EXTRACT:
|
||||
# The widgets: list is built per-value at validation time, so the
|
||||
# language-schema dumper sees nothing. Enumerate every registered
|
||||
# widget type as an optional key (a widget item is really a
|
||||
# single-key mapping; over-listing them lets editors complete any
|
||||
# widget — `esphome config` enforces exactly one). extras carries the
|
||||
# layout child options where applicable.
|
||||
return cv.ensure_list(
|
||||
cv.Schema(
|
||||
{
|
||||
cv.Optional(name): container_schema_value(widget_type, extras)
|
||||
for name, widget_type in WIDGET_TYPES.items()
|
||||
}
|
||||
)
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
# Convert to list
|
||||
is_dict = True
|
||||
|
||||
@@ -139,6 +139,8 @@ MADCTL_FLIP_FLAG = 0x100 # meta-flag to indicate use of axis flips
|
||||
# Special constant for delays in command sequences
|
||||
DELAY_FLAG = 0xFFF # Special flag to indicate a delay
|
||||
|
||||
CONF_PAD_HEIGHT = "pad_height"
|
||||
CONF_PAD_WIDTH = "pad_width"
|
||||
CONF_PIXEL_MODE = "pixel_mode"
|
||||
CONF_USE_AXIS_FLIPS = "use_axis_flips"
|
||||
|
||||
@@ -202,6 +204,8 @@ def dimension_schema(rounding):
|
||||
rounding
|
||||
),
|
||||
cv.Optional(CONF_OFFSET_WIDTH, default=0): validate_dimension(rounding),
|
||||
cv.Optional(CONF_PAD_WIDTH): validate_dimension(rounding),
|
||||
cv.Optional(CONF_PAD_HEIGHT): validate_dimension(rounding),
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -311,6 +315,36 @@ class DriverChip:
|
||||
name = name.upper()
|
||||
self.name = name
|
||||
self.initsequence = initsequence
|
||||
if CONF_NATIVE_WIDTH in defaults:
|
||||
if CONF_WIDTH not in defaults:
|
||||
defaults[CONF_WIDTH] = (
|
||||
defaults[CONF_NATIVE_WIDTH]
|
||||
- defaults.get(CONF_OFFSET_WIDTH, 0)
|
||||
- defaults.get(CONF_PAD_WIDTH, 0)
|
||||
)
|
||||
else:
|
||||
native_width = (
|
||||
defaults.get(CONF_WIDTH, 0)
|
||||
+ defaults.get(CONF_OFFSET_WIDTH, 0)
|
||||
+ defaults.get(CONF_PAD_WIDTH, 0)
|
||||
)
|
||||
if native_width != 0:
|
||||
defaults[CONF_NATIVE_WIDTH] = native_width
|
||||
if CONF_NATIVE_HEIGHT in defaults:
|
||||
if CONF_HEIGHT not in defaults:
|
||||
defaults[CONF_HEIGHT] = (
|
||||
defaults[CONF_NATIVE_HEIGHT]
|
||||
- defaults.get(CONF_OFFSET_HEIGHT, 0)
|
||||
- defaults.get(CONF_PAD_HEIGHT, 0)
|
||||
)
|
||||
else:
|
||||
native_height = (
|
||||
defaults.get(CONF_HEIGHT, 0)
|
||||
+ defaults.get(CONF_OFFSET_HEIGHT, 0)
|
||||
+ defaults.get(CONF_PAD_HEIGHT, 0)
|
||||
)
|
||||
if native_height != 0:
|
||||
defaults[CONF_NATIVE_HEIGHT] = native_height
|
||||
self.defaults = defaults
|
||||
DriverChip.models[name] = self
|
||||
|
||||
@@ -336,18 +370,6 @@ class DriverChip:
|
||||
initsequence = list(kwargs.pop("initsequence", self.initsequence))
|
||||
initsequence.extend(kwargs.pop("add_init_sequence", ()))
|
||||
defaults = self.defaults.copy()
|
||||
if (
|
||||
CONF_WIDTH in defaults
|
||||
and CONF_OFFSET_WIDTH in kwargs
|
||||
and CONF_NATIVE_WIDTH not in defaults
|
||||
):
|
||||
defaults[CONF_NATIVE_WIDTH] = defaults[CONF_WIDTH]
|
||||
if (
|
||||
CONF_HEIGHT in defaults
|
||||
and CONF_OFFSET_HEIGHT in kwargs
|
||||
and CONF_NATIVE_HEIGHT not in defaults
|
||||
):
|
||||
defaults[CONF_NATIVE_HEIGHT] = defaults[CONF_HEIGHT]
|
||||
defaults.update(kwargs)
|
||||
return self.__class__(name, initsequence=tuple(initsequence), **defaults)
|
||||
|
||||
@@ -385,13 +407,16 @@ class DriverChip:
|
||||
return CONF_SWAP_XY in transforms and CONF_MIRROR_X in transforms
|
||||
return CONF_SWAP_XY in transforms and CONF_MIRROR_Y in transforms
|
||||
|
||||
def get_dimensions(self, config, swap: bool = True) -> tuple[int, int, int, int]:
|
||||
def get_dimensions(
|
||||
self, config, swap: bool = True
|
||||
) -> tuple[int, int, int, int, int, int]:
|
||||
"""
|
||||
Return the dimensions of the current model.
|
||||
:param config: The current configuration
|
||||
:param swap: If width/height should be swapped when axes are swapped.
|
||||
:return:
|
||||
:return: A tuple (width, height, offset_width, offset_height, pad_width, pad_height).
|
||||
"""
|
||||
|
||||
if CONF_DIMENSIONS in config:
|
||||
# Explicit dimensions, just use as is
|
||||
dimensions = config[CONF_DIMENSIONS]
|
||||
@@ -400,33 +425,71 @@ class DriverChip:
|
||||
height = dimensions[CONF_HEIGHT]
|
||||
offset_width = dimensions[CONF_OFFSET_WIDTH]
|
||||
offset_height = dimensions[CONF_OFFSET_HEIGHT]
|
||||
return width, height, offset_width, offset_height
|
||||
(width, height) = dimensions
|
||||
return width, height, 0, 0
|
||||
if CONF_PAD_WIDTH in dimensions:
|
||||
pad_width = dimensions[CONF_PAD_WIDTH]
|
||||
native_width = width + offset_width + pad_width
|
||||
else:
|
||||
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
|
||||
if native_width == 0:
|
||||
pad_width = 0
|
||||
native_width = width + offset_width
|
||||
else:
|
||||
pad_width = native_width - width - offset_width
|
||||
if CONF_PAD_HEIGHT in dimensions:
|
||||
pad_height = dimensions[CONF_PAD_HEIGHT]
|
||||
native_height = height + offset_height + pad_height
|
||||
else:
|
||||
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
|
||||
if native_height == 0:
|
||||
pad_height = 0
|
||||
native_height = height + offset_height
|
||||
else:
|
||||
pad_height = native_height - height - offset_height
|
||||
if (
|
||||
pad_width + offset_width >= native_width
|
||||
or pad_height + offset_height >= native_height
|
||||
):
|
||||
raise cv.Invalid("Dimensions exceed native size", [CONF_DIMENSIONS])
|
||||
if pad_width < 0 or pad_height < 0:
|
||||
raise cv.Invalid("Invalid offsets", [CONF_DIMENSIONS])
|
||||
|
||||
return width, height, offset_width, offset_height, pad_width, pad_height
|
||||
|
||||
# Must be a tuple
|
||||
width, height = dimensions
|
||||
return width, height, 0, 0, 0, 0
|
||||
|
||||
# Default dimensions, use model defaults
|
||||
transform = self.get_transform(config)
|
||||
|
||||
width = self.get_default(CONF_WIDTH)
|
||||
height = self.get_default(CONF_HEIGHT)
|
||||
native_width = self.get_default(CONF_NATIVE_WIDTH, 0)
|
||||
native_height = self.get_default(CONF_NATIVE_HEIGHT, 0)
|
||||
offset_width = self.get_default(CONF_OFFSET_WIDTH, 0)
|
||||
offset_height = self.get_default(CONF_OFFSET_HEIGHT, 0)
|
||||
pad_width = self.get_default(
|
||||
CONF_PAD_WIDTH, native_width - width - offset_width
|
||||
)
|
||||
pad_height = self.get_default(
|
||||
CONF_PAD_HEIGHT, native_height - height - offset_height
|
||||
)
|
||||
|
||||
if pad_width < 0 or pad_height < 0:
|
||||
raise cv.Invalid("Offsets exceed native size", [CONF_DIMENSIONS])
|
||||
|
||||
# if mirroring axes and there are offsets, also mirror the offsets to cater for situations where
|
||||
# the offset is asymmetric
|
||||
if transform.get(CONF_MIRROR_X):
|
||||
native_width = self.get_default(CONF_NATIVE_WIDTH, width + offset_width * 2)
|
||||
offset_width = native_width - width - offset_width
|
||||
offset_width, pad_width = pad_width, offset_width
|
||||
if transform.get(CONF_MIRROR_Y):
|
||||
native_height = self.get_default(
|
||||
CONF_NATIVE_HEIGHT, height + offset_height * 2
|
||||
)
|
||||
offset_height = native_height - height - offset_height
|
||||
# Swap default dimensions if swap_xy is set, or if rotation is 90/270 and we are not using a buffer
|
||||
offset_height, pad_height = pad_height, offset_height
|
||||
# Swap default dimensions if swap_xy is set, or if rotation is 90/270, and we are not using a buffer
|
||||
if swap and transform.get(CONF_SWAP_XY) is True:
|
||||
width, height = height, width
|
||||
offset_height, offset_width = offset_width, offset_height
|
||||
return width, height, offset_width, offset_height
|
||||
pad_width, pad_height = pad_height, pad_width
|
||||
return width, height, offset_width, offset_height, pad_width, pad_height
|
||||
|
||||
def get_base_transform(self, config):
|
||||
transform = config.get(
|
||||
@@ -450,20 +513,8 @@ class DriverChip:
|
||||
|
||||
def get_transform(self, config) -> dict[str, bool]:
|
||||
transform = self.get_base_transform(config)
|
||||
can_transform = self.rotation_as_transform(config)
|
||||
# Can we use the MADCTL register to set the rotation?
|
||||
if can_transform and CONF_TRANSFORM not in config:
|
||||
rotation = config[CONF_ROTATION]
|
||||
if rotation == 180:
|
||||
transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X]
|
||||
transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y]
|
||||
elif rotation == 90:
|
||||
transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY]
|
||||
transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X]
|
||||
else:
|
||||
transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY]
|
||||
transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y]
|
||||
transform[CONF_TRANSFORM] = True
|
||||
transform[CONF_TRANSFORM] = self.rotation_as_transform(config)
|
||||
return transform
|
||||
|
||||
def swap_xy_schema(self):
|
||||
@@ -498,8 +549,8 @@ class DriverChip:
|
||||
return madctl
|
||||
|
||||
def add_madctl(self, sequence: list, config: dict):
|
||||
# Add the MADCTL command to the sequence based on the configuration.
|
||||
# This takes into account rotation if it can be implemented in the transform
|
||||
# Add the MADCTL command to the sequence based on the base configuration.
|
||||
# Rotation is not applied here, it will be done at runtime.
|
||||
transform = self.get_transform(config)
|
||||
madctl = self.get_madctl(transform, config)
|
||||
sequence.append((MADCTL, madctl & 0xFF))
|
||||
|
||||
@@ -172,7 +172,9 @@ def _config_schema(config):
|
||||
)(config)
|
||||
config = model_schema(config)(config)
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
width, height, _offset_width, _offset_height = model.get_dimensions(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
display.add_metadata(
|
||||
config[CONF_ID],
|
||||
width,
|
||||
@@ -206,7 +208,9 @@ async def to_code(config):
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
color_depth = COLOR_DEPTHS[get_color_depth(config)]
|
||||
pixel_mode = int(config[CONF_PIXEL_MODE].removesuffix("bit"))
|
||||
width, height, _offset_width, _offset_height = model.get_dimensions(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
var = cg.new_Pvariable(config[CONF_ID], width, height, color_depth, pixel_mode)
|
||||
|
||||
sequence = model.get_sequence(config)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -235,7 +235,9 @@ def _config_schema(config):
|
||||
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]),
|
||||
)(config)
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
width, height, _offset_width, _offset_height = model.get_dimensions(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
display.add_metadata(
|
||||
config[CONF_ID],
|
||||
width,
|
||||
@@ -273,7 +275,9 @@ FINAL_VALIDATE_SCHEMA = _final_validate
|
||||
|
||||
async def to_code(config):
|
||||
model = MODELS[config[CONF_MODEL].upper()]
|
||||
width, height, _offset_width, _offset_height = model.get_dimensions(config)
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
var = cg.new_Pvariable(config[CONF_ID], width, height)
|
||||
cg.add(var.set_model(model.name))
|
||||
if enable_pin := config.get(CONF_ENABLE_PIN):
|
||||
|
||||
@@ -27,7 +27,7 @@ from esphome.components.mipi import (
|
||||
requires_buffer,
|
||||
)
|
||||
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
|
||||
from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
|
||||
from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
|
||||
import esphome.config_validation as cv
|
||||
from esphome.config_validation import ALLOW_EXTRA
|
||||
from esphome.const import (
|
||||
@@ -121,7 +121,9 @@ def denominator(config):
|
||||
"""
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
frac = config.get(CONF_BUFFER_SIZE)
|
||||
_width, height, _offset_width, _offset_height = model.get_dimensions(config)
|
||||
_width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
if frac is None or frac > 0.75 or height < 32:
|
||||
return 1
|
||||
try:
|
||||
@@ -169,11 +171,22 @@ def model_schema(config):
|
||||
]
|
||||
if bus_mode == TYPE_SINGLE:
|
||||
other_options.append(CONF_SPI_16)
|
||||
# Calculate default SPI mode. Mode3 for octal bus or single bus with no cs pin, mode0 otherwise.
|
||||
spi_mode = model.get_default(CONF_SPI_MODE)
|
||||
if not spi_mode:
|
||||
if bus_mode == TYPE_OCTAL or (
|
||||
bus_mode == TYPE_SINGLE
|
||||
and not config.get(CONF_CS_PIN, model.get_default(CONF_CS_PIN))
|
||||
):
|
||||
spi_mode = "MODE3"
|
||||
else:
|
||||
spi_mode = "MODE0"
|
||||
|
||||
schema = (
|
||||
display.FULL_DISPLAY_SCHEMA.extend(
|
||||
spi.spi_device_schema(
|
||||
cs_pin_required=False,
|
||||
default_mode="MODE3" if bus_mode == TYPE_OCTAL else "MODE0",
|
||||
default_mode=spi_mode,
|
||||
default_data_rate=model.get_default(CONF_DATA_RATE, 10_000_000),
|
||||
mode=bus_mode,
|
||||
)
|
||||
@@ -279,8 +292,8 @@ def customise_schema(config):
|
||||
CONF_MIRROR_Y,
|
||||
CONF_SWAP_XY,
|
||||
}
|
||||
width, height, _offset_width, _offset_height = model.get_dimensions(
|
||||
config, not has_hardware_transform
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config, not has_hardware_transform)
|
||||
)
|
||||
display.add_metadata(
|
||||
config[CONF_ID],
|
||||
@@ -313,14 +326,17 @@ def _final_validate(config):
|
||||
# If no drawing methods are configured, and LVGL is not enabled, show a test card
|
||||
config[CONF_SHOW_TEST_CARD] = True
|
||||
|
||||
# Always call this to check dimensions during validation
|
||||
width, height, _offset_width, _offset_height, _pad_width, _pad_height = (
|
||||
model.get_dimensions(config)
|
||||
)
|
||||
|
||||
if PSRAM_DOMAIN not in global_config and CONF_BUFFER_SIZE not in config:
|
||||
# If PSRAM is not enabled, choose a small buffer size by default
|
||||
if not requires_buffer(config):
|
||||
return # No need to pick a size
|
||||
color_depth = get_color_depth(config)
|
||||
frac = denominator(config)
|
||||
width, height, _offset_width, _offset_height = model.get_dimensions(config)
|
||||
|
||||
buffer_size = color_depth // 8 * width * height // frac
|
||||
# Target a buffer size of 20kB, except for large displays, which shouldn't end up here
|
||||
fraction = min(20000.0, buffer_size // 4) / buffer_size
|
||||
@@ -347,8 +363,8 @@ def get_instance(config):
|
||||
CONF_MIRROR_Y,
|
||||
CONF_SWAP_XY,
|
||||
}
|
||||
width, height, offset_width, offset_height = model.get_dimensions(
|
||||
config, not has_hardware_transform
|
||||
width, height, offset_width, offset_height, pad_width, pad_height = (
|
||||
model.get_dimensions(config, not has_hardware_transform)
|
||||
)
|
||||
|
||||
color_depth = int(config[CONF_COLOR_DEPTH].removesuffix("bit"))
|
||||
@@ -374,6 +390,8 @@ def get_instance(config):
|
||||
height,
|
||||
offset_width,
|
||||
offset_height,
|
||||
pad_width,
|
||||
pad_height,
|
||||
madctl,
|
||||
has_hardware_transform,
|
||||
]
|
||||
|
||||
@@ -81,10 +81,15 @@ void internal_dump_config(const char *model, int width, int height, int offset_w
|
||||
* @tparam HEIGHT Height of the display in pixels
|
||||
* @tparam OFFSET_WIDTH The x-offset of the display in pixels
|
||||
* @tparam OFFSET_HEIGHT The y-offset of the display in pixels
|
||||
* @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width
|
||||
* @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width
|
||||
* @tparam MADCTL The base MADCTL value for the display, with no rotation bits set.
|
||||
* @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation.
|
||||
* buffer
|
||||
*/
|
||||
template<typename BUFFERTYPE, PixelMode BUFFERPIXEL, bool IS_BIG_ENDIAN, PixelMode DISPLAYPIXEL, BusType BUS_TYPE,
|
||||
int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, uint16_t MADCTL, bool HAS_HARDWARE_ROTATION>
|
||||
int WIDTH, int HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT, uint16_t MADCTL,
|
||||
bool HAS_HARDWARE_ROTATION>
|
||||
class MipiSpi : public display::Display,
|
||||
public spi::SPIDevice<spi::BIT_ORDER_MSB_FIRST, spi::CLOCK_POLARITY_LOW, spi::CLOCK_PHASE_LEADING,
|
||||
spi::DATA_RATE_1MHZ> {
|
||||
@@ -126,17 +131,6 @@ class MipiSpi : public display::Display,
|
||||
return HEIGHT;
|
||||
}
|
||||
|
||||
// If hardware rotation is in use, the actual display width/height changes with rotation
|
||||
int get_width_internal() override {
|
||||
if constexpr (HAS_HARDWARE_ROTATION)
|
||||
return get_width();
|
||||
return WIDTH;
|
||||
}
|
||||
int get_height_internal() override {
|
||||
if constexpr (HAS_HARDWARE_ROTATION)
|
||||
return get_height();
|
||||
return HEIGHT;
|
||||
}
|
||||
void set_init_sequence(const std::vector<uint8_t> &sequence) { this->init_sequence_ = sequence; }
|
||||
|
||||
// reset the display, and write the init sequence
|
||||
@@ -233,14 +227,25 @@ class MipiSpi : public display::Display,
|
||||
}
|
||||
|
||||
void dump_config() override {
|
||||
internal_dump_config(this->model_, this->get_width(), this->get_height(), OFFSET_WIDTH, OFFSET_HEIGHT,
|
||||
(uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8, IS_BIG_ENDIAN, this->brightness_,
|
||||
this->cs_, this->reset_pin_, this->dc_pin_, this->mode_, this->data_rate_, BUS_TYPE,
|
||||
HAS_HARDWARE_ROTATION);
|
||||
internal_dump_config(this->model_, this->get_width(), this->get_height(), this->get_offset_width_(),
|
||||
this->get_offset_height_(), (uint8_t) MADCTL, this->invert_colors_, DISPLAYPIXEL * 8,
|
||||
IS_BIG_ENDIAN, this->brightness_, this->cs_, this->reset_pin_, this->dc_pin_, this->mode_,
|
||||
this->data_rate_, BUS_TYPE, HAS_HARDWARE_ROTATION);
|
||||
}
|
||||
|
||||
protected:
|
||||
/* METHODS */
|
||||
// If hardware rotation is in use, the actual display width/height changes with rotation
|
||||
int get_width_internal() override {
|
||||
if constexpr (HAS_HARDWARE_ROTATION)
|
||||
return get_width();
|
||||
return WIDTH;
|
||||
}
|
||||
int get_height_internal() override {
|
||||
if constexpr (HAS_HARDWARE_ROTATION)
|
||||
return get_height();
|
||||
return HEIGHT;
|
||||
}
|
||||
// convenience functions to write commands with or without data
|
||||
void write_command_(uint8_t cmd, uint8_t data) { this->write_command_(cmd, &data, 1); }
|
||||
void write_command_(uint8_t cmd) { this->write_command_(cmd, &cmd, 0); }
|
||||
@@ -330,20 +335,34 @@ class MipiSpi : public display::Display,
|
||||
this->write_command_(MADCTL_CMD, madctl);
|
||||
}
|
||||
|
||||
uint16_t get_offset_width_() {
|
||||
uint16_t get_offset_width_() const {
|
||||
if constexpr (HAS_HARDWARE_ROTATION) {
|
||||
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
|
||||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
|
||||
return OFFSET_HEIGHT;
|
||||
switch (this->rotation_) {
|
||||
case display::DISPLAY_ROTATION_90_DEGREES:
|
||||
return OFFSET_HEIGHT;
|
||||
case display::DISPLAY_ROTATION_180_DEGREES:
|
||||
return PAD_WIDTH;
|
||||
case display::DISPLAY_ROTATION_270_DEGREES:
|
||||
return PAD_HEIGHT;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return OFFSET_WIDTH;
|
||||
}
|
||||
|
||||
uint16_t get_offset_height_() {
|
||||
uint16_t get_offset_height_() const {
|
||||
if constexpr (HAS_HARDWARE_ROTATION) {
|
||||
if (this->rotation_ == display::DISPLAY_ROTATION_90_DEGREES ||
|
||||
this->rotation_ == display::DISPLAY_ROTATION_270_DEGREES)
|
||||
return OFFSET_WIDTH;
|
||||
switch (this->rotation_) {
|
||||
case display::DISPLAY_ROTATION_90_DEGREES:
|
||||
return PAD_WIDTH;
|
||||
case display::DISPLAY_ROTATION_180_DEGREES:
|
||||
return PAD_HEIGHT;
|
||||
case display::DISPLAY_ROTATION_270_DEGREES:
|
||||
return OFFSET_WIDTH;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return OFFSET_HEIGHT;
|
||||
}
|
||||
@@ -396,7 +415,7 @@ class MipiSpi : public display::Display,
|
||||
this->write_cmd_addr_data(0, 0, 0, 0, ptr, w * h, 8);
|
||||
}
|
||||
} else {
|
||||
for (size_t y = 0; y != static_cast<size_t>(h); y++) {
|
||||
for (size_t y = 0; y != h; y++) {
|
||||
if constexpr (BUS_TYPE == BUS_TYPE_SINGLE || BUS_TYPE == BUS_TYPE_SINGLE_16) {
|
||||
this->write_array(ptr, w);
|
||||
} else if constexpr (BUS_TYPE == BUS_TYPE_QUAD) {
|
||||
@@ -492,19 +511,23 @@ class MipiSpi : public display::Display,
|
||||
* @tparam BUFFERPIXEL Color depth of the buffer
|
||||
* @tparam DISPLAYPIXEL Color depth of the display
|
||||
* @tparam BUS_TYPE The type of the interface bus (single, quad, octal)
|
||||
* @tparam ROTATION The rotation of the display
|
||||
* @tparam WIDTH Width of the display in pixels
|
||||
* @tparam HEIGHT Height of the display in pixels
|
||||
* @tparam OFFSET_WIDTH The x-offset of the display in pixels
|
||||
* @tparam OFFSET_HEIGHT The y-offset of the display in pixels
|
||||
* @tparam PAD_WIDTH Additional pixels recognised by the controller after the offset and width
|
||||
* @tparam PAD_HEIGHT Additional lines recognised by the controller after the offset and width
|
||||
* @tparam MADCTL The base MADCTL value for the display, with no rotation bits set.
|
||||
* @tparam HAS_HARDWARE_ROTATION Whether the display supports hardware rotation.
|
||||
* @tparam FRACTION The fraction of the display size to use for the buffer (e.g. 4 means a 1/4 buffer).
|
||||
* @tparam ROUNDING The alignment requirement for drawing operations (e.g. 2 means that x coordinates must be even)
|
||||
*/
|
||||
template<typename BUFFERTYPE, PixelMode BUFFERPIXEL, bool IS_BIG_ENDIAN, PixelMode DISPLAYPIXEL, BusType BUS_TYPE,
|
||||
uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, uint16_t MADCTL,
|
||||
bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING>
|
||||
class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT,
|
||||
OFFSET_WIDTH, OFFSET_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION> {
|
||||
uint16_t WIDTH, uint16_t HEIGHT, int OFFSET_WIDTH, int OFFSET_HEIGHT, int PAD_WIDTH, int PAD_HEIGHT,
|
||||
uint16_t MADCTL, bool HAS_HARDWARE_ROTATION, int FRACTION, unsigned ROUNDING>
|
||||
class MipiSpiBuffer
|
||||
: public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT, OFFSET_WIDTH,
|
||||
OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION> {
|
||||
public:
|
||||
// these values define the buffer size needed to write in accordance with the chip pixel alignment
|
||||
// requirements. If the required rounding does not divide the width and height, we round up to the next multiple and
|
||||
@@ -515,7 +538,7 @@ class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DIS
|
||||
|
||||
void dump_config() override {
|
||||
MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT,
|
||||
MADCTL, HAS_HARDWARE_ROTATION>::dump_config();
|
||||
PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::dump_config();
|
||||
esph_log_config(TAG,
|
||||
" Rotation: %d°\n"
|
||||
" Buffer pixels: %d bits\n"
|
||||
@@ -528,7 +551,7 @@ class MipiSpiBuffer : public MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DIS
|
||||
|
||||
void setup() override {
|
||||
MipiSpi<BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE, WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT,
|
||||
MADCTL, HAS_HARDWARE_ROTATION>::setup();
|
||||
PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION>::setup();
|
||||
RAMAllocator<BUFFERTYPE> allocator{};
|
||||
this->buffer_ = allocator.allocate(round_buffer(WIDTH) * round_buffer(HEIGHT) / FRACTION);
|
||||
if (this->buffer_ == nullptr) {
|
||||
|
||||
@@ -179,6 +179,9 @@ ILI9342 = DriverChip(
|
||||
# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation
|
||||
ILI9341.extend(
|
||||
"M5CORE2",
|
||||
# Reset native dimensions due to axis swap.
|
||||
native_width=320,
|
||||
native_height=240,
|
||||
width=320,
|
||||
height=240,
|
||||
mirror_x=False,
|
||||
@@ -786,3 +789,28 @@ ST7796.extend(
|
||||
dc_pin=0,
|
||||
invert_colors=True,
|
||||
)
|
||||
|
||||
ST7789V.extend(
|
||||
"GEEKMAGIC-SMALLTV",
|
||||
data_rate="40MHz",
|
||||
height=240,
|
||||
width=240,
|
||||
offset_width=0,
|
||||
offset_height=0,
|
||||
invert_colors=True,
|
||||
buffer_size=0.125,
|
||||
reset_pin=2,
|
||||
dc_pin=0,
|
||||
)
|
||||
ST7789V.extend(
|
||||
"GEEKMAGIC-SMALLTV-PRO",
|
||||
data_rate="40MHz",
|
||||
height=240,
|
||||
width=240,
|
||||
offset_width=0,
|
||||
offset_height=0,
|
||||
invert_colors=True,
|
||||
buffer_size=0.125,
|
||||
reset_pin=4,
|
||||
dc_pin=2,
|
||||
)
|
||||
|
||||
@@ -269,3 +269,16 @@ ST7789V.extend(
|
||||
cs_pin=14,
|
||||
dc_pin={"number": 15, "ignore_strapping_warning": True},
|
||||
)
|
||||
|
||||
ST7789V.extend(
|
||||
"WAVESHARE-ESP32-S3-GEEK",
|
||||
cs_pin=10,
|
||||
dc_pin=8,
|
||||
reset_pin=9,
|
||||
width=135,
|
||||
height=240,
|
||||
offset_width=52,
|
||||
offset_height=40,
|
||||
invert_colors=True,
|
||||
data_rate="40MHz",
|
||||
)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -17,6 +17,11 @@ class SPIDelegateHw : public SPIDelegate {
|
||||
write_only_(write_only) {
|
||||
if (!this->release_device_)
|
||||
add_device_();
|
||||
|
||||
if (this->write_only_) {
|
||||
ESP_LOGV(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)",
|
||||
Utility::get_pin_no(this->cs_pin_));
|
||||
}
|
||||
}
|
||||
|
||||
bool is_ready() override { return this->handle_ != nullptr; }
|
||||
@@ -195,11 +200,8 @@ class SPIDelegateHw : public SPIDelegate {
|
||||
config.post_cb = nullptr;
|
||||
if (this->bit_order_ == BIT_ORDER_LSB_FIRST)
|
||||
config.flags |= SPI_DEVICE_BIT_LSBFIRST;
|
||||
if (this->write_only_) {
|
||||
if (this->write_only_)
|
||||
config.flags |= SPI_DEVICE_HALFDUPLEX | SPI_DEVICE_NO_DUMMY;
|
||||
ESP_LOGD(TAG, "SPI device with CS pin %d using half-duplex mode (write-only)",
|
||||
Utility::get_pin_no(this->cs_pin_));
|
||||
}
|
||||
esp_err_t const err = spi_bus_add_device(this->channel_, &config, &this->handle_);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Add device failed - err %X", err);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.6.0b1"
|
||||
__version__ = "2026.6.2"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -958,6 +958,13 @@ class EsphomeCore:
|
||||
return build_flag
|
||||
|
||||
def add_build_unflag(self, build_unflag: str) -> None:
|
||||
if self.using_toolchain_esp_idf:
|
||||
# The native ESP-IDF build generator does not consume build_unflags
|
||||
_LOGGER.warning(
|
||||
"Build unflag %s is ignored when building with the native "
|
||||
"ESP-IDF toolchain",
|
||||
build_unflag,
|
||||
)
|
||||
self.build_unflags.add(build_unflag)
|
||||
_LOGGER.debug("Adding build unflag: %s", build_unflag)
|
||||
|
||||
|
||||
@@ -104,9 +104,13 @@ class Application {
|
||||
void register_area(Area *area) { this->areas_.push_back(area); }
|
||||
#endif
|
||||
|
||||
void set_current_component(Component *component) { this->current_component_ = component; }
|
||||
Component *get_current_component() { return this->current_component_; }
|
||||
|
||||
// Owning script of the action chain currently executing (nullptr when none); used to attribute
|
||||
// blocking warnings for deferred work to the script that scheduled it.
|
||||
void set_current_source(const LogString *source) { this->current_source_ = source; }
|
||||
const LogString *get_current_source() { return this->current_source_; }
|
||||
|
||||
// Entity register methods (generated from entity_types.h).
|
||||
// Each entity type gets two overloads:
|
||||
// - register_<entity>(obj) — bare push_back
|
||||
@@ -393,6 +397,7 @@ class Application {
|
||||
protected:
|
||||
friend Component;
|
||||
friend class Scheduler;
|
||||
friend class LoopBlockingGuard;
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
friend class runtime_stats::RuntimeStatsCollector;
|
||||
#endif
|
||||
@@ -402,6 +407,14 @@ class Application {
|
||||
/// Freshen the cached loop component start time. Called by Scheduler before each dispatch.
|
||||
void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; }
|
||||
|
||||
// Publish the running unit's identity (component + source) and dispatch time together, so a
|
||||
// dispatch site can't set one without the others. Friend-only (Scheduler).
|
||||
void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) {
|
||||
this->current_component_ = component;
|
||||
this->current_source_ = source;
|
||||
this->set_loop_component_start_time_(now);
|
||||
}
|
||||
|
||||
/// Walk all registered components looking for any whose component_state_
|
||||
/// has the given flag set. Used by Component::status_clear_*_slow_path_()
|
||||
/// (which is a friend) to decide whether to clear the corresponding bit on
|
||||
@@ -482,6 +495,7 @@ class Application {
|
||||
|
||||
// Pointer-sized members first
|
||||
Component *current_component_{nullptr};
|
||||
const LogString *current_source_{nullptr};
|
||||
|
||||
// std::vector (3 pointers each: begin, end, capacity)
|
||||
// Partitioned vector design for looping components
|
||||
@@ -554,6 +568,76 @@ class Application {
|
||||
/// Global storage of Application pointer - only one Application can exist.
|
||||
extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the
|
||||
/// previous value on exit, attributing deferred work scheduled inside to that source.
|
||||
class ScopedSourceGuard {
|
||||
public:
|
||||
explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) {
|
||||
App.set_current_source(source);
|
||||
}
|
||||
~ScopedSourceGuard() { App.set_current_source(this->prev_); }
|
||||
ScopedSourceGuard(const ScopedSourceGuard &) = delete;
|
||||
ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete;
|
||||
|
||||
private:
|
||||
const LogString *prev_;
|
||||
};
|
||||
|
||||
// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the
|
||||
// main loop too long. The constructor publishes the unit's identity + dispatch time to App;
|
||||
// finish()/the cold warning path read them back, so the guard stores no copy.
|
||||
//
|
||||
// Guards must not nest: the constructor publishes to App but never restores on destruction, so a
|
||||
// nested guard would clobber the outer's context. Safe because the two dispatch sites (component
|
||||
// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback.
|
||||
class LoopBlockingGuard {
|
||||
public:
|
||||
// Publish the unit's identity + dispatch time, then start timing. The millis start lives in App,
|
||||
// so only the runtime-stats micros stamp is kept here.
|
||||
LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) {
|
||||
App.set_current_execution_context_(component, source, now);
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
this->started_us_ = micros();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Finish the timing operation and return the current time (millis)
|
||||
// Inlined: the fast path is just millis() + subtract + compare
|
||||
inline uint32_t HOT finish() {
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t elapsed_us = micros() - this->started_us_;
|
||||
// Delays have no component; accumulate into the global counter so loop() can subtract them.
|
||||
Component *component = App.get_current_component();
|
||||
if (component != nullptr) {
|
||||
component->runtime_stats_.record_time(elapsed_us);
|
||||
} else {
|
||||
ComponentRuntimeStats::global_recorded_us += elapsed_us;
|
||||
}
|
||||
#endif
|
||||
uint32_t curr_time = MillisInternal::get();
|
||||
#ifndef USE_BENCHMARK
|
||||
// Fast path: compare against constant threshold in ms (computed at compile time from centiseconds)
|
||||
static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast<uint32_t>(WARN_IF_BLOCKING_OVER_CS) * 10U;
|
||||
uint32_t blocking_time = curr_time - App.get_loop_component_start_time();
|
||||
if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] {
|
||||
warn_blocking(blocking_time);
|
||||
}
|
||||
#endif
|
||||
return curr_time;
|
||||
}
|
||||
|
||||
~LoopBlockingGuard() = default;
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
protected:
|
||||
uint32_t started_us_;
|
||||
#endif
|
||||
|
||||
private:
|
||||
// Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit.
|
||||
static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time);
|
||||
};
|
||||
|
||||
// Phase A: drain wake notifications and run the scheduler. Invoked on every
|
||||
// Application::loop() tick regardless of whether a component phase runs, so
|
||||
// scheduler items fire at their requested cadence even when the caller has
|
||||
@@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
// before/tail splits recorded below.
|
||||
uint32_t loop_active_start_us = micros();
|
||||
// Snapshot the cumulative component-recorded time so we can subtract the
|
||||
// slice that the scheduler spends inside its own WarnIfComponentBlockingGuard
|
||||
// slice that the scheduler spends inside its own LoopBlockingGuard
|
||||
// (scheduler.cpp) — that time is already counted in per-component stats,
|
||||
// so charging it again to "before" would double-count.
|
||||
uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us;
|
||||
@@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
this->current_loop_index_++) {
|
||||
Component *component = this->looping_components_[this->current_loop_index_];
|
||||
|
||||
// Update the cached time before each component runs
|
||||
this->loop_component_start_time_ = last_op_end_time;
|
||||
|
||||
{
|
||||
this->set_current_component(component);
|
||||
WarnIfComponentBlockingGuard guard{component, last_op_end_time};
|
||||
// Guard publishes this component (no script source) + dispatch time, then times loop().
|
||||
LoopBlockingGuard guard{component, nullptr, last_op_end_time};
|
||||
component->loop();
|
||||
// Use the finish method to get the current time as the end time
|
||||
last_op_end_time = guard.finish();
|
||||
|
||||
@@ -201,7 +201,10 @@ template<typename... Ts> class DelayAction : public Action<Ts...> {
|
||||
/* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER,
|
||||
/* static_name= */ reinterpret_cast<const char *>(this), /* hash_or_id= */ 0, this->delay_.value(),
|
||||
[this]() { this->play_next_(); },
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1,
|
||||
// Record the owning script (if any) so the blocking warning can name it; propagates across
|
||||
// chained delays via the scheduler.
|
||||
/* source= */ App.get_current_source());
|
||||
} else {
|
||||
// For delays with arguments, capture by value to preserve argument values
|
||||
// Arguments must be copied because original references may be invalid after delay
|
||||
@@ -212,7 +215,9 @@ template<typename... Ts> class DelayAction : public Action<Ts...> {
|
||||
/* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER,
|
||||
/* static_name= */ reinterpret_cast<const char *>(this), /* hash_or_id= */ 0, this->delay_.value(x...),
|
||||
std::move(f),
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1,
|
||||
// See the no-argument branch above: record the owning script for log attribution.
|
||||
/* source= */ App.get_current_source());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -258,9 +258,11 @@ void Component::call() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bool Component::should_warn_of_blocking(uint32_t blocking_time) {
|
||||
bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) {
|
||||
// Convert centisecond threshold to milliseconds for comparison
|
||||
uint32_t threshold_ms = static_cast<uint32_t>(this->warn_if_blocking_over_) * 10U;
|
||||
// Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate.
|
||||
threshold_ms_out = threshold_ms;
|
||||
if (blocking_time > threshold_ms) {
|
||||
// Set new threshold: blocking_time + increment, converted back to centiseconds
|
||||
uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS;
|
||||
@@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int
|
||||
uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
#endif
|
||||
|
||||
void __attribute__((noinline, cold))
|
||||
WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) {
|
||||
bool should_warn;
|
||||
void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) {
|
||||
// Identity is published on App by the caller before the guard is built; read it back here.
|
||||
Component *component = App.get_current_component();
|
||||
// Component-less path always warns (the caller already checked the constant threshold).
|
||||
uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS;
|
||||
if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) {
|
||||
return; // Component's (possibly ratcheted) threshold not exceeded yet
|
||||
}
|
||||
// Component name if any, else the published source (owning script), else a generic label.
|
||||
const LogString *name;
|
||||
if (component != nullptr) {
|
||||
should_warn = component->should_warn_of_blocking(blocking_time);
|
||||
name = component->get_component_log_str();
|
||||
} else {
|
||||
should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller
|
||||
}
|
||||
if (should_warn) {
|
||||
ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms",
|
||||
component == nullptr ? LOG_STR_LITERAL("<null>") : LOG_STR_ARG(component->get_component_log_str()),
|
||||
blocking_time);
|
||||
name = App.get_current_source();
|
||||
if (name == nullptr)
|
||||
name = LOG_STR("a scheduled task");
|
||||
}
|
||||
ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name),
|
||||
blocking_time, threshold_ms);
|
||||
}
|
||||
|
||||
#ifdef USE_SETUP_PRIORITY_OVERRIDE
|
||||
|
||||
@@ -118,7 +118,7 @@ struct ComponentRuntimeStats {
|
||||
|
||||
// Cumulative sum of every record_time() duration since boot, across all
|
||||
// components. Used by Application::loop() to snapshot time spent inside
|
||||
// WarnIfComponentBlockingGuard (including guards constructed by the
|
||||
// LoopBlockingGuard (including guards constructed by the
|
||||
// scheduler at scheduler.cpp) so main-loop overhead accounting can
|
||||
// subtract scheduled-callback time from the before_loop_tasks_ wall time.
|
||||
static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -326,7 +326,7 @@ class Component {
|
||||
return component_source_lookup(this->component_source_index_);
|
||||
}
|
||||
|
||||
bool should_warn_of_blocking(uint32_t blocking_time);
|
||||
bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out);
|
||||
|
||||
protected:
|
||||
friend class Application;
|
||||
@@ -571,7 +571,7 @@ class Component {
|
||||
volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
friend class runtime_stats::RuntimeStatsCollector;
|
||||
friend class WarnIfComponentBlockingGuard;
|
||||
friend class LoopBlockingGuard;
|
||||
ComponentRuntimeStats runtime_stats_;
|
||||
#endif
|
||||
};
|
||||
@@ -619,59 +619,7 @@ class PollingComponent : public Component {
|
||||
uint32_t update_interval_;
|
||||
};
|
||||
|
||||
// millis() and micros() are available via hal.h
|
||||
|
||||
class WarnIfComponentBlockingGuard {
|
||||
public:
|
||||
WarnIfComponentBlockingGuard(Component *component, uint32_t start_time)
|
||||
: started_(start_time),
|
||||
component_(component)
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
,
|
||||
started_us_(micros())
|
||||
#endif
|
||||
{
|
||||
}
|
||||
|
||||
// Finish the timing operation and return the current time (millis)
|
||||
// Inlined: the fast path is just millis() + subtract + compare
|
||||
inline uint32_t HOT finish() {
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t elapsed_us = micros() - this->started_us_;
|
||||
// component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...))
|
||||
if (this->component_ != nullptr) {
|
||||
this->component_->runtime_stats_.record_time(elapsed_us);
|
||||
} else {
|
||||
// Still accumulate into the global counter so Application::loop() can subtract
|
||||
// this time from before_loop_tasks_ wall time.
|
||||
ComponentRuntimeStats::global_recorded_us += elapsed_us;
|
||||
}
|
||||
#endif
|
||||
uint32_t curr_time = MillisInternal::get();
|
||||
#ifndef USE_BENCHMARK
|
||||
// Fast path: compare against constant threshold in ms (computed at compile time from centiseconds)
|
||||
static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast<uint32_t>(WARN_IF_BLOCKING_OVER_CS) * 10U;
|
||||
uint32_t blocking_time = curr_time - this->started_;
|
||||
if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] {
|
||||
warn_blocking(this->component_, blocking_time);
|
||||
}
|
||||
#endif
|
||||
return curr_time;
|
||||
}
|
||||
|
||||
~WarnIfComponentBlockingGuard() = default;
|
||||
|
||||
protected:
|
||||
uint32_t started_;
|
||||
Component *component_;
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t started_us_;
|
||||
#endif
|
||||
|
||||
private:
|
||||
// Cold path for blocking warning - defined in component.cpp
|
||||
static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time);
|
||||
};
|
||||
// LoopBlockingGuard lives in application.h because it reads its state from App.
|
||||
|
||||
// Function to clear setup priority overrides after all components are set up
|
||||
// Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined
|
||||
|
||||
@@ -503,8 +503,58 @@ async def add_includes(includes: list[str], is_c_header: bool = False) -> None:
|
||||
include_file(path, basename, is_c_header)
|
||||
|
||||
|
||||
def _add_library_str(lib: str) -> None:
|
||||
if "@" in lib:
|
||||
name, vers = lib.split("@", 1)
|
||||
cg.add_library(name, vers)
|
||||
elif "://" in lib:
|
||||
# Repository...
|
||||
if "=" in lib:
|
||||
name, repo = lib.split("=", 1)
|
||||
cg.add_library(name, None, repo)
|
||||
else:
|
||||
cg.add_library(None, None, lib)
|
||||
else:
|
||||
cg.add_library(lib, None)
|
||||
|
||||
|
||||
@coroutine_with_priority(CoroPriority.FINAL)
|
||||
async def _add_platformio_options(pio_options):
|
||||
async def _add_platformio_options(pio_options: dict[str, str | list[str]]) -> None:
|
||||
if CORE.using_toolchain_esp_idf:
|
||||
# The native ESP-IDF build doesn't read platformio.ini; honor the
|
||||
# options with a native equivalent and warn about the rest, which
|
||||
# would otherwise be silently ignored.
|
||||
for key, val in pio_options.items():
|
||||
vals = [val] if isinstance(val, str) else val
|
||||
if key == CONF_BUILD_FLAGS:
|
||||
# Deprecated: esphome->build_flags is the native equivalent.
|
||||
# Remove before 2026.12.0
|
||||
_LOGGER.warning(
|
||||
"esphome->platformio_options->build_flags is deprecated; use "
|
||||
"esphome->build_flags instead. Support for it will be removed "
|
||||
"in 2026.12.0."
|
||||
)
|
||||
for flag in vals:
|
||||
cg.add_build_flag(flag)
|
||||
elif key == "lib_deps":
|
||||
# Routed through the regular library mechanism so the libraries
|
||||
# are converted to IDF components like any other PIO library
|
||||
for lib in vals:
|
||||
_add_library_str(lib)
|
||||
elif key == "lib_ignore":
|
||||
# Read by the PIO-library-to-IDF-component conversion
|
||||
# (generate_idf_components); filters both top-level libraries
|
||||
# and dependencies discovered during conversion
|
||||
cg.add_platformio_option(key, vals)
|
||||
elif key != "upload_speed":
|
||||
# upload_speed needs no handling: it is read from the raw
|
||||
# config at upload time (upload_using_esptool)
|
||||
_LOGGER.warning(
|
||||
"esphome->platformio_options->%s is ignored when building with "
|
||||
"the native ESP-IDF toolchain",
|
||||
key,
|
||||
)
|
||||
return
|
||||
# Add includes at the very end, so that they override everything
|
||||
for key, val in pio_options.items():
|
||||
if key in ["build_flags", "lib_ignore"] and not isinstance(val, list):
|
||||
@@ -655,19 +705,7 @@ async def to_code(config: ConfigType) -> None:
|
||||
|
||||
# Libraries
|
||||
for lib in config[CONF_LIBRARIES]:
|
||||
if "@" in lib:
|
||||
name, vers = lib.split("@", 1)
|
||||
cg.add_library(name, vers)
|
||||
elif "://" in lib:
|
||||
# Repository...
|
||||
if "=" in lib:
|
||||
name, repo = lib.split("=", 1)
|
||||
cg.add_library(name, None, repo)
|
||||
else:
|
||||
cg.add_library(None, None, lib)
|
||||
|
||||
else:
|
||||
cg.add_library(lib, None)
|
||||
_add_library_str(lib)
|
||||
|
||||
cg.add_build_flag("-Wno-unused-variable")
|
||||
cg.add_build_flag("-Wno-unused-but-set-variable")
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace esphome {
|
||||
|
||||
// Friend-gated accessor for a fast millis() variant intended only for
|
||||
// known task-context callers on the main loop hot path (Application::loop()
|
||||
// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context
|
||||
// and LoopBlockingGuard::finish()). It skips the ISR-context
|
||||
// dispatch that the public esphome::millis() pays on ESP32 and libretiny.
|
||||
//
|
||||
// MUST NOT be called from ISR context: on ESP32 and libretiny it calls the
|
||||
@@ -50,7 +50,7 @@ class MillisInternal {
|
||||
#endif
|
||||
}
|
||||
friend class Application;
|
||||
friend class WarnIfComponentBlockingGuard;
|
||||
friend class LoopBlockingGuard;
|
||||
};
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t
|
||||
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
|
||||
void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type,
|
||||
const char *static_name, uint32_t hash_or_id, uint32_t delay,
|
||||
std::function<void()> &&func, bool is_retry, bool skip_cancel) {
|
||||
std::function<void()> &&func, bool is_retry, bool skip_cancel,
|
||||
const LogString *source) {
|
||||
if (delay == SCHEDULER_DONT_RUN) {
|
||||
// Still need to cancel existing timer if we have a name/id
|
||||
if (!skip_cancel) {
|
||||
@@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
|
||||
|
||||
// Create and populate the scheduler item
|
||||
SchedulerItem *item = this->get_item_from_pool_locked_();
|
||||
item->component = component;
|
||||
// SELF_POINTER items store the source name (owning script) in the union slot instead of a component.
|
||||
if (name_type == NameType::SELF_POINTER) {
|
||||
item->source_name = source;
|
||||
} else {
|
||||
item->component = component;
|
||||
}
|
||||
item->set_name(name_type, static_name, hash_or_id);
|
||||
item->type = type;
|
||||
// Use destroy + placement-new instead of move-assignment.
|
||||
@@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
// Not reached timeout yet, done for this call
|
||||
break;
|
||||
}
|
||||
// Don't run on failed components
|
||||
if (item->component != nullptr && item->component->is_failed()) {
|
||||
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
|
||||
if (this->is_item_failed_(item)) {
|
||||
LockGuard guard{this->lock_};
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
continue;
|
||||
@@ -790,10 +796,21 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() {
|
||||
|
||||
// Helper to execute a scheduler item
|
||||
uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) {
|
||||
App.set_current_component(item->component);
|
||||
// Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time.
|
||||
App.set_loop_component_start_time_(now);
|
||||
WarnIfComponentBlockingGuard guard{item->component, now};
|
||||
// Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared
|
||||
// union slot with a single name-type check. Self-keyed items have no owning component; their slot
|
||||
// holds the source name (e.g. the owning script), published so deferred work chained inside the
|
||||
// callback re-captures it and the blocking warning can name the script instead of "<null>".
|
||||
Component *component;
|
||||
const LogString *source;
|
||||
if (item->get_name_type() == NameType::SELF_POINTER) {
|
||||
component = nullptr;
|
||||
source = item->source_name;
|
||||
} else {
|
||||
component = item->component;
|
||||
source = nullptr;
|
||||
}
|
||||
// Guard publishes the item's identity + dispatch time, then times the callback.
|
||||
LoopBlockingGuard guard{component, source, now};
|
||||
item->callback();
|
||||
uint32_t end = guard.finish();
|
||||
// Feed the watchdog after each scheduled item (both main heap and defer
|
||||
|
||||
@@ -183,11 +183,12 @@ class Scheduler {
|
||||
|
||||
protected:
|
||||
struct SchedulerItem {
|
||||
// Ordered by size to minimize padding.
|
||||
// `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive).
|
||||
// Ordered by size to minimize padding. Mutually exclusive by state; read the component via
|
||||
// get_component() so SELF_POINTER items read as component-less.
|
||||
union {
|
||||
Component *component;
|
||||
SchedulerItem *next_free;
|
||||
Component *component; // live, non-SELF_POINTER: owning component
|
||||
const LogString *source_name; // live SELF_POINTER: owning script name (log attribution)
|
||||
SchedulerItem *next_free; // while pooled
|
||||
};
|
||||
// Optimized name storage using tagged union - zero heap allocation
|
||||
union {
|
||||
@@ -302,14 +303,23 @@ class Scheduler {
|
||||
next_execution_high_ = static_cast<uint16_t>(value >> 32);
|
||||
}
|
||||
constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; }
|
||||
const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); }
|
||||
// The owning component, or nullptr for SELF_POINTER items (whose slot holds source_name instead).
|
||||
// All component access goes through this so SELF_POINTER items read as component-less.
|
||||
Component *get_component() const { return name_type_ == NameType::SELF_POINTER ? nullptr : component; }
|
||||
const LogString *get_source() const {
|
||||
// Same no-source label as warn_blocking, for consistent log vocabulary.
|
||||
if (name_type_ == NameType::SELF_POINTER)
|
||||
return source_name != nullptr ? source_name : LOG_STR("a scheduled task");
|
||||
return component != nullptr ? component->get_component_log_str() : LOG_STR("unknown");
|
||||
}
|
||||
};
|
||||
|
||||
// Common implementation for both timeout and interval
|
||||
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
|
||||
// `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise.
|
||||
void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name,
|
||||
uint32_t hash_or_id, uint32_t delay, std::function<void()> &&func, bool is_retry = false,
|
||||
bool skip_cancel = false);
|
||||
bool skip_cancel = false, const LogString *source = nullptr);
|
||||
|
||||
// Common implementation for retry - Remove before 2026.8.0
|
||||
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
|
||||
@@ -402,8 +412,10 @@ class Scheduler {
|
||||
// Fixes: https://github.com/esphome/esphome/issues/11940
|
||||
if (item == nullptr)
|
||||
return false;
|
||||
if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) ||
|
||||
(match_retry && !item->is_retry)) {
|
||||
// get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they
|
||||
// match by the `this` key alone.
|
||||
if (item->get_component() != component || item->type != type ||
|
||||
(skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) {
|
||||
return false;
|
||||
}
|
||||
// Name type must match
|
||||
@@ -423,11 +435,16 @@ class Scheduler {
|
||||
// Helper to execute a scheduler item
|
||||
uint32_t execute_item_(SchedulerItem *item, uint32_t now);
|
||||
|
||||
// Helper to check if item should be skipped
|
||||
bool should_skip_item_(SchedulerItem *item) const {
|
||||
return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed());
|
||||
// True if the item's component is failed (so it must not run). SELF_POINTER delays have no
|
||||
// component (get_component() == nullptr) and always fire.
|
||||
bool is_item_failed_(SchedulerItem *item) const {
|
||||
Component *component = item->get_component();
|
||||
return component != nullptr && component->is_failed();
|
||||
}
|
||||
|
||||
// Helper to check if item should be skipped
|
||||
bool should_skip_item_(SchedulerItem *item) const { return is_item_removed_(item) || this->is_item_failed_(item); }
|
||||
|
||||
// Helper to recycle a SchedulerItem back to the pool.
|
||||
// Takes a raw pointer — caller transfers ownership. The item is either added to the
|
||||
// pool or deleted if the pool is full.
|
||||
|
||||
@@ -162,7 +162,7 @@ def _setup_core(work_dir: Path, settings: _Settings) -> None:
|
||||
|
||||
# Gates arduino-only components in esphome/idf_component.yml (IDF reads it at
|
||||
# reconfigure time). Set here -- before the manifest is written/reconfigured.
|
||||
os.environ["ESPHOME_ARDUINO"] = (
|
||||
os.environ["ESPHOME_ARDUINO_COMPONENT"] = (
|
||||
"1" if settings.target_framework == "arduino" else "0"
|
||||
)
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ ESPHOME_DATA_EXTRA_CMAKE_KEY = "EXTRA_CMAKE"
|
||||
|
||||
|
||||
class Source:
|
||||
def download(self, dir_suffix: str, force: bool = False) -> Path:
|
||||
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -64,10 +64,12 @@ class URLSource(Source):
|
||||
def __init__(self, url: str):
|
||||
self.url = url
|
||||
|
||||
def download(self, dir_suffix: str, force: bool = False) -> Path:
|
||||
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
|
||||
base_dir = Path(CORE.data_dir) / DOMAIN
|
||||
h = hashlib.new("sha256")
|
||||
h.update(self.url.encode())
|
||||
if salt:
|
||||
h.update(salt.encode())
|
||||
path = base_dir / h.hexdigest()[:8] / dir_suffix
|
||||
# Marker file written last to signal a complete extraction. Using a
|
||||
# marker (instead of just `path.is_dir()`) means an interrupted
|
||||
@@ -99,12 +101,12 @@ class GitSource(Source):
|
||||
self.url = url
|
||||
self.ref = ref
|
||||
|
||||
def download(self, dir_suffix: str, force: bool = False) -> Path:
|
||||
def download(self, dir_suffix: str, force: bool = False, salt: str = "") -> Path:
|
||||
path, _ = git.clone_or_update(
|
||||
url=self.url,
|
||||
ref=self.ref,
|
||||
refresh=git.NEVER_REFRESH if not force else None,
|
||||
domain=DOMAIN,
|
||||
domain=f"{DOMAIN}/{salt}" if salt else DOMAIN,
|
||||
submodules=[],
|
||||
subpath=Path(dir_suffix),
|
||||
)
|
||||
@@ -146,14 +148,16 @@ class IDFComponent:
|
||||
def get_require_name(self):
|
||||
return self.get_sanitized_name().replace("/", "__")
|
||||
|
||||
def download(self, force: bool = False):
|
||||
def download(self, force: bool = False, salt: str = ""):
|
||||
"""
|
||||
The dependency name should match the directory name at the end of the override path.
|
||||
The ESP-IDF build system uses the directory name as the component name, so the directory of the override_path should match the component name.
|
||||
If you want to specify the full name of the component with the namespace, replace / in the component name with __.
|
||||
@see https://docs.espressif.com/projects/idf-component-manager/en/latest/reference/manifest_file.html
|
||||
"""
|
||||
self.path = self.source.download(self.get_sanitized_name(), force=force)
|
||||
self.path = self.source.download(
|
||||
self.get_sanitized_name(), force=force, salt=salt
|
||||
)
|
||||
|
||||
|
||||
def _apply_extra_script(component: IDFComponent) -> None:
|
||||
@@ -699,9 +703,33 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
|
||||
The returned list holds the top-level components (those directly requested);
|
||||
transitive dependencies are converted too and wired into each component's
|
||||
generated manifest.
|
||||
|
||||
``lib_ignore`` from ``esphome->platformio_options`` excludes libraries by
|
||||
short name (part after the ``/``), matched against both the top-level
|
||||
libraries and every dependency discovered during the graph walk.
|
||||
"""
|
||||
nodes: dict[str, _LibNode] = {}
|
||||
|
||||
lib_ignore = {
|
||||
name.split("/")[-1].lower()
|
||||
for name in CORE.platformio_options.get("lib_ignore", [])
|
||||
}
|
||||
|
||||
# The generated CMakeLists.txt/idf_component.yml inside the shared cache
|
||||
# bake in the dependency wiring, which lib_ignore changes; salt the cache
|
||||
# path so configs with different lib_ignore values don't fight over (and
|
||||
# constantly rewrite) the same converted component files.
|
||||
salt = (
|
||||
hashlib.sha256(",".join(sorted(lib_ignore)).encode()).hexdigest()[:8]
|
||||
if lib_ignore
|
||||
else ""
|
||||
)
|
||||
|
||||
def is_ignored(name: str | None) -> bool:
|
||||
if not lib_ignore or name is None:
|
||||
return False
|
||||
return name.split("/")[-1].lower() in lib_ignore
|
||||
|
||||
def add_spec(name: str | None, version: str | None, repository: str | None) -> str:
|
||||
key, is_git, locator = _node_key(name, version, repository)
|
||||
node = nodes.get(key) or _LibNode(key=key, is_git=is_git)
|
||||
@@ -718,6 +746,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
|
||||
top_level = [
|
||||
add_spec(library.name, library.version, library.repository)
|
||||
for library in libraries
|
||||
if not is_ignored(library.name)
|
||||
]
|
||||
|
||||
# Collect + resolve to a fixpoint: a node is (re)resolved whenever its
|
||||
@@ -749,7 +778,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
|
||||
component = IDFComponent(
|
||||
_owner_pkgname_to_name(owner, name), version, URLSource(url)
|
||||
)
|
||||
component.download()
|
||||
component.download(salt=salt)
|
||||
|
||||
library_json_path = component.path / "library.json"
|
||||
library_properties_path = component.path / "library.properties"
|
||||
@@ -787,6 +816,12 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
|
||||
except InvalidIDFComponent as e:
|
||||
_LOGGER.debug("Skip dependency %s: %s", dependency.get("name"), str(e))
|
||||
continue
|
||||
dep_name = _owner_pkgname_to_name(
|
||||
dependency.get("owner"), dependency.get("name")
|
||||
)
|
||||
if is_ignored(dep_name):
|
||||
_LOGGER.debug("Skip ignored dependency %s", dep_name)
|
||||
continue
|
||||
# The version field may actually be a URL (git/archive dependency).
|
||||
dep_version = dependency["version"]
|
||||
dep_url = None
|
||||
@@ -796,11 +831,7 @@ def generate_idf_components(libraries: list[Library]) -> list[IDFComponent]:
|
||||
dep_url, dep_version = dep_version, None
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
dep_key = add_spec(
|
||||
_owner_pkgname_to_name(dependency.get("owner"), dependency.get("name")),
|
||||
dep_version,
|
||||
dep_url,
|
||||
)
|
||||
dep_key = add_spec(dep_name, dep_version, dep_url)
|
||||
node.edges.add(dep_key)
|
||||
worklist.append(dep_key)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
@@ -109,4 +109,4 @@ dependencies:
|
||||
git: https://github.com/FastLED/FastLED.git
|
||||
version: d44c800a9e876a8394caefc2ce4915dd96dac77b
|
||||
rules:
|
||||
- if: "$ESPHOME_ARDUINO == 1"
|
||||
- if: "$ESPHOME_ARDUINO_COMPONENT == 1"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -141,7 +141,10 @@ extra_scripts = post:esphome/components/esp8266/post_build.py.script
|
||||
; This are common settings for the ESP32 (all variants) using Arduino.
|
||||
[common:esp32-arduino]
|
||||
extends = common:arduino
|
||||
platform = https://github.com/pioarduino/platform-espressif32.git
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip
|
||||
platform_packages =
|
||||
pioarduino/framework-arduinoespressif32@https://github.com/espressif/arduino-esp32/releases/download/3.3.9/esp32-core-3.3.9.tar.xz
|
||||
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
|
||||
|
||||
framework = arduino, espidf ; Arduino as an ESP-IDF component
|
||||
lib_deps =
|
||||
@@ -168,12 +171,16 @@ build_flags =
|
||||
-DAUDIO_NO_SD_FS ; i2s_audio
|
||||
build_unflags =
|
||||
${common.build_unflags}
|
||||
extra_scripts = post:esphome/components/esp32/post_build.py.script
|
||||
extra_scripts =
|
||||
pre:esphome/components/esp32/pre_build.py.script
|
||||
post:esphome/components/esp32/post_build.py.script
|
||||
|
||||
; This are common settings for the ESP32 (all variants) using IDF.
|
||||
[common:esp32-idf]
|
||||
extends = common:idf
|
||||
platform = https://github.com/pioarduino/platform-espressif32.git
|
||||
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.39/platform-espressif32.zip
|
||||
platform_packages =
|
||||
pioarduino/framework-espidf@https://github.com/pioarduino/esp-idf/releases/download/v5.5.4/esp-idf-v5.5.4.tar.xz
|
||||
|
||||
framework = espidf
|
||||
lib_deps =
|
||||
@@ -187,7 +194,9 @@ build_flags =
|
||||
-DUSE_ESP32_FRAMEWORK_ESP_IDF
|
||||
build_unflags =
|
||||
${common.build_unflags}
|
||||
extra_scripts = post:esphome/components/esp32/post_build.py.script
|
||||
extra_scripts =
|
||||
pre:esphome/components/esp32/pre_build.py.script
|
||||
post:esphome/components/esp32/post_build.py.script
|
||||
|
||||
; These are common settings for the RP2040 using Arduino.
|
||||
[common:rp2040-arduino]
|
||||
@@ -271,7 +280,6 @@ build_unflags =
|
||||
[env:esp32-arduino]
|
||||
extends = common:esp32-arduino
|
||||
board = esp32dev
|
||||
board_build.partitions = huge_app.csv
|
||||
build_flags =
|
||||
${common:esp32-arduino.build_flags}
|
||||
${flags:runtime.build_flags}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -428,6 +428,33 @@ def fix_menu():
|
||||
menu[S_EXTENDS].append("display_menu_base.MENU_TYPES")
|
||||
|
||||
|
||||
def fix_lvgl_widgets():
|
||||
# lvgl's `widgets:` is a recursive tree (a widget can contain widgets). The
|
||||
# dumper has no cycle detection, so — like fix_menu — hoist the inlined
|
||||
# widget-type enumeration into a named schema and reference it for both the
|
||||
# top-level list and each widget's own children, instead of expanding it.
|
||||
if "lvgl" not in output:
|
||||
return
|
||||
schemas = output["lvgl"][S_SCHEMAS]
|
||||
config_vars = schemas["CONFIG_SCHEMA"][S_SCHEMA][S_CONFIG_VARS]
|
||||
widgets = config_vars.get("widgets")
|
||||
if not widgets or S_SCHEMA not in widgets or S_CONFIG_VARS not in widgets[S_SCHEMA]:
|
||||
return
|
||||
# 1. Hoist the (one-level) widget enumeration into a named schema.
|
||||
schemas["WIDGET_TYPES"] = {S_TYPE: S_SCHEMA, S_SCHEMA: widgets[S_SCHEMA]}
|
||||
# 2. Reference it from the top-level widgets: list instead of inlining.
|
||||
widgets[S_SCHEMA] = {S_EXTENDS: ["lvgl.WIDGET_TYPES"]}
|
||||
# 3. Let every widget contain child widgets, via the same named ref.
|
||||
for widget in schemas["WIDGET_TYPES"][S_SCHEMA][S_CONFIG_VARS].values():
|
||||
if widget.get(S_TYPE) == S_SCHEMA and S_SCHEMA in widget:
|
||||
widget[S_SCHEMA].setdefault(S_CONFIG_VARS, {})["widgets"] = {
|
||||
S_TYPE: S_SCHEMA,
|
||||
"is_list": True,
|
||||
"key": "Optional",
|
||||
S_SCHEMA: {S_EXTENDS: ["lvgl.WIDGET_TYPES"]},
|
||||
}
|
||||
|
||||
|
||||
def get_logger_tags():
|
||||
pattern = re.compile(r'^static const char \*const TAG = "(\w.*)";', re.MULTILINE)
|
||||
# tags not in components dir
|
||||
@@ -740,6 +767,7 @@ def build_schema():
|
||||
add_logger_tags()
|
||||
shrink()
|
||||
fix_menu()
|
||||
fix_lvgl_widgets()
|
||||
|
||||
# aggregate components, so all component info is in same file, otherwise we have dallas.json, dallas.sensor.json, etc.
|
||||
data = {}
|
||||
@@ -923,6 +951,15 @@ def convert(schema, config_var, path):
|
||||
elif schema_type == "enum":
|
||||
config_var[S_TYPE] = "enum"
|
||||
config_var["values"] = dict.fromkeys(list(data.keys()))
|
||||
elif schema_type == "variant_enum":
|
||||
# Per-variant enum (e.g. psram mode/speed): each value carries the
|
||||
# list of variants that accept it so clients can filter to the
|
||||
# user's selected variant. Additive to the plain enum format —
|
||||
# consumers that ignore the metadata still see every option.
|
||||
config_var[S_TYPE] = "enum"
|
||||
config_var["values"] = {
|
||||
value: {"variants": variants} for value, variants in data.items()
|
||||
}
|
||||
elif schema_type == "maybe":
|
||||
# maybe_simple_value: either a scalar shorthand (mapped to the key in
|
||||
# data[1]) or the full wrapped schema. The wrapped schema is usually a
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
framework:
|
||||
type: esp-idf
|
||||
9
tests/component_tests/esp32/config/flash_mode_idf.yaml
Normal file
9
tests/component_tests/esp32/config/flash_mode_idf.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
flash_mode: qio
|
||||
flash_frequency: 80MHz
|
||||
framework:
|
||||
type: esp-idf
|
||||
@@ -285,3 +285,29 @@ def test_native_idf_enables_reproducible_build(
|
||||
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert sdkconfig.get("CONFIG_APP_REPRODUCIBLE_BUILD") is True
|
||||
|
||||
|
||||
def test_flash_mode_sets_sdkconfig_and_pio_option(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""flash_mode/flash_frequency select the esptool flash parameters on both backends."""
|
||||
generate_main(component_config_path("flash_mode_idf.yaml"))
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHMODE_QIO") is True
|
||||
assert sdkconfig.get("CONFIG_ESPTOOLPY_FLASHFREQ_80M") is True
|
||||
assert CORE.platformio_options.get("board_build.flash_mode") == "qio"
|
||||
assert CORE.platformio_options.get("board_build.f_flash") == "80000000L"
|
||||
|
||||
|
||||
def test_flash_mode_unset_leaves_defaults(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Without flash_mode the board/sdkconfig defaults stay untouched."""
|
||||
generate_main(component_config_path("flash_mode_default.yaml"))
|
||||
sdkconfig = CORE.data[KEY_ESP32][KEY_SDKCONFIG_OPTIONS]
|
||||
assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHMODE_") for key in sdkconfig)
|
||||
assert not any(key.startswith("CONFIG_ESPTOOLPY_FLASHFREQ_") for key in sdkconfig)
|
||||
assert "board_build.flash_mode" not in CORE.platformio_options
|
||||
assert "board_build.f_flash" not in CORE.platformio_options
|
||||
|
||||
@@ -314,7 +314,7 @@ def test_native_generation(
|
||||
|
||||
main_cpp = generate_main(component_fixture_path("native.yaml"))
|
||||
assert (
|
||||
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, true, 1, 1>()"
|
||||
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, 0, 0, true, 1, 1>()"
|
||||
in main_cpp
|
||||
)
|
||||
assert "set_init_sequence({240, 1, 8, 242" in main_cpp
|
||||
@@ -330,7 +330,7 @@ def test_lvgl_generation(
|
||||
|
||||
main_cpp = generate_main(component_fixture_path("lvgl.yaml"))
|
||||
assert (
|
||||
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, true>();"
|
||||
"mipi_spi::MipiSpi<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_SINGLE, 128, 160, 0, 0, 0, 0, 0, true>();"
|
||||
in main_cpp
|
||||
)
|
||||
assert "set_init_sequence({1, 0, 10, 255, 177" in main_cpp
|
||||
|
||||
434
tests/component_tests/mipi_spi/test_padding_and_offsets.py
Normal file
434
tests/component_tests/mipi_spi/test_padding_and_offsets.py
Normal file
@@ -0,0 +1,434 @@
|
||||
"""Tests for padding, offset calculation, and SPI mode configuration in mipi_spi."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome.components.esp32 import (
|
||||
KEY_BOARD,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
from esphome.components.mipi_spi.display import (
|
||||
CONFIG_SCHEMA,
|
||||
FINAL_VALIDATE_SCHEMA,
|
||||
MODELS,
|
||||
get_instance,
|
||||
)
|
||||
from esphome.components.spi import CONF_SPI_MODE, TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
|
||||
from esphome.const import CONF_CS_PIN, CONF_DC_PIN, PlatformFramework
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def validated_config(config: ConfigType) -> ConfigType:
|
||||
"""Run schema + final validation and return the validated config."""
|
||||
config = CONFIG_SCHEMA(config)
|
||||
FINAL_VALIDATE_SCHEMA(config)
|
||||
return config
|
||||
|
||||
|
||||
class TestSPIModeCalculation:
|
||||
"""Test default SPI mode calculation logic."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("bus_mode", "cs_pin", "expected_mode"),
|
||||
[
|
||||
pytest.param(
|
||||
TYPE_OCTAL,
|
||||
None,
|
||||
"MODE3",
|
||||
id="octal_bus_no_cs",
|
||||
),
|
||||
pytest.param(
|
||||
TYPE_OCTAL,
|
||||
14,
|
||||
"MODE3",
|
||||
id="octal_bus_with_cs",
|
||||
),
|
||||
pytest.param(
|
||||
TYPE_SINGLE,
|
||||
None,
|
||||
"MODE3",
|
||||
id="single_bus_no_cs",
|
||||
),
|
||||
pytest.param(
|
||||
TYPE_SINGLE,
|
||||
14,
|
||||
"MODE0",
|
||||
id="single_bus_with_cs",
|
||||
),
|
||||
pytest.param(
|
||||
TYPE_QUAD,
|
||||
None,
|
||||
"MODE0",
|
||||
id="quad_bus_no_cs",
|
||||
),
|
||||
pytest.param(
|
||||
TYPE_QUAD,
|
||||
14,
|
||||
"MODE0",
|
||||
id="quad_bus_with_cs",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_default_spi_mode_calculation(
|
||||
self,
|
||||
bus_mode: str,
|
||||
cs_pin: int | None,
|
||||
expected_mode: str,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Test that SPI mode is correctly calculated based on bus mode and CS pin."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
},
|
||||
)
|
||||
|
||||
config: ConfigType = {
|
||||
"model": "custom",
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
"bus_mode": bus_mode,
|
||||
}
|
||||
|
||||
# Add dc_pin for modes that require it (single and octal)
|
||||
# quad mode does not allow dc_pin
|
||||
if bus_mode != TYPE_QUAD:
|
||||
config[CONF_DC_PIN] = 11
|
||||
|
||||
# Add CS pin if specified
|
||||
if cs_pin is not None:
|
||||
config[CONF_CS_PIN] = cs_pin
|
||||
|
||||
validated = validated_config(config)
|
||||
# The validated config should have the correct SPI mode set by model_schema
|
||||
assert validated.get(CONF_SPI_MODE) == expected_mode
|
||||
|
||||
def test_explicit_spi_mode_overrides_default(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Test that an explicitly configured SPI mode is not overridden."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
},
|
||||
)
|
||||
|
||||
# For octal bus, default is MODE3, but we specify MODE0
|
||||
config = validated_config(
|
||||
{
|
||||
"model": "custom",
|
||||
"dc_pin": 11, # Required for octal mode
|
||||
"dimensions": {"width": 320, "height": 240},
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
"bus_mode": TYPE_OCTAL,
|
||||
"spi_mode": "MODE0", # Explicitly set
|
||||
}
|
||||
)
|
||||
|
||||
assert config[CONF_SPI_MODE] == "MODE0"
|
||||
|
||||
|
||||
class TestModelWithPaddingDimensions:
|
||||
"""Test that padding dimensions are correctly returned by models."""
|
||||
|
||||
def test_model_get_dimensions_returns_six_values(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Test that get_dimensions() returns 6 values including padding."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
},
|
||||
)
|
||||
|
||||
# Test with a real model
|
||||
model = MODELS["ST7735"]
|
||||
config = {"model": "ST7735", "dc_pin": 18}
|
||||
|
||||
# Call get_dimensions - should return 6 values (width, height, offset_x, offset_y, pad_width, pad_height)
|
||||
dimensions = model.get_dimensions(config)
|
||||
assert len(dimensions) == 6
|
||||
assert all(isinstance(v, int) for v in dimensions)
|
||||
|
||||
def test_custom_model_padding_values(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Test padding values for a custom model with explicit offset."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
config = validated_config(
|
||||
{
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {
|
||||
"width": 240,
|
||||
"height": 320,
|
||||
"offset_width": 20,
|
||||
"offset_height": 10,
|
||||
},
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
}
|
||||
)
|
||||
|
||||
# For custom models, the model is created dynamically from the config
|
||||
# We can verify the config has the right dimensions
|
||||
assert config["dimensions"]["width"] == 240
|
||||
assert config["dimensions"]["height"] == 320
|
||||
assert config["dimensions"]["offset_width"] == 20
|
||||
assert config["dimensions"]["offset_height"] == 10
|
||||
# Padding is not stored in config for custom models (defaults to 0)
|
||||
assert config["dimensions"].get("offset_width_pad", 0) == 0
|
||||
assert config["dimensions"].get("offset_height_pad", 0) == 0
|
||||
|
||||
|
||||
class TestNewModelVariants:
|
||||
"""Test new model variants added in this change."""
|
||||
|
||||
def test_m5core2_with_native_dimensions(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Test M5CORE2 variant with reset native_width and native_height."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
},
|
||||
)
|
||||
|
||||
# M5CORE2 should validate successfully
|
||||
config = validated_config({"model": "M5CORE2"})
|
||||
assert config is not None
|
||||
|
||||
# Verify the model has correct dimensions
|
||||
model = MODELS["M5CORE2"]
|
||||
dimensions = model.get_dimensions(config)
|
||||
width, height, _, _, _, _ = dimensions
|
||||
assert width == 320
|
||||
assert height == 240
|
||||
|
||||
def test_geekmagic_smalltv_variant(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Test GEEKMAGIC-SMALLTV variant of ST7789V."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# GEEKMAGIC-SMALLTV should validate successfully
|
||||
config = validated_config({"model": "GEEKMAGIC-SMALLTV"})
|
||||
assert config is not None
|
||||
|
||||
# Verify it's a variant of ST7789V with expected dimensions
|
||||
model = MODELS["GEEKMAGIC-SMALLTV"]
|
||||
dimensions = model.get_dimensions(config)
|
||||
width, height, offset_x, offset_y, _, _ = dimensions
|
||||
assert width == 240
|
||||
assert height == 240
|
||||
assert offset_x == 0
|
||||
assert offset_y == 0
|
||||
|
||||
def test_all_predefined_models_with_new_get_dimensions_signature(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Verify all predefined models work with new 6-value get_dimensions()."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
},
|
||||
)
|
||||
|
||||
for name, model in MODELS.items():
|
||||
# Skip custom model
|
||||
if name == "custom":
|
||||
continue
|
||||
|
||||
config = {"model": name}
|
||||
|
||||
# Try to get dimensions - should return 6 values for all models
|
||||
dimensions = model.get_dimensions(config)
|
||||
assert len(dimensions) == 6, (
|
||||
f"Model {name} should return 6 dimensions, got {len(dimensions)}"
|
||||
)
|
||||
|
||||
|
||||
class TestTemplateParameterPassing:
|
||||
"""Test that padding parameters are correctly passed to C++ templates."""
|
||||
|
||||
def test_instance_creation_with_padding(
|
||||
self,
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_fixture_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Test that get_instance() correctly passes padding parameters to template."""
|
||||
main_cpp = generate_main(component_fixture_path("native.yaml"))
|
||||
|
||||
# native.yaml uses JC3636W518 which should have 8 template parameters for MipiSpiBuffer
|
||||
# (BUFFERTYPE, BUFFERPIXEL, IS_BIG_ENDIAN, DISPLAYPIXEL, BUS_TYPE,
|
||||
# WIDTH, HEIGHT, OFFSET_WIDTH, OFFSET_HEIGHT, PAD_WIDTH, PAD_HEIGHT, MADCTL, HAS_HARDWARE_ROTATION,
|
||||
# FRACTION, ROUNDING)
|
||||
# The instantiation should include padding values (0, 0 for default)
|
||||
assert (
|
||||
"mipi_spi::MipiSpiBuffer<uint16_t, mipi_spi::PIXEL_MODE_16, true, mipi_spi::PIXEL_MODE_16, mipi_spi::BUS_TYPE_QUAD, 360, 360, 0, 1, 0, 0, 0, true, 1, 1>()"
|
||||
in main_cpp
|
||||
), (
|
||||
"Padding parameters (0, 0) should be in the MipiSpiBuffer template instantiation"
|
||||
)
|
||||
|
||||
def test_single_mode_with_offset_padding(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Test that single-mode display with custom offset works with padding."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
config = validated_config(
|
||||
{
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {
|
||||
"width": 240,
|
||||
"height": 320,
|
||||
"offset_width": 40,
|
||||
"offset_height": 20,
|
||||
},
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
"buffer_size": 0.25,
|
||||
}
|
||||
)
|
||||
|
||||
# Should not raise any errors
|
||||
instance = get_instance(config)
|
||||
assert instance is not None
|
||||
|
||||
|
||||
class TestUserConfiguredPadding:
|
||||
"""Test that pad_width and pad_height can be configured in user dimensions."""
|
||||
|
||||
def test_explicit_pad_width_and_height_in_dimensions(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Test that pad_width and pad_height can be explicitly set in dimensions."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
config = validated_config(
|
||||
{
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {
|
||||
"width": 240,
|
||||
"height": 320,
|
||||
"offset_width": 40,
|
||||
"offset_height": 20,
|
||||
"pad_width": 80,
|
||||
"pad_height": 40,
|
||||
},
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
"buffer_size": 0.25,
|
||||
}
|
||||
)
|
||||
|
||||
# Config should validate successfully with padding dimensions
|
||||
assert config is not None
|
||||
assert config["dimensions"]["pad_width"] == 80
|
||||
assert config["dimensions"]["pad_height"] == 40
|
||||
|
||||
def test_padding_for_native_dimension_calculation(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Test that explicit padding allows native dimensions to be calculated."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# A controller that has 320x320 total pixels with:
|
||||
# - 240x320 active display area
|
||||
# - offset_width=40, offset_height=20
|
||||
# - pad_width=40 (remaining pixels on right), pad_height=60 (remaining pixels on bottom)
|
||||
config = validated_config(
|
||||
{
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {
|
||||
"width": 240, # Active display width
|
||||
"height": 320, # Active display height
|
||||
"offset_width": 40,
|
||||
"offset_height": 0,
|
||||
"pad_width": 40, # Pixels after width+offset
|
||||
"pad_height": 0, # Pixels after height+offset
|
||||
},
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
"buffer_size": 0.25,
|
||||
}
|
||||
)
|
||||
|
||||
# Get instance should work and correctly calculate native dimensions
|
||||
instance = get_instance(config)
|
||||
assert instance is not None
|
||||
|
||||
def test_padding_without_offset(
|
||||
self,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""Test padding can be used without offset for controllers with top-left-aligned displays."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# A display with no offset but padding on right and bottom
|
||||
config = validated_config(
|
||||
{
|
||||
"model": "custom",
|
||||
"dc_pin": 18,
|
||||
"dimensions": {
|
||||
"width": 240,
|
||||
"height": 240,
|
||||
"offset_width": 0,
|
||||
"offset_height": 0,
|
||||
"pad_width": 0,
|
||||
"pad_height": 16,
|
||||
},
|
||||
"init_sequence": [[0xA0, 0x01]],
|
||||
"buffer_size": 0.25,
|
||||
}
|
||||
)
|
||||
|
||||
assert config is not None
|
||||
assert config["dimensions"]["width"] == 240
|
||||
assert config["dimensions"]["height"] == 240
|
||||
assert config["dimensions"]["pad_height"] == 16
|
||||
@@ -97,6 +97,54 @@ def test_psram_configuration_valid_supported_variants(
|
||||
FINAL_VALIDATE_SCHEMA(config)
|
||||
|
||||
|
||||
def test_psram_applies_single_mode_default(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""On a single-mode variant the omitted mode/speed fall back to defaults."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_VARIANT: VARIANT_ESP32},
|
||||
full_config={CONF_ESPHOME: {}},
|
||||
)
|
||||
from esphome.components.psram import CONFIG_SCHEMA
|
||||
|
||||
config = CONFIG_SCHEMA({})
|
||||
assert config["mode"] == "quad"
|
||||
assert config["speed"] == "40MHZ"
|
||||
assert config["disabled"] is False
|
||||
assert config["ignore_not_found"] is True
|
||||
|
||||
|
||||
def test_psram_requires_mode_on_multi_mode_variant(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A variant with multiple modes requires an explicit mode selection."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_VARIANT: VARIANT_ESP32S3},
|
||||
full_config={CONF_ESPHOME: {}},
|
||||
)
|
||||
from esphome.components.psram import CONFIG_SCHEMA
|
||||
|
||||
with pytest.raises(cv.Invalid, match=r"requires PSRAM mode selection"):
|
||||
CONFIG_SCHEMA({})
|
||||
|
||||
|
||||
def test_psram_rejects_mode_invalid_for_variant(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
) -> None:
|
||||
"""A mode not supported by the active variant is rejected by the schema."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_VARIANT: VARIANT_ESP32},
|
||||
full_config={CONF_ESPHOME: {}},
|
||||
)
|
||||
from esphome.components.psram import CONFIG_SCHEMA
|
||||
|
||||
with pytest.raises(cv.Invalid, match=r"Unknown value 'octal'"):
|
||||
CONFIG_SCHEMA({"mode": "octal"})
|
||||
|
||||
|
||||
def _setup_psram_final_validation_test(
|
||||
esp32_config: dict,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
|
||||
5
tests/components/psram/validate-quad.esp32-s3-idf.yaml
Normal file
5
tests/components/psram/validate-quad.esp32-s3-idf.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
# Config-only: the ESP32-S3 supports both quad and octal. The compile test uses
|
||||
# octal; this exercises the other branch of the per-variant mode enum (quad) and
|
||||
# lets speed fall back to its 40MHz default.
|
||||
psram:
|
||||
mode: quad
|
||||
4
tests/components/psram/validate.esp32-idf.yaml
Normal file
4
tests/components/psram/validate.esp32-idf.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
# Config-only: with no options the single-mode ESP32 resolves mode -> quad and
|
||||
# speed -> 40MHz from the per-variant defaults. Compiling adds no signal here,
|
||||
# so this only runs through `esphome config`.
|
||||
psram:
|
||||
4
tests/components/psram/validate.esp32-p4-idf.yaml
Normal file
4
tests/components/psram/validate.esp32-p4-idf.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
# Config-only: the ESP32-P4 has a distinct value set (hex mode, 20/100/200MHz).
|
||||
# With no options it resolves mode -> hex and speed -> 20MHz, exercising the
|
||||
# P4-specific default branch of the per-variant enums.
|
||||
psram:
|
||||
@@ -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);
|
||||
22
tests/integration/fixtures/scheduler_blocking_warning.yaml
Normal file
22
tests/integration/fixtures/scheduler_blocking_warning.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
esphome:
|
||||
name: scheduler-blocking-warning
|
||||
on_boot:
|
||||
then:
|
||||
- script.execute: blocking_script
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
# The busy-block runs in the second delay's continuation; the warning must name the script. Two
|
||||
# delays verify the source survives chained delays (the scheduler republishes it each continuation).
|
||||
script:
|
||||
- id: blocking_script
|
||||
then:
|
||||
- delay: 10ms
|
||||
- delay: 10ms
|
||||
- lambda: |-
|
||||
const uint32_t start = millis();
|
||||
while (millis() - start < 80) {
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
esphome:
|
||||
name: scheduler-blocking-generic
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
globals:
|
||||
- id: done
|
||||
type: bool
|
||||
restore_value: false
|
||||
initial_value: "false"
|
||||
|
||||
# A delay in a plain (non-script) automation has no owning script, so the block must log the
|
||||
# generic "a scheduled task" label, not a script name.
|
||||
interval:
|
||||
- interval: 100ms
|
||||
id: gen_interval
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: "return !id(done);"
|
||||
then:
|
||||
- lambda: "id(done) = true;"
|
||||
- delay: 10ms
|
||||
- lambda: |-
|
||||
const uint32_t start = millis();
|
||||
while (millis() - start < 80) {
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
esphome:
|
||||
name: scheduler-delay-failed
|
||||
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
level: DEBUG
|
||||
|
||||
globals:
|
||||
- id: started
|
||||
type: bool
|
||||
restore_value: false
|
||||
initial_value: "false"
|
||||
|
||||
# The interval marks itself failed, then schedules a delay. The delay must still fire: a failed
|
||||
# component must not drop it, since the SELF_POINTER scheduler item has no owning component.
|
||||
interval:
|
||||
- interval: 100ms
|
||||
id: host_interval
|
||||
then:
|
||||
- if:
|
||||
condition:
|
||||
lambda: "return !id(started);"
|
||||
then:
|
||||
- lambda: |-
|
||||
id(started) = true;
|
||||
id(host_interval)->mark_failed();
|
||||
- delay: 200ms
|
||||
- logger.log: "DELAY_FIRED_AFTER_FAIL"
|
||||
119
tests/integration/test_logger_buffered_recursion_guard.py
Normal file
119
tests/integration/test_logger_buffered_recursion_guard.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Integration test for the recursion guard on the buffered logger drain path.
|
||||
|
||||
Regression test for a crash where a log message drained from the task log buffer
|
||||
(i.e. logged from a non-main thread) re-entered the logger on the main task while it
|
||||
was still being delivered to listeners. The buffered drain in
|
||||
``Logger::process_messages_`` did not hold the main-task recursion guard that the
|
||||
synchronous logging path holds, so a listener callback that logged again on the main
|
||||
task (e.g. the API log-forwarding path, or a ``logger.on_message`` automation) reused
|
||||
the shared ``tx_buffer_`` and clobbered the message mid-delivery. On ESP32 this showed
|
||||
up as a ``StoreProhibited`` panic inside the API send path.
|
||||
|
||||
The fixture logs a small batch of verifiable messages from a non-main thread (kept
|
||||
under the host task-log-buffer slot count so they all take the buffered drain path
|
||||
rather than the emergency console fallback) while an ``on_message`` automation re-logs
|
||||
``REENTRANT_CLOBBER_MARKER`` on the main task for every delivered message.
|
||||
|
||||
Without the guard the re-entrant marker is written into the shared ``tx_buffer_`` while
|
||||
the buffered thread message is still being delivered, so the message the API receives is
|
||||
contaminated (it contains the marker and an embedded newline glued onto the thread
|
||||
payload). With the guard the re-entrant log is dropped during the drain, the marker
|
||||
never appears, and every thread message is delivered clean.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
from aioesphomeapi import LogLevel
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
# THREADMSGnnn_DATA_xxxxxxxx where data is a deterministic checksum of the index
|
||||
THREAD_MSG_PATTERN = re.compile(r"THREADMSG(\d{3})_DATA_([0-9A-F]{8})")
|
||||
|
||||
NUM_MESSAGES = 30
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_logger_buffered_recursion_guard(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Buffered (non-main-thread) log messages survive a re-entrant main-task log."""
|
||||
api_messages: list[str] = []
|
||||
all_drained = asyncio.Event()
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "logger-recursion-test"
|
||||
|
||||
# Subscribe over the API: this is the exact path that crashed in the field
|
||||
# (the API log callback runs during the buffered drain). The API message field
|
||||
# preserves embedded newlines, so it reliably exposes a clobbered buffer.
|
||||
#
|
||||
# Every buffered thread message is delivered here whether it survives intact or
|
||||
# gets clobbered (a clobbered message still carries its THREADMSG payload), so
|
||||
# counting THREADMSG occurrences is a deterministic "drain complete" signal: no
|
||||
# arbitrary sleep, no dependence on the fix being present.
|
||||
def on_log(msg) -> None:
|
||||
text = msg.message.decode("utf-8", errors="replace")
|
||||
api_messages.append(text)
|
||||
received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages)
|
||||
if received >= NUM_MESSAGES:
|
||||
all_drained.set()
|
||||
|
||||
client.subscribe_logs(on_log, log_level=LogLevel.LOG_LEVEL_VERY_VERBOSE)
|
||||
|
||||
entities, _ = await client.list_entities_services()
|
||||
buttons = [e for e in entities if e.name == "Start Race Test"]
|
||||
assert buttons, "Could not find Start Race Test button"
|
||||
client.button_command(buttons[0].key)
|
||||
|
||||
# Wait until every buffered thread message has been delivered over the API.
|
||||
try:
|
||||
await asyncio.wait_for(all_drained.wait(), timeout=30.0)
|
||||
except TimeoutError:
|
||||
received = sum(len(THREAD_MSG_PATTERN.findall(m)) for m in api_messages)
|
||||
pytest.fail(
|
||||
f"Only {received}/{NUM_MESSAGES} thread messages arrived before timeout; "
|
||||
"device likely crashed or hung."
|
||||
)
|
||||
|
||||
intact: set[int] = set()
|
||||
contaminated: list[str] = []
|
||||
for raw in api_messages:
|
||||
text = _ANSI.sub("", raw)
|
||||
if "THREADMSG" not in text:
|
||||
continue
|
||||
# A clean thread message is a single line carrying only its own payload. A
|
||||
# clobbered buffer glues the re-entrant marker (and an embedded newline) onto it.
|
||||
if "REENTRANT" in text or "\n" in text:
|
||||
contaminated.append(repr(raw))
|
||||
continue
|
||||
match = THREAD_MSG_PATTERN.search(text)
|
||||
assert match, f"Unexpected thread message format: {raw!r}"
|
||||
msg_num = int(match.group(1))
|
||||
expected = f"{msg_num * 12345:08X}"
|
||||
if match.group(2) != expected:
|
||||
contaminated.append(repr(raw))
|
||||
continue
|
||||
intact.add(msg_num)
|
||||
|
||||
assert not contaminated, (
|
||||
"Buffered thread messages were clobbered by a re-entrant main-task log "
|
||||
"(missing recursion guard on the buffered drain path):\n"
|
||||
+ "\n".join(contaminated[:10])
|
||||
)
|
||||
assert len(intact) == NUM_MESSAGES, (
|
||||
f"Expected {NUM_MESSAGES} intact buffered thread messages over the API, got "
|
||||
f"{len(intact)}. Missing ids: {sorted(set(range(NUM_MESSAGES)) - intact)}"
|
||||
)
|
||||
120
tests/integration/test_scheduler_blocking_warning.py
Normal file
120
tests/integration/test_scheduler_blocking_warning.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Integration tests for blocking-warning source attribution.
|
||||
|
||||
A blocking operation that runs inside a deferred scheduler continuation (e.g. after a ``delay``
|
||||
in a script) used to be reported as ``<null> took a long time for an operation (NN ms),
|
||||
max is 30 ms`` because the continuation carries no component. The warning should instead name
|
||||
the owning script and report the real threshold (50 ms).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
# Matches: "<source> took a long time for an operation (NN ms), max is NN ms"
|
||||
WARN_PATTERN = re.compile(
|
||||
r"(\S+) took a long time for an operation \((\d+) ms\), max is (\d+) ms"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_blocking_warning(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Deferred blocking work inside a script is attributed to the script, not "<null>"."""
|
||||
loop = asyncio.get_running_loop()
|
||||
warning_future: asyncio.Future[str] = loop.create_future()
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
if WARN_PATTERN.search(line) and not warning_future.done():
|
||||
warning_future.set_result(line)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
|
||||
# on_boot runs the script, which defers via delay then busy-blocks > 50 ms in the
|
||||
# continuation, tripping the blocking warning.
|
||||
warning_line = await asyncio.wait_for(warning_future, timeout=10.0)
|
||||
|
||||
# Must name the owning script, not "<null>" and not the generic fallback.
|
||||
assert "<null>" not in warning_line, (
|
||||
f"Warning should name the script, got: {warning_line}"
|
||||
)
|
||||
assert "a scheduled task" not in warning_line, (
|
||||
f"Warning should name the script, got: {warning_line}"
|
||||
)
|
||||
match = WARN_PATTERN.search(warning_line)
|
||||
assert match is not None
|
||||
assert match.group(1) == "blocking_script", (
|
||||
f"Warning should name 'blocking_script', got: {warning_line}"
|
||||
)
|
||||
# The reported threshold must be the real default (50 ms), not the stale "30 ms".
|
||||
assert match.group(3) == "50", f"Expected 'max is 50 ms', got: {warning_line}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_blocking_warning_generic_source(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A delay in a plain (non-script) automation logs the generic label, not a script name."""
|
||||
loop = asyncio.get_running_loop()
|
||||
warning_future: asyncio.Future[str] = loop.create_future()
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
if WARN_PATTERN.search(line) and not warning_future.done():
|
||||
warning_future.set_result(line)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
assert await client.device_info() is not None
|
||||
warning_line = await asyncio.wait_for(warning_future, timeout=10.0)
|
||||
|
||||
assert "a scheduled task took a long time" in warning_line, (
|
||||
f"Non-script deferred work should log the generic label, got: {warning_line}"
|
||||
)
|
||||
assert "<null>" not in warning_line
|
||||
match = WARN_PATTERN.search(warning_line)
|
||||
assert match is not None and match.group(3) == "50", (
|
||||
f"Expected 'max is 50 ms', got: {warning_line}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scheduler_delay_runs_on_failed_component(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""A delay must still fire even when its context component is marked failed.
|
||||
|
||||
Deferred (SELF_POINTER) scheduler items have no owning component, so the scheduler's
|
||||
failed-component skip must not drop them.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
fired: asyncio.Future[bool] = loop.create_future()
|
||||
|
||||
def check_output(line: str) -> None:
|
||||
if "DELAY_FIRED_AFTER_FAIL" in line and not fired.done():
|
||||
fired.set_result(True)
|
||||
|
||||
async with (
|
||||
run_compiled(yaml_config, line_callback=check_output),
|
||||
api_client_connected() as client,
|
||||
):
|
||||
assert await client.device_info() is not None
|
||||
# If the failed host component wrongly dropped the delay, this times out.
|
||||
await asyncio.wait_for(fired, timeout=10.0)
|
||||
76
tests/script/test_build_helpers.py
Normal file
76
tests/script/test_build_helpers.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""Unit tests for script/build_helpers.py."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Add the script directory to the path so we can import build_helpers.
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "script"))
|
||||
|
||||
import build_helpers # noqa: E402
|
||||
|
||||
from esphome.core import CORE # noqa: E402
|
||||
|
||||
|
||||
class _FakeComponent:
|
||||
def __init__(self, config_schema, *, is_target_platform=False):
|
||||
self.multi_conf = False
|
||||
self.is_platform_component = False
|
||||
self.is_target_platform = is_target_platform
|
||||
self.config_schema = config_schema
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_core_toolchain():
|
||||
"""Keep CORE.toolchain changes from leaking between tests."""
|
||||
saved = CORE.toolchain
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
CORE.toolchain = saved
|
||||
|
||||
|
||||
def test_populate_dependency_config_skips_target_platforms() -> None:
|
||||
"""Target-platform deps must be skipped, not config-populated, in a host build.
|
||||
|
||||
Regression test for #17035: esp32 (a target platform) appears only as a
|
||||
transitive dependency of a host C++ unit test. Running its schema with {}
|
||||
set ``CORE.toolchain = ESP_IDF`` as a side effect before failing validation,
|
||||
which crashed the host compile with KeyError('esp32'). The fix skips
|
||||
target-platform components entirely so their schema never runs.
|
||||
"""
|
||||
CORE.toolchain = None # the state a host build starts from
|
||||
schema_calls = []
|
||||
|
||||
def leaky_schema(value):
|
||||
# If this ever runs for a target platform, the bug is back.
|
||||
schema_calls.append(value)
|
||||
CORE.toolchain = "esp-idf-leak"
|
||||
raise ValueError("no board or variant")
|
||||
|
||||
config: dict = {}
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["esp32"],
|
||||
get_component_fn=lambda name: _FakeComponent(
|
||||
leaky_schema, is_target_platform=True
|
||||
),
|
||||
register_platform_fn=lambda domain: None,
|
||||
)
|
||||
|
||||
assert "esp32" not in config # skipped: no synthesized entry
|
||||
assert schema_calls == [] # schema never run
|
||||
assert CORE.toolchain is None # no global side effect leaked
|
||||
|
||||
|
||||
def test_populate_dependency_config_populates_defaults() -> None:
|
||||
"""A non-target-platform dep still has its schema defaults harvested."""
|
||||
config: dict = {}
|
||||
build_helpers.populate_dependency_config(
|
||||
config,
|
||||
["ok"],
|
||||
get_component_fn=lambda name: _FakeComponent(lambda value: {"default": 1}),
|
||||
register_platform_fn=lambda domain: None,
|
||||
)
|
||||
assert config["ok"] == {"default": 1}
|
||||
@@ -4,7 +4,12 @@ from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from esphome import config_validation as cv
|
||||
|
||||
@@ -134,6 +139,28 @@ def test_convert_walks_callable_schema_extractor() -> None:
|
||||
assert "foo" in config_var["schema"]["config_vars"]
|
||||
|
||||
|
||||
def test_convert_emits_variant_enum() -> None:
|
||||
"""A per-variant enum is dumped with each value tagged by its variants."""
|
||||
from esphome.components.esp32 import (
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32S3,
|
||||
variant_filtered_enum,
|
||||
)
|
||||
|
||||
validator = variant_filtered_enum(
|
||||
{VARIANT_ESP32: ("quad",), VARIANT_ESP32S3: ("quad", "octal")},
|
||||
lower=True,
|
||||
)
|
||||
config_var: dict = {}
|
||||
_bls.convert(validator, config_var, "/test")
|
||||
|
||||
assert config_var["type"] == "enum"
|
||||
assert config_var["values"] == {
|
||||
"quad": {"variants": [VARIANT_ESP32, VARIANT_ESP32S3]},
|
||||
"octal": {"variants": [VARIANT_ESP32S3]},
|
||||
}
|
||||
|
||||
|
||||
def test_convert_keys_emits_heuristic_sensitive_marker() -> None:
|
||||
converted: dict = {}
|
||||
_bls.convert_keys(converted, {cv.Optional("password"): cv.string}, "/root")
|
||||
@@ -176,3 +203,105 @@ def test_convert_keys_no_marker_for_non_sensitive_field() -> None:
|
||||
entry = converted["schema"]["config_vars"]["hostname"]
|
||||
assert "sensitive" not in entry
|
||||
assert "sensitive_source" not in entry
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regression tests for the lvgl schema dump.
|
||||
#
|
||||
# lvgl's CONFIG_SCHEMA is a callable closure and its widget/style schemas are
|
||||
# built lazily at validation time, so the static dumper used to emit an empty
|
||||
# `lvgl:` schema, no widget completion, and an inlined ~80-property STYLE_SCHEMA
|
||||
# duplicated at every widget x part x state (a 17 MB lvgl.json). These exercise
|
||||
# the full `build_schema()` and assert the generated lvgl.json carries the data
|
||||
# the schema_extractor hooks added.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def lvgl_schema(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
||||
"""Run the full language-schema build once and return parsed lvgl.json.
|
||||
|
||||
The build must run in a fresh interpreter: ``build_language_schema.py``
|
||||
enables schema extraction *before* importing any esphome component, and the
|
||||
extraction hooks are no-ops if the components were already imported (as they
|
||||
are inside the pytest session). Running it as a subprocess mirrors how CI
|
||||
generates the schema and keeps this test isolated from import order.
|
||||
"""
|
||||
out_dir = tmp_path_factory.mktemp("language_schema")
|
||||
subprocess.run(
|
||||
[sys.executable, str(SCRIPT_PATH), "--output-path", str(out_dir)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return json.loads((out_dir / "lvgl.json").read_text())
|
||||
|
||||
|
||||
def _lvgl_config_vars(lvgl_schema: dict) -> dict:
|
||||
config_schema = lvgl_schema["lvgl"]["schemas"]["CONFIG_SCHEMA"]
|
||||
# Previously empty (`{}`); the schema_extractor on lvgl_config_schema now
|
||||
# hands the dumper the composed top-level schema.
|
||||
assert config_schema["type"] == "schema"
|
||||
return config_schema["schema"]["config_vars"]
|
||||
|
||||
|
||||
def test_lvgl_top_level_schema_is_exposed(lvgl_schema: dict) -> None:
|
||||
config_vars = _lvgl_config_vars(lvgl_schema)
|
||||
# Was 0 config_vars before LVGL_TOP_LEVEL_SCHEMA was exposed.
|
||||
assert len(config_vars) > 100
|
||||
# A representative spread of top-level options the runtime validates.
|
||||
for key in ("displays", "pages", "default_font", "on_idle", "touchscreens"):
|
||||
assert key in config_vars, f"missing top-level lvgl option: {key}"
|
||||
|
||||
|
||||
def test_lvgl_widgets_key_enumerated(lvgl_schema: dict) -> None:
|
||||
config_vars = _lvgl_config_vars(lvgl_schema)
|
||||
# The widgets: list is assembled per-value at runtime; the extractor
|
||||
# enumerates every registered widget type into a named WIDGET_TYPES schema
|
||||
# which the widgets: list references (recursive, so widgets can nest).
|
||||
assert "widgets" in config_vars
|
||||
widgets = config_vars["widgets"]
|
||||
assert widgets["is_list"] is True
|
||||
assert widgets["schema"]["extends"] == ["lvgl.WIDGET_TYPES"]
|
||||
|
||||
widget_types = lvgl_schema["lvgl"]["schemas"]["WIDGET_TYPES"]["schema"][
|
||||
"config_vars"
|
||||
]
|
||||
# Every registered widget type should appear as an optional key.
|
||||
for name in ("obj", "label", "button", "slider", "switch", "arc"):
|
||||
assert name in widget_types, f"widget type not enumerated: {name}"
|
||||
# Each enumerated widget carries its own property schema, not an empty stub.
|
||||
assert widget_types["label"]["type"] == "schema"
|
||||
assert len(widget_types["label"]["schema"]["config_vars"]) > 0
|
||||
# Each widget can contain child widgets, via the same named ref — so the
|
||||
# tree is recursive and the dump stays finite.
|
||||
nested = widget_types["obj"]["schema"]["config_vars"]["widgets"]
|
||||
assert nested["is_list"] is True
|
||||
assert nested["schema"]["extends"] == ["lvgl.WIDGET_TYPES"]
|
||||
|
||||
|
||||
def test_lvgl_style_schemas_are_named_and_deduped(lvgl_schema: dict) -> None:
|
||||
schemas = lvgl_schema["lvgl"]["schemas"]
|
||||
# Importing these into the lvgl __init__ namespace lets the dumper register
|
||||
# them as named schemas and emit `extends` refs instead of inlining them.
|
||||
for name in ("STYLE_SCHEMA", "STATE_SCHEMA", "SET_STATE_SCHEMA"):
|
||||
assert name in schemas, f"style schema not registered as named: {name}"
|
||||
|
||||
# STYLE_SCHEMA must be referenced via `extends`, not inlined at every use
|
||||
# site. Count the references to prove the dedup actually happened.
|
||||
refs = 0
|
||||
|
||||
def _count(node: object) -> None:
|
||||
nonlocal refs
|
||||
if isinstance(node, dict):
|
||||
extends = node.get("extends")
|
||||
if isinstance(extends, list) and "lvgl.STYLE_SCHEMA" in extends:
|
||||
refs += 1
|
||||
for value in node.values():
|
||||
_count(value)
|
||||
elif isinstance(node, list):
|
||||
for value in node:
|
||||
_count(value)
|
||||
|
||||
_count(lvgl_schema)
|
||||
assert refs > 100, f"STYLE_SCHEMA should be referenced via extends, got {refs}"
|
||||
|
||||
169
tests/script/test_docker_build.py
Normal file
169
tests/script/test_docker_build.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Unit tests for docker/build.py command generation."""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
_BUILD_PY = Path(__file__).parents[2] / "docker" / "build.py"
|
||||
_spec = importlib.util.spec_from_file_location("docker_build", _BUILD_PY)
|
||||
docker_build = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(docker_build)
|
||||
|
||||
|
||||
def _run(capsys: pytest.CaptureFixture[str], *argv: str) -> list[str]:
|
||||
"""Run build.py main() in dry-run mode and return the emitted commands."""
|
||||
full_argv = ["build.py", "--dry-run", *argv]
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setattr(sys, "argv", full_argv)
|
||||
docker_build.main()
|
||||
out = capsys.readouterr().out
|
||||
return [line[2:] for line in out.splitlines() if line.startswith("$ ")]
|
||||
|
||||
|
||||
def test_branch_build_pushes_single_ghcr_tag_without_cache_to(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
commands = _run(
|
||||
capsys,
|
||||
"--tag",
|
||||
"my-branch",
|
||||
"--arch",
|
||||
"amd64",
|
||||
"--build-type",
|
||||
"docker",
|
||||
"--registry",
|
||||
"ghcr",
|
||||
"build",
|
||||
"--push",
|
||||
"--no-cache-to",
|
||||
)
|
||||
|
||||
assert len(commands) == 1
|
||||
cmd = commands[0]
|
||||
# Custom tag -> only the tag itself, no companion "dev"/"latest" tags
|
||||
assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd
|
||||
assert ":dev" not in cmd
|
||||
# ghcr only -> no Docker Hub image name
|
||||
assert "--tag esphome/esphome-amd64:my-branch" not in cmd
|
||||
# custom tag falls back to the dev cache for reads
|
||||
assert (
|
||||
"--cache-from type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-dev" in cmd
|
||||
)
|
||||
assert "--push" in cmd
|
||||
# --no-cache-to must suppress the cache write
|
||||
assert "--cache-to" not in cmd
|
||||
|
||||
|
||||
def test_branch_manifest_targets_ghcr_only(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
commands = _run(
|
||||
capsys,
|
||||
"--tag",
|
||||
"my-branch",
|
||||
"--build-type",
|
||||
"ha-addon",
|
||||
"--registry",
|
||||
"ghcr",
|
||||
"manifest",
|
||||
)
|
||||
|
||||
assert commands == [
|
||||
"docker buildx imagetools create "
|
||||
"--tag ghcr.io/esphome/esphome-hassio:my-branch "
|
||||
"ghcr.io/esphome/esphome-hassio-amd64:my-branch "
|
||||
"ghcr.io/esphome/esphome-hassio-aarch64:my-branch"
|
||||
]
|
||||
|
||||
|
||||
def test_release_build_keeps_both_registries_and_cache_to(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
commands = _run(
|
||||
capsys,
|
||||
"--tag",
|
||||
"2025.6.0",
|
||||
"--arch",
|
||||
"amd64",
|
||||
"--build-type",
|
||||
"docker",
|
||||
"build",
|
||||
"--push",
|
||||
)
|
||||
|
||||
cmd = commands[0]
|
||||
# Default (no --registry) keeps both Docker Hub and ghcr image names
|
||||
assert "--tag esphome/esphome-amd64:2025.6.0" in cmd
|
||||
assert "--tag ghcr.io/esphome/esphome-amd64:2025.6.0" in cmd
|
||||
# Release channel still gets its companion tags
|
||||
assert "--tag esphome/esphome-amd64:latest" in cmd
|
||||
# Without --no-cache-to the cache write is preserved
|
||||
assert (
|
||||
"--cache-to type=registry,ref=ghcr.io/esphome/esphome-amd64:cache-latest,mode=max"
|
||||
in cmd
|
||||
)
|
||||
|
||||
|
||||
def test_build_no_push_omits_push_and_cache(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
commands = _run(
|
||||
capsys,
|
||||
"--tag",
|
||||
"my-branch",
|
||||
"--arch",
|
||||
"amd64",
|
||||
"--build-type",
|
||||
"docker",
|
||||
"--registry",
|
||||
"ghcr",
|
||||
"build",
|
||||
)
|
||||
|
||||
cmd = commands[0]
|
||||
assert "--tag ghcr.io/esphome/esphome-amd64:my-branch" in cmd
|
||||
assert "--push" not in cmd
|
||||
assert "--cache-to" not in cmd
|
||||
|
||||
|
||||
def test_build_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
commands = _run(
|
||||
capsys,
|
||||
"--tag",
|
||||
"my-branch",
|
||||
"--arch",
|
||||
"amd64",
|
||||
"--build-type",
|
||||
"docker",
|
||||
"--registry",
|
||||
"dockerhub",
|
||||
"build",
|
||||
"--push",
|
||||
)
|
||||
|
||||
cmd = commands[0]
|
||||
assert "--tag esphome/esphome-amd64:my-branch" in cmd
|
||||
assert "ghcr.io" not in cmd
|
||||
# Cache reference falls back to Docker Hub when GHCR isn't selected
|
||||
assert "--cache-from type=registry,ref=esphome/esphome-amd64:cache-dev" in cmd
|
||||
|
||||
|
||||
def test_manifest_dockerhub_only(capsys: pytest.CaptureFixture[str]) -> None:
|
||||
commands = _run(
|
||||
capsys,
|
||||
"--tag",
|
||||
"my-branch",
|
||||
"--build-type",
|
||||
"docker",
|
||||
"--registry",
|
||||
"dockerhub",
|
||||
"manifest",
|
||||
)
|
||||
|
||||
create = commands[0]
|
||||
assert create.startswith(
|
||||
"docker buildx imagetools create --tag esphome/esphome:my-branch "
|
||||
)
|
||||
assert "ghcr.io" not in create
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user