mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 21:36:05 +00:00
Compare commits
40 Commits
sendspin-a
...
release
| 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 |
@@ -1 +1 @@
|
||||
a6ec18b82143e293ca6dee6947217f10a387ace99881a34b2c308ff627c8173c
|
||||
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
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -456,7 +456,7 @@ jobs:
|
||||
echo "binary=$BINARY" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run CodSpeed benchmarks
|
||||
uses: CodSpeedHQ/action@c145068895e045cc725ee76fcd2307624b65c3af # v4.17.5
|
||||
uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0
|
||||
with:
|
||||
run: |
|
||||
. venv/bin/activate
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -59,19 +59,6 @@ This document provides essential context for AI models interacting with this pro
|
||||
- Protected/private fields: `lower_snake_case_with_trailing_underscore_`
|
||||
- Favor descriptive names over abbreviations
|
||||
|
||||
* **Python Idioms:**
|
||||
* **Assignment expressions (PEP 572):** Prefer the walrus operator (`:=`) wherever it removes a redundant lookup or a throwaway temporary. The most common case in component code is presence-checking a config key and then indexing it separately — fetch once with `.get()` and bind in the condition instead:
|
||||
```python
|
||||
# Bad - looks up CONF_BLAH twice
|
||||
if CONF_BLAH in config:
|
||||
cg.add(var.set_blah(config[CONF_BLAH]))
|
||||
|
||||
# Good - single lookup, value bound inline
|
||||
if (blah := config.get(CONF_BLAH)) is not None:
|
||||
cg.add(var.set_blah(blah))
|
||||
```
|
||||
The same applies to `while` loops and comprehensions where it avoids recomputing a value. Don't contort code to use it — reach for `:=` only when it genuinely cuts repetition or an extra assignment line.
|
||||
|
||||
* **C++ Field Visibility:**
|
||||
* **Prefer `protected`:** Use `protected` for most class fields to enable extensibility and testing. Fields should be `lower_snake_case_with_trailing_underscore_`.
|
||||
* **Use `private` for safety-critical cases:** Use `private` visibility when direct field access could introduce bugs or violate invariants:
|
||||
|
||||
@@ -192,7 +192,6 @@ esphome/components/ft5x06/* @clydebarrow
|
||||
esphome/components/ft63x6/* @gpambrozio
|
||||
esphome/components/gcja5/* @gcormier
|
||||
esphome/components/gdk101/* @Szewcson
|
||||
esphome/components/generic_image/* @kahrendt
|
||||
esphome/components/gl_r01_i2c/* @pkejval
|
||||
esphome/components/globals/* @esphome/core
|
||||
esphome/components/gp2y1010au0f/* @zry98
|
||||
@@ -448,7 +447,6 @@ esphome/components/sen21231/* @shreyaskarnik
|
||||
esphome/components/sen5x/* @martgras
|
||||
esphome/components/sen6x/* @martgras @mebner86 @mikelawrence @tuct
|
||||
esphome/components/sendspin/* @kahrendt
|
||||
esphome/components/sendspin/generic_image/* @kahrendt
|
||||
esphome/components/sendspin/media_player/* @kahrendt
|
||||
esphome/components/sendspin/media_source/* @kahrendt
|
||||
esphome/components/sendspin/sensor/* @kahrendt
|
||||
|
||||
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.7.0-dev
|
||||
PROJECT_NUMBER = 2026.6.2
|
||||
|
||||
# Using the PROJECT_BRIEF tag one can provide an optional one line description
|
||||
# for a project that appears at the top of each page and should give viewer a
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -13,7 +13,6 @@ from esphome.components.mipi import (
|
||||
import esphome.config_validation as cv
|
||||
from esphome.config_validation import update_interval
|
||||
from esphome.const import (
|
||||
CONF_AUTO_CLEAR_ENABLED,
|
||||
CONF_BUSY_PIN,
|
||||
CONF_CS_PIN,
|
||||
CONF_DATA_RATE,
|
||||
@@ -130,23 +129,7 @@ def customise_schema(config):
|
||||
},
|
||||
extra=cv.ALLOW_EXTRA,
|
||||
)(config)
|
||||
model = MODELS[config[CONF_MODEL]]
|
||||
config = model_schema(config)(config)
|
||||
width, height = model.get_dimensions(config)
|
||||
display.add_metadata(
|
||||
config[CONF_ID],
|
||||
width,
|
||||
height,
|
||||
has_hardware_rotation=True,
|
||||
byte_order=cv.UNDEFINED,
|
||||
has_writer=config.get(CONF_AUTO_CLEAR_ENABLED) is True
|
||||
or config.get(CONF_PAGES) is not None
|
||||
or config.get(CONF_LAMBDA) is not None
|
||||
or config.get(CONF_SHOW_TEST_CARD) is True,
|
||||
rotation=config.get(CONF_ROTATION, 0),
|
||||
draw_rounding=0,
|
||||
)
|
||||
return config
|
||||
return model_schema(config)(config)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = customise_schema
|
||||
@@ -214,9 +197,6 @@ async def to_code(config):
|
||||
if busy_pin := config.get(CONF_BUSY_PIN):
|
||||
busy = await cg.gpio_pin_expression(busy_pin)
|
||||
cg.add(var.set_busy_pin(busy))
|
||||
if enable_pin := config.get(CONF_ENABLE_PIN):
|
||||
enable = [await cg.gpio_pin_expression(pin) for pin in enable_pin]
|
||||
cg.add(var.set_enable_pins(enable))
|
||||
cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY]))
|
||||
if CONF_RESET_DURATION in config:
|
||||
cg.add(var.set_reset_duration(config[CONF_RESET_DURATION]))
|
||||
|
||||
@@ -38,10 +38,6 @@ bool EPaperBase::init_buffer_(size_t buffer_length) {
|
||||
}
|
||||
|
||||
void EPaperBase::setup_pins_() const {
|
||||
for (auto *pin : this->enable_pins_) {
|
||||
pin->setup();
|
||||
pin->digital_write(true);
|
||||
}
|
||||
this->dc_pin_->setup(); // OUTPUT
|
||||
this->dc_pin_->digital_write(false);
|
||||
|
||||
|
||||
@@ -50,7 +50,6 @@ class EPaperBase : public Display,
|
||||
float get_setup_priority() const override;
|
||||
void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; }
|
||||
void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; }
|
||||
void set_enable_pins(std::vector<GPIOPin *> enable_pins) { this->enable_pins_ = std::move(enable_pins); }
|
||||
void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; }
|
||||
void set_transform(uint8_t transform) {
|
||||
this->transform_ = transform;
|
||||
@@ -178,7 +177,6 @@ class EPaperBase : public Display,
|
||||
GPIOPin *dc_pin_{};
|
||||
GPIOPin *busy_pin_{};
|
||||
GPIOPin *reset_pin_{};
|
||||
std::vector<GPIOPin *> enable_pins_{};
|
||||
bool waiting_for_idle_{};
|
||||
uint32_t delay_until_{}; // timestamp until which to delay processing
|
||||
uint16_t next_delay_{}; // milliseconds to delay before next state
|
||||
|
||||
@@ -10,11 +10,11 @@ class SSD1677(EpaperModel):
|
||||
|
||||
# fmt: off
|
||||
def get_init_sequence(self, config: dict):
|
||||
_width, height = self.get_dimensions(config)
|
||||
width, _height = self.get_dimensions(config)
|
||||
return (
|
||||
(0x18, 0x80), # Select internal Temp sensor
|
||||
(0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2
|
||||
(0x01, (height - 1) % 256, (height - 1) // 256, 0x02), # Set gate limit (number of rows-1)
|
||||
(0x01, (width - 1) % 256, (width - 1) // 256, 0x02), # Set column gate limit
|
||||
(0x3C, 0x01), # Set border waveform
|
||||
(0x11, 3), # Set transform
|
||||
)
|
||||
@@ -51,16 +51,3 @@ ssd1677.extend(
|
||||
height=480,
|
||||
mirror_x=True,
|
||||
)
|
||||
|
||||
ssd1677.extend(
|
||||
"seeed-reterminal-sticky",
|
||||
width=800,
|
||||
height=480,
|
||||
mirror_x=True,
|
||||
enable_pin=47,
|
||||
cs_pin=15,
|
||||
dc_pin=16,
|
||||
reset_pin=17,
|
||||
busy_pin=18,
|
||||
data_rate="10MHz",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -50,6 +50,11 @@ async def new_fastled_light(config):
|
||||
ref="d44c800a9e876a8394caefc2ce4915dd96dac77b",
|
||||
)
|
||||
cg.add_library("SPI", None)
|
||||
# FastLED's RMT5 driver hard-codes intr_priority=3, which conflicts with
|
||||
# esphome's RMT channels (remote_transmitter etc., priority 0): the IDF
|
||||
# driver rejects FastLED's channel and show() then hangs ~3s with no
|
||||
# output. Override to 0 so it shares the interrupt. See #17063.
|
||||
cg.add_build_flag("-DFL_RMT5_INTERRUPT_LEVEL=0")
|
||||
else:
|
||||
cg.add_library("fastled/FastLED", "3.9.16")
|
||||
await light.register_light(var, config)
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
IS_PLATFORM_COMPONENT = True
|
||||
@@ -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)) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#include "online_image.h"
|
||||
#include "esphome/components/runtime_image/image_decoder.h"
|
||||
#include "esphome/core/log.h"
|
||||
#include <algorithm>
|
||||
|
||||
@@ -182,7 +181,7 @@ void OnlineImage::loop() {
|
||||
auto consumed = this->feed_data(this->download_buffer_.data(), this->download_buffer_.unread());
|
||||
|
||||
if (consumed < 0) {
|
||||
ESP_LOGE(TAG, "Error decoding image: %s", esphome::runtime_image::decode_error_to_string(consumed));
|
||||
ESP_LOGE(TAG, "Error decoding image: %d", consumed);
|
||||
this->end_connection_();
|
||||
this->download_error_callback_.call();
|
||||
return;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,24 +7,8 @@ enum DecodeError : int {
|
||||
DECODE_ERROR_INVALID_TYPE = -1,
|
||||
DECODE_ERROR_UNSUPPORTED_FORMAT = -2,
|
||||
DECODE_ERROR_OUT_OF_MEMORY = -3,
|
||||
DECODE_ERROR_INTERNAL_DECODER_ERROR = -4,
|
||||
};
|
||||
|
||||
constexpr const char *decode_error_to_string(int error) {
|
||||
switch (error) {
|
||||
case DECODE_ERROR_INVALID_TYPE:
|
||||
return "Invalid type";
|
||||
case DECODE_ERROR_UNSUPPORTED_FORMAT:
|
||||
return "Unsupported format";
|
||||
case DECODE_ERROR_OUT_OF_MEMORY:
|
||||
return "Out of memory";
|
||||
case DECODE_ERROR_INTERNAL_DECODER_ERROR:
|
||||
return "Internal decoder error";
|
||||
default:
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
|
||||
class RuntimeImage;
|
||||
|
||||
/**
|
||||
|
||||
@@ -89,21 +89,9 @@ int HOT JpegDecoder::decode(uint8_t *buffer, size_t size) {
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
}
|
||||
if (!this->jpeg_.decode(0, 0, 0)) {
|
||||
auto error = this->jpeg_.getLastError();
|
||||
ESP_LOGE(TAG, "Error while decoding: %d", error);
|
||||
ESP_LOGE(TAG, "Error while decoding.");
|
||||
this->jpeg_.close();
|
||||
switch (error) {
|
||||
case JPEG_ERROR_MEMORY:
|
||||
return DECODE_ERROR_OUT_OF_MEMORY;
|
||||
case JPEG_UNSUPPORTED_FEATURE:
|
||||
return DECODE_ERROR_UNSUPPORTED_FORMAT;
|
||||
case JPEG_INVALID_FILE:
|
||||
case JPEG_INVALID_PARAMETER:
|
||||
return DECODE_ERROR_INVALID_TYPE;
|
||||
case JPEG_DECODE_ERROR:
|
||||
default:
|
||||
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
|
||||
}
|
||||
return DECODE_ERROR_UNSUPPORTED_FORMAT;
|
||||
}
|
||||
this->decoded_bytes_ = size;
|
||||
this->jpeg_.close();
|
||||
|
||||
@@ -95,7 +95,6 @@ int HOT PngDecoder::decode(uint8_t *buffer, size_t size) {
|
||||
auto fed = pngle_feed(this->pngle_, buffer, size);
|
||||
if (fed < 0) {
|
||||
ESP_LOGE(TAG, "Error decoding image: %s", pngle_error(this->pngle_));
|
||||
return DECODE_ERROR_INTERNAL_DECODER_ERROR;
|
||||
} else {
|
||||
this->decoded_bytes_ += fed;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
@@ -6,13 +6,9 @@ from esphome.components import esp32, network, psram, socket, wifi
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_BUFFER_SIZE,
|
||||
CONF_FORMAT,
|
||||
CONF_HEIGHT,
|
||||
CONF_ID,
|
||||
CONF_SAMPLE_RATE,
|
||||
CONF_SOURCE,
|
||||
CONF_TASK_STACK_IN_PSRAM,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.core import CORE, ID
|
||||
from esphome.cpp_generator import TemplateArgsType
|
||||
@@ -25,7 +21,6 @@ DEPENDENCIES = ["network"]
|
||||
DOMAIN = "sendspin"
|
||||
|
||||
CONF_SENDSPIN_ID = "sendspin_id"
|
||||
CONF_SLOT = "slot"
|
||||
|
||||
CONF_INITIAL_STATIC_DELAY = "initial_static_delay"
|
||||
CONF_FIXED_DELAY = "fixed_delay"
|
||||
@@ -41,21 +36,9 @@ CODEC_FORMAT_OPUS = SendspinCodecFormat.enum("OPUS")
|
||||
CODEC_FORMAT_PCM = SendspinCodecFormat.enum("PCM")
|
||||
CODEC_FORMAT_UNSUPPORTED = SendspinCodecFormat.enum("UNSUPPORTED")
|
||||
|
||||
SendspinImageFormat = sendspin_library_ns.enum("SendspinImageFormat", is_class=True)
|
||||
IMAGE_FORMAT_JPEG = SendspinImageFormat.enum("JPEG")
|
||||
IMAGE_FORMAT_PNG = SendspinImageFormat.enum("PNG")
|
||||
IMAGE_FORMAT_BMP = SendspinImageFormat.enum("BMP")
|
||||
|
||||
SendspinImageSource = sendspin_library_ns.enum("SendspinImageSource", is_class=True)
|
||||
IMAGE_SOURCE_ALBUM = SendspinImageSource.enum("ALBUM")
|
||||
IMAGE_SOURCE_ARTIST = SendspinImageSource.enum("ARTIST")
|
||||
IMAGE_SOURCE_NONE = SendspinImageSource.enum("NONE")
|
||||
|
||||
# Library Structs
|
||||
AudioSupportedFormatObject = sendspin_library_ns.struct("AudioSupportedFormatObject")
|
||||
PlayerRoleConfig = sendspin_library_ns.struct("PlayerRoleConfig")
|
||||
ArtworkRoleConfig = sendspin_library_ns.struct("ArtworkRoleConfig")
|
||||
ImageSlotPreference = sendspin_library_ns.struct("ImageSlotPreference")
|
||||
|
||||
# MemoryLocation enum (from sendspin/types.h) controls SPIRAM-vs-internal-RAM placement
|
||||
# preference for the player role's transfer buffers.
|
||||
@@ -93,7 +76,6 @@ class SendspinConfiguration:
|
||||
player_support: bool = False
|
||||
visualizer_support: bool = False
|
||||
|
||||
artwork_preferences: list[ConfigType] = field(default_factory=list)
|
||||
player_config: ConfigType | None = None
|
||||
|
||||
|
||||
@@ -128,12 +110,6 @@ def request_visualizer_support() -> None:
|
||||
_get_data().visualizer_support = True
|
||||
|
||||
|
||||
def register_artwork_preference(config: ConfigType) -> None:
|
||||
"""Register an artwork slot preference from an image subcomponent."""
|
||||
request_artwork_support()
|
||||
_get_data().artwork_preferences.append(config)
|
||||
|
||||
|
||||
def register_player_config(config: ConfigType) -> None:
|
||||
"""Register the player role config from the media source subcomponent."""
|
||||
data = _get_data()
|
||||
@@ -234,27 +210,6 @@ async def to_code(config: ConfigType) -> None:
|
||||
# and disable building unused code paths in the sendspin-cpp library (IDF SDKConfig via CONFIG_SENDSPIN_ENABLE_*).
|
||||
if data.artwork_support:
|
||||
cg.add_define("USE_SENDSPIN_ARTWORK", True)
|
||||
|
||||
preference_structs = [
|
||||
cg.StructInitializer(
|
||||
ImageSlotPreference,
|
||||
("slot", pref[CONF_SLOT]),
|
||||
("source", pref[CONF_SOURCE]),
|
||||
("format", pref[CONF_FORMAT]),
|
||||
("width", pref[CONF_WIDTH]),
|
||||
("height", pref[CONF_HEIGHT]),
|
||||
)
|
||||
for pref in data.artwork_preferences
|
||||
]
|
||||
|
||||
artwork_psram_stack = bool(config.get(CONF_TASK_STACK_IN_PSRAM))
|
||||
artwork_config = cg.StructInitializer(
|
||||
ArtworkRoleConfig,
|
||||
("preferred_formats", preference_structs),
|
||||
("psram_stack", artwork_psram_stack),
|
||||
("priority", 2),
|
||||
)
|
||||
cg.add(var.set_artwork_config(artwork_config))
|
||||
else:
|
||||
esp32.add_idf_sdkconfig_option("CONFIG_SENDSPIN_ENABLE_ARTWORK", False)
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"""Sendspin generic_image platform."""
|
||||
|
||||
from esphome import automation
|
||||
import esphome.codegen as cg
|
||||
from esphome.components import runtime_image
|
||||
from esphome.components.image import CONF_TRANSPARENCY, add_metadata
|
||||
import esphome.config_validation as cv
|
||||
from esphome.const import (
|
||||
CONF_FORMAT,
|
||||
CONF_HEIGHT,
|
||||
CONF_ID,
|
||||
CONF_RESIZE,
|
||||
CONF_SOURCE,
|
||||
CONF_TRIGGER_ID,
|
||||
CONF_TYPE,
|
||||
CONF_WIDTH,
|
||||
)
|
||||
from esphome.core import CORE
|
||||
from esphome.types import ConfigType
|
||||
|
||||
from .. import (
|
||||
CONF_SENDSPIN_ID,
|
||||
CONF_SLOT,
|
||||
IMAGE_FORMAT_BMP,
|
||||
IMAGE_FORMAT_JPEG,
|
||||
IMAGE_FORMAT_PNG,
|
||||
IMAGE_SOURCE_ALBUM,
|
||||
IMAGE_SOURCE_ARTIST,
|
||||
IMAGE_SOURCE_NONE,
|
||||
SendspinHub,
|
||||
register_artwork_preference,
|
||||
sendspin_ns,
|
||||
)
|
||||
|
||||
AUTO_LOAD = ["runtime_image"]
|
||||
CODEOWNERS = ["@kahrendt"]
|
||||
DEPENDENCIES = ["sendspin"]
|
||||
|
||||
MAX_IMAGE_SLOTS = 4
|
||||
_SLOT_COUNTER_KEY = "sendspin_image_slot_counter"
|
||||
|
||||
CONF_ON_IMAGE_DISPLAY = "on_image_display"
|
||||
CONF_ON_IMAGE_ERROR = "on_image_error"
|
||||
|
||||
# Map runtime_image's format string to the sendspin library's SendspinImageFormat enum.
|
||||
_FORMAT_TO_SENDSPIN_ENUM = {
|
||||
"JPEG": IMAGE_FORMAT_JPEG,
|
||||
"PNG": IMAGE_FORMAT_PNG,
|
||||
"BMP": IMAGE_FORMAT_BMP,
|
||||
}
|
||||
|
||||
IMAGE_SOURCES = {
|
||||
"ALBUM": IMAGE_SOURCE_ALBUM,
|
||||
"ARTIST": IMAGE_SOURCE_ARTIST,
|
||||
"NONE": IMAGE_SOURCE_NONE,
|
||||
}
|
||||
|
||||
SendspinImage = sendspin_ns.class_(
|
||||
"SendspinImage",
|
||||
runtime_image.RuntimeImage,
|
||||
cg.Component,
|
||||
)
|
||||
|
||||
SendspinImageDisplayTrigger = sendspin_ns.class_(
|
||||
"SendspinImageDisplayTrigger", automation.Trigger.template()
|
||||
)
|
||||
SendspinImageErrorTrigger = sendspin_ns.class_(
|
||||
"SendspinImageErrorTrigger", automation.Trigger.template()
|
||||
)
|
||||
|
||||
|
||||
def _assign_slot_and_register(config: ConfigType) -> ConfigType:
|
||||
"""Auto-assign a slot, validate the max count, and register the artwork preference with the hub."""
|
||||
current = CORE.data.get(_SLOT_COUNTER_KEY, 0)
|
||||
if current >= MAX_IMAGE_SLOTS:
|
||||
raise cv.Invalid(
|
||||
f"Too many Sendspin generic_image components. Maximum is {MAX_IMAGE_SLOTS}."
|
||||
)
|
||||
CORE.data[_SLOT_COUNTER_KEY] = current + 1
|
||||
config[CONF_SLOT] = current
|
||||
|
||||
width, height = config[CONF_RESIZE]
|
||||
register_artwork_preference(
|
||||
{
|
||||
CONF_SLOT: current,
|
||||
CONF_SOURCE: config[CONF_SOURCE],
|
||||
CONF_FORMAT: _FORMAT_TO_SENDSPIN_ENUM[config[CONF_FORMAT]],
|
||||
CONF_WIDTH: width,
|
||||
CONF_HEIGHT: height,
|
||||
}
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
CONFIG_SCHEMA = cv.All(
|
||||
runtime_image.runtime_image_schema(SendspinImage).extend(
|
||||
{
|
||||
cv.GenerateID(): cv.declare_id(SendspinImage),
|
||||
cv.GenerateID(CONF_SENDSPIN_ID): cv.use_id(SendspinHub),
|
||||
cv.Required(CONF_RESIZE): cv.dimensions,
|
||||
cv.Optional(CONF_SOURCE, default="ALBUM"): cv.enum(
|
||||
IMAGE_SOURCES, upper=True
|
||||
),
|
||||
cv.Optional(CONF_ON_IMAGE_DISPLAY): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
SendspinImageDisplayTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
cv.Optional(CONF_ON_IMAGE_ERROR): automation.validate_automation(
|
||||
{
|
||||
cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(
|
||||
SendspinImageErrorTrigger
|
||||
),
|
||||
}
|
||||
),
|
||||
}
|
||||
),
|
||||
runtime_image.validate_runtime_image_settings,
|
||||
cv.only_on_esp32,
|
||||
_assign_slot_and_register,
|
||||
)
|
||||
|
||||
|
||||
async def to_code(config: ConfigType) -> None:
|
||||
settings = await runtime_image.process_runtime_image_config(config)
|
||||
|
||||
add_metadata(
|
||||
config[CONF_ID],
|
||||
settings.width,
|
||||
settings.height,
|
||||
config[CONF_TYPE],
|
||||
config[CONF_TRANSPARENCY],
|
||||
)
|
||||
|
||||
var = cg.new_Pvariable(
|
||||
config[CONF_ID],
|
||||
settings.width,
|
||||
settings.height,
|
||||
settings.format_enum,
|
||||
settings.image_type_enum,
|
||||
settings.transparent,
|
||||
settings.byte_order_big_endian,
|
||||
settings.placeholder,
|
||||
)
|
||||
await cg.register_component(var, config)
|
||||
await cg.register_parented(var, config[CONF_SENDSPIN_ID])
|
||||
|
||||
cg.add(var.set_slot(config[CONF_SLOT]))
|
||||
cg.add(var.set_image_source(IMAGE_SOURCES[config[CONF_SOURCE]]))
|
||||
|
||||
for conf in config.get(CONF_ON_IMAGE_DISPLAY, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
|
||||
for conf in config.get(CONF_ON_IMAGE_ERROR, []):
|
||||
trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var)
|
||||
await automation.build_automation(trigger, [], conf)
|
||||
@@ -1,66 +0,0 @@
|
||||
#include "sendspin_generic_image.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
|
||||
|
||||
#include "esphome/core/log.h"
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
static const char *const TAG = "sendspin.generic_image";
|
||||
|
||||
SendspinImage::SendspinImage(int fixed_width, int fixed_height, runtime_image::ImageFormat format,
|
||||
image::ImageType type, image::Transparency transparency, bool is_big_endian,
|
||||
image::Image *placeholder)
|
||||
: runtime_image::RuntimeImage(format, type, transparency, placeholder, is_big_endian, fixed_width, fixed_height) {}
|
||||
|
||||
// THREAD CONTEXT: Main loop. The decode callback registered below fires on the artwork
|
||||
// decode thread; the display and clear callbacks fire on the main loop.
|
||||
void SendspinImage::setup() {
|
||||
this->parent_->add_image_decode_callback(
|
||||
[this](uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) {
|
||||
if (slot == this->slot_)
|
||||
this->on_decode_(data, length);
|
||||
});
|
||||
this->parent_->add_image_display_callback([this](uint8_t slot) {
|
||||
if (slot == this->slot_)
|
||||
this->on_display_();
|
||||
});
|
||||
this->parent_->add_image_clear_callback([this](uint8_t slot) {
|
||||
if (slot == this->slot_)
|
||||
this->on_clear_();
|
||||
});
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Dedicated artwork decode thread (via SendspinHub's decode callback).
|
||||
// Decode synchronously into the back buffer; heavy CPU work is allowed here.
|
||||
void SendspinImage::on_decode_(const uint8_t *data, size_t length) {
|
||||
this->begin_decode(length);
|
||||
size_t total_consumed = 0;
|
||||
while (total_consumed < length) {
|
||||
int consumed = this->feed_data(const_cast<uint8_t *>(data) + total_consumed, length - total_consumed);
|
||||
if (consumed < 0) {
|
||||
ESP_LOGE(TAG, "Error decoding image data at offset %zu", total_consumed);
|
||||
this->image_error_callback_.call();
|
||||
return;
|
||||
}
|
||||
total_consumed += consumed;
|
||||
}
|
||||
if (!this->end_decode()) {
|
||||
ESP_LOGE(TAG, "Failed to finalize image after decoding");
|
||||
this->image_error_callback_.call();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (fired once the server display timestamp is reached)
|
||||
void SendspinImage::on_display_() { this->image_display_callback_.call(); }
|
||||
|
||||
// THREAD CONTEXT: Main loop
|
||||
void SendspinImage::on_clear_() {
|
||||
this->release();
|
||||
this->image_display_callback_.call();
|
||||
}
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif
|
||||
@@ -1,63 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "esphome/core/defines.h"
|
||||
|
||||
#if defined(USE_ESP32) && defined(USE_SENDSPIN_ARTWORK)
|
||||
|
||||
#include "esphome/components/runtime_image/runtime_image.h"
|
||||
#include "esphome/components/sendspin/sendspin_hub.h"
|
||||
#include "esphome/core/automation.h"
|
||||
|
||||
#include <sendspin/artwork_role.h>
|
||||
|
||||
namespace esphome::sendspin_ {
|
||||
|
||||
class SendspinImage : public SendspinChild, public runtime_image::RuntimeImage {
|
||||
public:
|
||||
SendspinImage(int fixed_width, int fixed_height, runtime_image::ImageFormat format, image::ImageType type,
|
||||
image::Transparency transparency, bool is_big_endian = false, image::Image *placeholder = nullptr);
|
||||
|
||||
void setup() override;
|
||||
|
||||
template<typename F> void add_on_image_display_callback(F &&callback) {
|
||||
this->image_display_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_on_image_error_callback(F &&callback) {
|
||||
this->image_error_callback_.add(std::forward<F>(callback));
|
||||
}
|
||||
|
||||
void set_image_source(sendspin::SendspinImageSource source) { this->source_ = source; }
|
||||
void set_slot(uint8_t slot) { this->slot_ = slot; }
|
||||
|
||||
protected:
|
||||
// Artwork thread. Decodes encoded bytes synchronously; buffer is valid only for this call.
|
||||
void on_decode_(const uint8_t *data, size_t length);
|
||||
// Main loop thread. Trigger when art should be displayed.
|
||||
void on_display_();
|
||||
// Main loop thread. Releases the decoded image and refires the display trigger so listeners re-render.
|
||||
void on_clear_();
|
||||
|
||||
LazyCallbackManager<void()> image_display_callback_{};
|
||||
LazyCallbackManager<void()> image_error_callback_{};
|
||||
|
||||
sendspin::SendspinImageSource source_{sendspin::SendspinImageSource::ALBUM};
|
||||
uint8_t slot_{0};
|
||||
};
|
||||
|
||||
class SendspinImageDisplayTrigger : public Trigger<> {
|
||||
public:
|
||||
explicit SendspinImageDisplayTrigger(SendspinImage *parent) {
|
||||
parent->add_on_image_display_callback([this]() { this->trigger(); });
|
||||
}
|
||||
};
|
||||
|
||||
class SendspinImageErrorTrigger : public Trigger<> {
|
||||
public:
|
||||
explicit SendspinImageErrorTrigger(SendspinImage *parent) {
|
||||
parent->add_on_image_error_callback([this]() { this->trigger(); });
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace esphome::sendspin_
|
||||
|
||||
#endif
|
||||
@@ -37,10 +37,6 @@ void SendspinHub::setup() {
|
||||
this->client_->set_network_provider(this);
|
||||
this->client_->set_persistence_provider(this);
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
this->client_->add_artwork(this->artwork_config_).set_listener(this);
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
this->controller_role_ = &this->client_->add_controller();
|
||||
this->controller_role_->set_listener(this);
|
||||
@@ -176,20 +172,6 @@ std::optional<uint32_t> SendspinHub::load_last_server_hash() {
|
||||
|
||||
// --- Sendspin role specific methods/overrides ---
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
// THREAD CONTEXT: Dedicated artwork decode thread; downstream callbacks run here too
|
||||
void SendspinHub::on_image_decode(uint8_t slot, const uint8_t *data, size_t length,
|
||||
sendspin::SendspinImageFormat format) {
|
||||
this->artwork_image_decode_callbacks_.call(slot, data, length, format);
|
||||
}
|
||||
|
||||
// THREAD CONTEXT: Main loop (fired from client_->loop() once the server display timestamp is reached)
|
||||
void SendspinHub::on_image_display(uint8_t slot) { this->artwork_image_display_callbacks_.call(slot); }
|
||||
|
||||
// THREAD CONTEXT: Main loop (fired from client_->loop())
|
||||
void SendspinHub::on_image_clear(uint8_t slot) { this->artwork_image_clear_callbacks_.call(slot); }
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
// THREAD CONTEXT: Main loop (invoked from ESPHome actions / other components)
|
||||
void SendspinHub::send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume,
|
||||
|
||||
@@ -13,9 +13,6 @@
|
||||
#include <sendspin/config.h>
|
||||
#include <sendspin/types.h>
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
#include <sendspin/artwork_role.h>
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
#include <sendspin/controller_role.h>
|
||||
#endif
|
||||
@@ -72,9 +69,6 @@ struct StaticDelayPref {
|
||||
/// (for services the library pulls; e.g., persistence, network readiness).
|
||||
/// - User -> library communication uses exposed functions on the client and role objects that the user calls.
|
||||
class SendspinHub final : public Component,
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
public sendspin::ArtworkRoleListener,
|
||||
#endif
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
public sendspin::ControllerRoleListener,
|
||||
#endif
|
||||
@@ -127,20 +121,6 @@ class SendspinHub final : public Component,
|
||||
|
||||
// --- Sendspin role specific methods ---
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
void set_artwork_config(const sendspin::ArtworkRoleConfig &config) { this->artwork_config_ = config; }
|
||||
|
||||
template<typename F> void add_image_decode_callback(F &&callback) {
|
||||
this->artwork_image_decode_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_image_display_callback(F &&callback) {
|
||||
this->artwork_image_display_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
template<typename F> void add_image_clear_callback(F &&callback) {
|
||||
this->artwork_image_clear_callbacks_.add(std::forward<F>(callback));
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
void send_client_command(sendspin::SendspinControllerCommand command, std::optional<uint8_t> volume = std::nullopt,
|
||||
std::optional<bool> mute = std::nullopt);
|
||||
@@ -191,23 +171,6 @@ class SendspinHub final : public Component,
|
||||
|
||||
// --- Sendspin role specific methods/overrides/member variables ---
|
||||
|
||||
#ifdef USE_SENDSPIN_ARTWORK
|
||||
void on_image_decode(uint8_t slot, const uint8_t *data, size_t length, sendspin::SendspinImageFormat format) override;
|
||||
|
||||
void on_image_display(uint8_t slot) override;
|
||||
|
||||
void on_image_clear(uint8_t slot) override;
|
||||
|
||||
sendspin::ArtworkRoleConfig artwork_config_{};
|
||||
|
||||
// Callback fan-out to child components; they filter by slot as needed.
|
||||
// decode and display fire from the library's artwork thread; clear fires from the main loop.
|
||||
CallbackManager<void(uint8_t, const uint8_t *, size_t, sendspin::SendspinImageFormat)>
|
||||
artwork_image_decode_callbacks_{};
|
||||
CallbackManager<void(uint8_t)> artwork_image_display_callbacks_{};
|
||||
CallbackManager<void(uint8_t)> artwork_image_clear_callbacks_{};
|
||||
#endif
|
||||
|
||||
#ifdef USE_SENDSPIN_CONTROLLER
|
||||
sendspin::ControllerRole *controller_role_{nullptr};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -6,13 +6,6 @@
|
||||
|
||||
namespace esphome::xpt2046 {
|
||||
|
||||
static constexpr uint8_t XPT_READ_Z1 = 0xB0;
|
||||
static constexpr uint8_t XPT_READ_Z2 = 0xC0;
|
||||
static constexpr uint8_t XPT_READ_X = 0xD0;
|
||||
static constexpr uint8_t XPT_READ_Y = 0x90;
|
||||
static constexpr uint8_t XPT_ADC_ON = 0x01;
|
||||
static constexpr uint8_t XPT_VREF_ON = 0x02;
|
||||
|
||||
static const char *const TAG = "xpt2046";
|
||||
|
||||
void XPT2046Component::setup() {
|
||||
@@ -27,7 +20,7 @@ void XPT2046Component::setup() {
|
||||
this->attach_interrupt_(this->irq_pin_, gpio::INTERRUPT_FALLING_EDGE);
|
||||
}
|
||||
this->spi_setup();
|
||||
this->read_adc_(XPT_READ_X); // ADC powerdown, enable PENIRQ pin
|
||||
this->read_adc_(0xD0); // ADC powerdown, enable PENIRQ pin
|
||||
}
|
||||
|
||||
void XPT2046Component::update_touches() {
|
||||
@@ -36,22 +29,21 @@ void XPT2046Component::update_touches() {
|
||||
|
||||
enable();
|
||||
|
||||
int16_t touch_pressure_1 = this->read_adc_(XPT_READ_Z1 | XPT_ADC_ON);
|
||||
int16_t touch_pressure_2 = this->read_adc_(XPT_READ_Z2 | XPT_ADC_ON);
|
||||
int16_t touch_pressure_1 = this->read_adc_(0xB1 /* touch_pressure_1 */);
|
||||
int16_t touch_pressure_2 = this->read_adc_(0xC1 /* touch_pressure_2 */);
|
||||
z_raw = touch_pressure_1 + 0xfff - touch_pressure_2;
|
||||
ESP_LOGVV(TAG, "Touchscreen Update z = %d", z_raw);
|
||||
touch = (z_raw >= this->threshold_);
|
||||
if (touch) {
|
||||
read_adc_(XPT_READ_X | XPT_ADC_ON); // dummy X measure, 1st is always noisy
|
||||
// make 3 x-y measurements
|
||||
data[0] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON);
|
||||
data[1] = this->read_adc_(XPT_READ_X | XPT_ADC_ON);
|
||||
data[2] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON);
|
||||
data[3] = this->read_adc_(XPT_READ_X | XPT_ADC_ON);
|
||||
data[4] = this->read_adc_(XPT_READ_Y | XPT_ADC_ON);
|
||||
read_adc_(0xD1 /* X */); // dummy Y measure, 1st is always noisy
|
||||
data[0] = this->read_adc_(0x91 /* Y */);
|
||||
data[1] = this->read_adc_(0xD1 /* X */); // make 3 x-y measurements
|
||||
data[2] = this->read_adc_(0x91 /* Y */);
|
||||
data[3] = this->read_adc_(0xD1 /* X */);
|
||||
data[4] = this->read_adc_(0x91 /* Y */);
|
||||
}
|
||||
|
||||
data[5] = this->read_adc_(XPT_READ_X); // Last X touch power down
|
||||
data[5] = this->read_adc_(0xD0 /* X */); // Last X touch power down
|
||||
|
||||
disable();
|
||||
|
||||
@@ -103,16 +95,15 @@ int16_t XPT2046Component::best_two_avg(int16_t value1, int16_t value2, int16_t v
|
||||
return reta;
|
||||
}
|
||||
|
||||
int16_t XPT2046Component::read_adc_(uint8_t ctrl) {
|
||||
uint8_t data[3];
|
||||
int16_t XPT2046Component::read_adc_(uint8_t ctrl) { // NOLINT
|
||||
uint8_t data[2];
|
||||
|
||||
data[0] = ctrl;
|
||||
data[1] = 0;
|
||||
data[2] = 0;
|
||||
this->write_byte(ctrl);
|
||||
delay(1);
|
||||
data[0] = this->read_byte();
|
||||
data[1] = this->read_byte();
|
||||
|
||||
this->transfer_array(data, sizeof(data));
|
||||
|
||||
return ((data[1] << 8) | data[2]) >> 3;
|
||||
return ((data[0] << 8) | data[1]) >> 3;
|
||||
}
|
||||
|
||||
} // namespace esphome::xpt2046
|
||||
|
||||
@@ -4,7 +4,7 @@ from enum import Enum
|
||||
|
||||
from esphome.enum import StrEnum
|
||||
|
||||
__version__ = "2026.7.0-dev"
|
||||
__version__ = "2026.6.2"
|
||||
|
||||
ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
VALID_SUBSTITUTIONS_CHARACTERS = (
|
||||
|
||||
@@ -104,9 +104,13 @@ class Application {
|
||||
void register_area(Area *area) { this->areas_.push_back(area); }
|
||||
#endif
|
||||
|
||||
void set_current_component(Component *component) { this->current_component_ = component; }
|
||||
Component *get_current_component() { return this->current_component_; }
|
||||
|
||||
// Owning script of the action chain currently executing (nullptr when none); used to attribute
|
||||
// blocking warnings for deferred work to the script that scheduled it.
|
||||
void set_current_source(const LogString *source) { this->current_source_ = source; }
|
||||
const LogString *get_current_source() { return this->current_source_; }
|
||||
|
||||
// Entity register methods (generated from entity_types.h).
|
||||
// Each entity type gets two overloads:
|
||||
// - register_<entity>(obj) — bare push_back
|
||||
@@ -393,6 +397,7 @@ class Application {
|
||||
protected:
|
||||
friend Component;
|
||||
friend class Scheduler;
|
||||
friend class LoopBlockingGuard;
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
friend class runtime_stats::RuntimeStatsCollector;
|
||||
#endif
|
||||
@@ -402,6 +407,14 @@ class Application {
|
||||
/// Freshen the cached loop component start time. Called by Scheduler before each dispatch.
|
||||
void set_loop_component_start_time_(uint32_t now) { this->loop_component_start_time_ = now; }
|
||||
|
||||
// Publish the running unit's identity (component + source) and dispatch time together, so a
|
||||
// dispatch site can't set one without the others. Friend-only (Scheduler).
|
||||
void set_current_execution_context_(Component *component, const LogString *source, uint32_t now) {
|
||||
this->current_component_ = component;
|
||||
this->current_source_ = source;
|
||||
this->set_loop_component_start_time_(now);
|
||||
}
|
||||
|
||||
/// Walk all registered components looking for any whose component_state_
|
||||
/// has the given flag set. Used by Component::status_clear_*_slow_path_()
|
||||
/// (which is a friend) to decide whether to clear the corresponding bit on
|
||||
@@ -482,6 +495,7 @@ class Application {
|
||||
|
||||
// Pointer-sized members first
|
||||
Component *current_component_{nullptr};
|
||||
const LogString *current_source_{nullptr};
|
||||
|
||||
// std::vector (3 pointers each: begin, end, capacity)
|
||||
// Partitioned vector design for looping components
|
||||
@@ -554,6 +568,76 @@ class Application {
|
||||
/// Global storage of Application pointer - only one Application can exist.
|
||||
extern Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
|
||||
/// RAII guard that publishes a current source (e.g. a script name) for a scope and restores the
|
||||
/// previous value on exit, attributing deferred work scheduled inside to that source.
|
||||
class ScopedSourceGuard {
|
||||
public:
|
||||
explicit ScopedSourceGuard(const LogString *source) : prev_(App.get_current_source()) {
|
||||
App.set_current_source(source);
|
||||
}
|
||||
~ScopedSourceGuard() { App.set_current_source(this->prev_); }
|
||||
ScopedSourceGuard(const ScopedSourceGuard &) = delete;
|
||||
ScopedSourceGuard &operator=(const ScopedSourceGuard &) = delete;
|
||||
|
||||
private:
|
||||
const LogString *prev_;
|
||||
};
|
||||
|
||||
// Times one unit of work (a component loop() or a scheduled callback) and warns if it blocks the
|
||||
// main loop too long. The constructor publishes the unit's identity + dispatch time to App;
|
||||
// finish()/the cold warning path read them back, so the guard stores no copy.
|
||||
//
|
||||
// Guards must not nest: the constructor publishes to App but never restores on destruction, so a
|
||||
// nested guard would clobber the outer's context. Safe because the two dispatch sites (component
|
||||
// loop phase, execute_item_) run strictly sequentially and aren't re-entered from a timed callback.
|
||||
class LoopBlockingGuard {
|
||||
public:
|
||||
// Publish the unit's identity + dispatch time, then start timing. The millis start lives in App,
|
||||
// so only the runtime-stats micros stamp is kept here.
|
||||
LoopBlockingGuard(Component *component, const LogString *source, uint32_t now) {
|
||||
App.set_current_execution_context_(component, source, now);
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
this->started_us_ = micros();
|
||||
#endif
|
||||
}
|
||||
|
||||
// Finish the timing operation and return the current time (millis)
|
||||
// Inlined: the fast path is just millis() + subtract + compare
|
||||
inline uint32_t HOT finish() {
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t elapsed_us = micros() - this->started_us_;
|
||||
// Delays have no component; accumulate into the global counter so loop() can subtract them.
|
||||
Component *component = App.get_current_component();
|
||||
if (component != nullptr) {
|
||||
component->runtime_stats_.record_time(elapsed_us);
|
||||
} else {
|
||||
ComponentRuntimeStats::global_recorded_us += elapsed_us;
|
||||
}
|
||||
#endif
|
||||
uint32_t curr_time = MillisInternal::get();
|
||||
#ifndef USE_BENCHMARK
|
||||
// Fast path: compare against constant threshold in ms (computed at compile time from centiseconds)
|
||||
static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast<uint32_t>(WARN_IF_BLOCKING_OVER_CS) * 10U;
|
||||
uint32_t blocking_time = curr_time - App.get_loop_component_start_time();
|
||||
if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] {
|
||||
warn_blocking(blocking_time);
|
||||
}
|
||||
#endif
|
||||
return curr_time;
|
||||
}
|
||||
|
||||
~LoopBlockingGuard() = default;
|
||||
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
protected:
|
||||
uint32_t started_us_;
|
||||
#endif
|
||||
|
||||
private:
|
||||
// Cold path; defined in component.cpp. Reads the current component/source from App to name the culprit.
|
||||
static void __attribute__((noinline, cold)) warn_blocking(uint32_t blocking_time);
|
||||
};
|
||||
|
||||
// Phase A: drain wake notifications and run the scheduler. Invoked on every
|
||||
// Application::loop() tick regardless of whether a component phase runs, so
|
||||
// scheduler items fire at their requested cadence even when the caller has
|
||||
@@ -607,7 +691,7 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
// before/tail splits recorded below.
|
||||
uint32_t loop_active_start_us = micros();
|
||||
// Snapshot the cumulative component-recorded time so we can subtract the
|
||||
// slice that the scheduler spends inside its own WarnIfComponentBlockingGuard
|
||||
// slice that the scheduler spends inside its own LoopBlockingGuard
|
||||
// (scheduler.cpp) — that time is already counted in per-component stats,
|
||||
// so charging it again to "before" would double-count.
|
||||
uint64_t loop_recorded_snap = ComponentRuntimeStats::global_recorded_us;
|
||||
@@ -660,12 +744,9 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() {
|
||||
this->current_loop_index_++) {
|
||||
Component *component = this->looping_components_[this->current_loop_index_];
|
||||
|
||||
// Update the cached time before each component runs
|
||||
this->loop_component_start_time_ = last_op_end_time;
|
||||
|
||||
{
|
||||
this->set_current_component(component);
|
||||
WarnIfComponentBlockingGuard guard{component, last_op_end_time};
|
||||
// Guard publishes this component (no script source) + dispatch time, then times loop().
|
||||
LoopBlockingGuard guard{component, nullptr, last_op_end_time};
|
||||
component->loop();
|
||||
// Use the finish method to get the current time as the end time
|
||||
last_op_end_time = guard.finish();
|
||||
|
||||
@@ -201,7 +201,10 @@ template<typename... Ts> class DelayAction : public Action<Ts...> {
|
||||
/* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER,
|
||||
/* static_name= */ reinterpret_cast<const char *>(this), /* hash_or_id= */ 0, this->delay_.value(),
|
||||
[this]() { this->play_next_(); },
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1,
|
||||
// Record the owning script (if any) so the blocking warning can name it; propagates across
|
||||
// chained delays via the scheduler.
|
||||
/* source= */ App.get_current_source());
|
||||
} else {
|
||||
// For delays with arguments, capture by value to preserve argument values
|
||||
// Arguments must be copied because original references may be invalid after delay
|
||||
@@ -212,7 +215,9 @@ template<typename... Ts> class DelayAction : public Action<Ts...> {
|
||||
/* component= */ nullptr, Scheduler::SchedulerItem::TIMEOUT, Scheduler::NameType::SELF_POINTER,
|
||||
/* static_name= */ reinterpret_cast<const char *>(this), /* hash_or_id= */ 0, this->delay_.value(x...),
|
||||
std::move(f),
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1);
|
||||
/* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1,
|
||||
// See the no-argument branch above: record the owning script for log attribution.
|
||||
/* source= */ App.get_current_source());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -258,9 +258,11 @@ void Component::call() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
bool Component::should_warn_of_blocking(uint32_t blocking_time) {
|
||||
bool Component::should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out) {
|
||||
// Convert centisecond threshold to milliseconds for comparison
|
||||
uint32_t threshold_ms = static_cast<uint32_t>(this->warn_if_blocking_over_) * 10U;
|
||||
// Report the threshold that was exceeded (before any ratcheting below) so the warning is accurate.
|
||||
threshold_ms_out = threshold_ms;
|
||||
if (blocking_time > threshold_ms) {
|
||||
// Set new threshold: blocking_time + increment, converted back to centiseconds
|
||||
uint32_t new_threshold_ms = blocking_time + WARN_IF_BLOCKING_INCREMENT_MS;
|
||||
@@ -491,19 +493,25 @@ uint32_t PollingComponent::get_update_interval() const { return this->update_int
|
||||
uint64_t ComponentRuntimeStats::global_recorded_us = 0; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
#endif
|
||||
|
||||
void __attribute__((noinline, cold))
|
||||
WarnIfComponentBlockingGuard::warn_blocking(Component *component, uint32_t blocking_time) {
|
||||
bool should_warn;
|
||||
void __attribute__((noinline, cold)) LoopBlockingGuard::warn_blocking(uint32_t blocking_time) {
|
||||
// Identity is published on App by the caller before the guard is built; read it back here.
|
||||
Component *component = App.get_current_component();
|
||||
// Component-less path always warns (the caller already checked the constant threshold).
|
||||
uint32_t threshold_ms = WARN_IF_BLOCKING_OVER_MS;
|
||||
if (component != nullptr && !component->should_warn_of_blocking(blocking_time, threshold_ms)) {
|
||||
return; // Component's (possibly ratcheted) threshold not exceeded yet
|
||||
}
|
||||
// Component name if any, else the published source (owning script), else a generic label.
|
||||
const LogString *name;
|
||||
if (component != nullptr) {
|
||||
should_warn = component->should_warn_of_blocking(blocking_time);
|
||||
name = component->get_component_log_str();
|
||||
} else {
|
||||
should_warn = true; // Already checked > WARN_IF_BLOCKING_OVER_MS in caller
|
||||
}
|
||||
if (should_warn) {
|
||||
ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is 30 ms",
|
||||
component == nullptr ? LOG_STR_LITERAL("<null>") : LOG_STR_ARG(component->get_component_log_str()),
|
||||
blocking_time);
|
||||
name = App.get_current_source();
|
||||
if (name == nullptr)
|
||||
name = LOG_STR("a scheduled task");
|
||||
}
|
||||
ESP_LOGW(TAG, "%s took a long time for an operation (%" PRIu32 " ms), max is %" PRIu32 " ms", LOG_STR_ARG(name),
|
||||
blocking_time, threshold_ms);
|
||||
}
|
||||
|
||||
#ifdef USE_SETUP_PRIORITY_OVERRIDE
|
||||
|
||||
@@ -118,7 +118,7 @@ struct ComponentRuntimeStats {
|
||||
|
||||
// Cumulative sum of every record_time() duration since boot, across all
|
||||
// components. Used by Application::loop() to snapshot time spent inside
|
||||
// WarnIfComponentBlockingGuard (including guards constructed by the
|
||||
// LoopBlockingGuard (including guards constructed by the
|
||||
// scheduler at scheduler.cpp) so main-loop overhead accounting can
|
||||
// subtract scheduled-callback time from the before_loop_tasks_ wall time.
|
||||
static uint64_t global_recorded_us; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)
|
||||
@@ -326,7 +326,7 @@ class Component {
|
||||
return component_source_lookup(this->component_source_index_);
|
||||
}
|
||||
|
||||
bool should_warn_of_blocking(uint32_t blocking_time);
|
||||
bool should_warn_of_blocking(uint32_t blocking_time, uint32_t &threshold_ms_out);
|
||||
|
||||
protected:
|
||||
friend class Application;
|
||||
@@ -571,7 +571,7 @@ class Component {
|
||||
volatile bool pending_enable_loop_{false}; ///< ISR-safe flag for enable_loop_soon_any_context
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
friend class runtime_stats::RuntimeStatsCollector;
|
||||
friend class WarnIfComponentBlockingGuard;
|
||||
friend class LoopBlockingGuard;
|
||||
ComponentRuntimeStats runtime_stats_;
|
||||
#endif
|
||||
};
|
||||
@@ -619,59 +619,7 @@ class PollingComponent : public Component {
|
||||
uint32_t update_interval_;
|
||||
};
|
||||
|
||||
// millis() and micros() are available via hal.h
|
||||
|
||||
class WarnIfComponentBlockingGuard {
|
||||
public:
|
||||
WarnIfComponentBlockingGuard(Component *component, uint32_t start_time)
|
||||
: started_(start_time),
|
||||
component_(component)
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
,
|
||||
started_us_(micros())
|
||||
#endif
|
||||
{
|
||||
}
|
||||
|
||||
// Finish the timing operation and return the current time (millis)
|
||||
// Inlined: the fast path is just millis() + subtract + compare
|
||||
inline uint32_t HOT finish() {
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t elapsed_us = micros() - this->started_us_;
|
||||
// component_ is nullptr for self-keyed scheduler items (set_timeout/set_interval(self, ...))
|
||||
if (this->component_ != nullptr) {
|
||||
this->component_->runtime_stats_.record_time(elapsed_us);
|
||||
} else {
|
||||
// Still accumulate into the global counter so Application::loop() can subtract
|
||||
// this time from before_loop_tasks_ wall time.
|
||||
ComponentRuntimeStats::global_recorded_us += elapsed_us;
|
||||
}
|
||||
#endif
|
||||
uint32_t curr_time = MillisInternal::get();
|
||||
#ifndef USE_BENCHMARK
|
||||
// Fast path: compare against constant threshold in ms (computed at compile time from centiseconds)
|
||||
static constexpr uint32_t WARN_IF_BLOCKING_OVER_MS = static_cast<uint32_t>(WARN_IF_BLOCKING_OVER_CS) * 10U;
|
||||
uint32_t blocking_time = curr_time - this->started_;
|
||||
if (blocking_time > WARN_IF_BLOCKING_OVER_MS) [[unlikely]] {
|
||||
warn_blocking(this->component_, blocking_time);
|
||||
}
|
||||
#endif
|
||||
return curr_time;
|
||||
}
|
||||
|
||||
~WarnIfComponentBlockingGuard() = default;
|
||||
|
||||
protected:
|
||||
uint32_t started_;
|
||||
Component *component_;
|
||||
#ifdef USE_RUNTIME_STATS
|
||||
uint32_t started_us_;
|
||||
#endif
|
||||
|
||||
private:
|
||||
// Cold path for blocking warning - defined in component.cpp
|
||||
static void __attribute__((noinline, cold)) warn_blocking(Component *component, uint32_t blocking_time);
|
||||
};
|
||||
// LoopBlockingGuard lives in application.h because it reads its state from App.
|
||||
|
||||
// Function to clear setup priority overrides after all components are set up
|
||||
// Only has an implementation when USE_SETUP_PRIORITY_OVERRIDE is defined
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace esphome {
|
||||
|
||||
// Friend-gated accessor for a fast millis() variant intended only for
|
||||
// known task-context callers on the main loop hot path (Application::loop()
|
||||
// and WarnIfComponentBlockingGuard::finish()). It skips the ISR-context
|
||||
// and LoopBlockingGuard::finish()). It skips the ISR-context
|
||||
// dispatch that the public esphome::millis() pays on ESP32 and libretiny.
|
||||
//
|
||||
// MUST NOT be called from ISR context: on ESP32 and libretiny it calls the
|
||||
@@ -50,7 +50,7 @@ class MillisInternal {
|
||||
#endif
|
||||
}
|
||||
friend class Application;
|
||||
friend class WarnIfComponentBlockingGuard;
|
||||
friend class LoopBlockingGuard;
|
||||
};
|
||||
|
||||
} // namespace esphome
|
||||
|
||||
@@ -131,7 +131,8 @@ bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_t
|
||||
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
|
||||
void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type,
|
||||
const char *static_name, uint32_t hash_or_id, uint32_t delay,
|
||||
std::function<void()> &&func, bool is_retry, bool skip_cancel) {
|
||||
std::function<void()> &&func, bool is_retry, bool skip_cancel,
|
||||
const LogString *source) {
|
||||
if (delay == SCHEDULER_DONT_RUN) {
|
||||
// Still need to cancel existing timer if we have a name/id
|
||||
if (!skip_cancel) {
|
||||
@@ -174,7 +175,12 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type
|
||||
|
||||
// Create and populate the scheduler item
|
||||
SchedulerItem *item = this->get_item_from_pool_locked_();
|
||||
item->component = component;
|
||||
// SELF_POINTER items store the source name (owning script) in the union slot instead of a component.
|
||||
if (name_type == NameType::SELF_POINTER) {
|
||||
item->source_name = source;
|
||||
} else {
|
||||
item->component = component;
|
||||
}
|
||||
item->set_name(name_type, static_name, hash_or_id);
|
||||
item->type = type;
|
||||
// Use destroy + placement-new instead of move-assignment.
|
||||
@@ -642,8 +648,8 @@ uint32_t HOT Scheduler::call(uint32_t now) {
|
||||
// Not reached timeout yet, done for this call
|
||||
break;
|
||||
}
|
||||
// Don't run on failed components
|
||||
if (item->component != nullptr && item->component->is_failed()) {
|
||||
// Don't run on failed components (is_item_failed_ exempts SELF_POINTER delays).
|
||||
if (this->is_item_failed_(item)) {
|
||||
LockGuard guard{this->lock_};
|
||||
this->recycle_item_main_loop_(this->pop_raw_locked_());
|
||||
continue;
|
||||
@@ -790,10 +796,21 @@ Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() {
|
||||
|
||||
// Helper to execute a scheduler item
|
||||
uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) {
|
||||
App.set_current_component(item->component);
|
||||
// Freshen so callbacks reading App.get_loop_component_start_time() see this item's dispatch time.
|
||||
App.set_loop_component_start_time_(now);
|
||||
WarnIfComponentBlockingGuard guard{item->component, now};
|
||||
// Resolve the component and (for SELF_POINTER/deferred items) the source name from the shared
|
||||
// union slot with a single name-type check. Self-keyed items have no owning component; their slot
|
||||
// holds the source name (e.g. the owning script), published so deferred work chained inside the
|
||||
// callback re-captures it and the blocking warning can name the script instead of "<null>".
|
||||
Component *component;
|
||||
const LogString *source;
|
||||
if (item->get_name_type() == NameType::SELF_POINTER) {
|
||||
component = nullptr;
|
||||
source = item->source_name;
|
||||
} else {
|
||||
component = item->component;
|
||||
source = nullptr;
|
||||
}
|
||||
// Guard publishes the item's identity + dispatch time, then times the callback.
|
||||
LoopBlockingGuard guard{component, source, now};
|
||||
item->callback();
|
||||
uint32_t end = guard.finish();
|
||||
// Feed the watchdog after each scheduled item (both main heap and defer
|
||||
|
||||
@@ -183,11 +183,12 @@ class Scheduler {
|
||||
|
||||
protected:
|
||||
struct SchedulerItem {
|
||||
// Ordered by size to minimize padding.
|
||||
// `component` while live; `next_free` while in scheduler_item_pool_head_ (mutually exclusive).
|
||||
// Ordered by size to minimize padding. Mutually exclusive by state; read the component via
|
||||
// get_component() so SELF_POINTER items read as component-less.
|
||||
union {
|
||||
Component *component;
|
||||
SchedulerItem *next_free;
|
||||
Component *component; // live, non-SELF_POINTER: owning component
|
||||
const LogString *source_name; // live SELF_POINTER: owning script name (log attribution)
|
||||
SchedulerItem *next_free; // while pooled
|
||||
};
|
||||
// Optimized name storage using tagged union - zero heap allocation
|
||||
union {
|
||||
@@ -302,14 +303,23 @@ class Scheduler {
|
||||
next_execution_high_ = static_cast<uint16_t>(value >> 32);
|
||||
}
|
||||
constexpr const char *get_type_str() const { return (type == TIMEOUT) ? "timeout" : "interval"; }
|
||||
const LogString *get_source() const { return component ? component->get_component_log_str() : LOG_STR("unknown"); }
|
||||
// The owning component, or nullptr for SELF_POINTER items (whose slot holds source_name instead).
|
||||
// All component access goes through this so SELF_POINTER items read as component-less.
|
||||
Component *get_component() const { return name_type_ == NameType::SELF_POINTER ? nullptr : component; }
|
||||
const LogString *get_source() const {
|
||||
// Same no-source label as warn_blocking, for consistent log vocabulary.
|
||||
if (name_type_ == NameType::SELF_POINTER)
|
||||
return source_name != nullptr ? source_name : LOG_STR("a scheduled task");
|
||||
return component != nullptr ? component->get_component_log_str() : LOG_STR("unknown");
|
||||
}
|
||||
};
|
||||
|
||||
// Common implementation for both timeout and interval
|
||||
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
|
||||
// `source` is stored (in the union slot) only for SELF_POINTER items; ignored otherwise.
|
||||
void set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type, const char *static_name,
|
||||
uint32_t hash_or_id, uint32_t delay, std::function<void()> &&func, bool is_retry = false,
|
||||
bool skip_cancel = false);
|
||||
bool skip_cancel = false, const LogString *source = nullptr);
|
||||
|
||||
// Common implementation for retry - Remove before 2026.8.0
|
||||
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
|
||||
@@ -402,8 +412,10 @@ class Scheduler {
|
||||
// Fixes: https://github.com/esphome/esphome/issues/11940
|
||||
if (item == nullptr)
|
||||
return false;
|
||||
if (item->component != component || item->type != type || (skip_removed && this->is_item_removed_locked_(item)) ||
|
||||
(match_retry && !item->is_retry)) {
|
||||
// get_component() is nullptr for SELF_POINTER items (their cancels pass nullptr too), so they
|
||||
// match by the `this` key alone.
|
||||
if (item->get_component() != component || item->type != type ||
|
||||
(skip_removed && this->is_item_removed_locked_(item)) || (match_retry && !item->is_retry)) {
|
||||
return false;
|
||||
}
|
||||
// Name type must match
|
||||
@@ -423,11 +435,16 @@ class Scheduler {
|
||||
// Helper to execute a scheduler item
|
||||
uint32_t execute_item_(SchedulerItem *item, uint32_t now);
|
||||
|
||||
// Helper to check if item should be skipped
|
||||
bool should_skip_item_(SchedulerItem *item) const {
|
||||
return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed());
|
||||
// True if the item's component is failed (so it must not run). SELF_POINTER delays have no
|
||||
// component (get_component() == nullptr) and always fire.
|
||||
bool is_item_failed_(SchedulerItem *item) const {
|
||||
Component *component = item->get_component();
|
||||
return component != nullptr && component->is_failed();
|
||||
}
|
||||
|
||||
// Helper to check if item should be skipped
|
||||
bool should_skip_item_(SchedulerItem *item) const { return is_item_removed_(item) || this->is_item_failed_(item); }
|
||||
|
||||
// Helper to recycle a SchedulerItem back to the pool.
|
||||
// Takes a raw pointer — caller transfers ownership. The item is either added to the
|
||||
// pool or deleted if the pool is full.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -7,6 +7,7 @@ import sys
|
||||
|
||||
from esphome.const import CONF_COMPILE_PROCESS_LIMIT, CONF_ESPHOME, KEY_CORE
|
||||
from esphome.core import CORE, EsphomeError
|
||||
from esphome.helpers import add_git_ceiling_directory
|
||||
from esphome.util import FlashImage, run_external_process
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -53,6 +54,10 @@ def run_platformio_cli(*args, **kwargs) -> str | int:
|
||||
os.environ.setdefault("PYTHONWARNINGS", "ignore::SyntaxWarning")
|
||||
# Increase uv retry count to handle transient network errors (default is 3)
|
||||
os.environ.setdefault("UV_HTTP_RETRIES", "10")
|
||||
# Cap git's repo search at the config directory so the framework's build
|
||||
# scripts running `git describe` for the app version can't error out on an
|
||||
# uninitialized or corrupt git repo in a parent directory.
|
||||
add_git_ceiling_directory(os.environ, CORE.config_dir)
|
||||
# Strip the Windows extended-length path prefix from sys.executable so it
|
||||
# doesn't propagate into PlatformIO's $PYTHONEXE and break SCons-emitted
|
||||
# command lines run through cmd.exe.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
pylint==4.0.5
|
||||
flake8==7.3.0 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.15.17 # also change in .pre-commit-config.yaml when updating
|
||||
ruff==0.15.16 # also change in .pre-commit-config.yaml when updating
|
||||
pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating
|
||||
pre-commit
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -104,44 +104,6 @@ def set_component_config() -> Callable[[str, Any], None]:
|
||||
return setter
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def choose_variant_with_pins() -> Generator[Callable[[list], None]]:
|
||||
"""Set the ESP32 variant to the first one on which all the given pins are valid.
|
||||
|
||||
For ESP32 only, since the other platforms do not have variants. The core
|
||||
configuration must already have been set up for an ESP32 target.
|
||||
Using local imports to avoid importing when ESP32 is not the target.
|
||||
"""
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS
|
||||
from esphome.components.esp32.gpio import validate_gpio_pin
|
||||
from esphome.const import CONF_INPUT, CONF_OUTPUT
|
||||
from esphome.pins import gpio_pin_schema
|
||||
|
||||
def chooser(pins: list) -> None:
|
||||
for variant in VARIANTS:
|
||||
try:
|
||||
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
|
||||
for pin in pins:
|
||||
if pin is not None:
|
||||
pin = gpio_pin_schema(
|
||||
{
|
||||
CONF_INPUT: True,
|
||||
CONF_OUTPUT: True,
|
||||
},
|
||||
internal=True,
|
||||
)(pin)
|
||||
validate_gpio_pin(pin)
|
||||
return
|
||||
except cv.Invalid:
|
||||
continue
|
||||
raise cv.Invalid(
|
||||
f"No compatible variant found for pins: {', '.join(map(str, pins))}"
|
||||
)
|
||||
|
||||
yield chooser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def component_fixture_path(request: pytest.FixtureRequest) -> Callable[[str], Path]:
|
||||
"""Return a function to get absolute paths relative to the component's fixtures directory."""
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
esphome:
|
||||
name: test
|
||||
|
||||
esp32:
|
||||
board: esp32dev
|
||||
|
||||
spi:
|
||||
clk_pin: GPIO18
|
||||
mosi_pin: GPIO19
|
||||
|
||||
display:
|
||||
- platform: epaper_spi
|
||||
id: epaper_display
|
||||
model: ssd1677
|
||||
dc_pin: GPIO21
|
||||
busy_pin: GPIO22
|
||||
reset_pin: GPIO23
|
||||
cs_pin: GPIO5
|
||||
enable_pin:
|
||||
- GPIO25
|
||||
- GPIO26
|
||||
dimensions:
|
||||
width: 200
|
||||
height: 200
|
||||
@@ -1,156 +0,0 @@
|
||||
"""Tests for display metadata created by the epaper_spi component."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.display import get_all_display_metadata, get_display_metadata
|
||||
from esphome.components.epaper_spi.display import CONFIG_SCHEMA
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.const import PlatformFramework
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
|
||||
def _base_config(**overrides: Any) -> ConfigType:
|
||||
"""Build a minimal valid ssd1677 config, allowing field overrides."""
|
||||
config: ConfigType = {
|
||||
"id": "test_display",
|
||||
"model": "ssd1677",
|
||||
"dc_pin": 21,
|
||||
"busy_pin": 22,
|
||||
"reset_pin": 23,
|
||||
"cs_pin": 5,
|
||||
"dimensions": {"width": 200, "height": 300},
|
||||
}
|
||||
config.update(overrides)
|
||||
return config
|
||||
|
||||
|
||||
def test_metadata_dimensions_and_defaults(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Callable[[str, Any], None],
|
||||
) -> None:
|
||||
"""Metadata picks up explicit dimensions and epaper_spi defaults."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
|
||||
|
||||
config = CONFIG_SCHEMA(_base_config())
|
||||
meta = get_display_metadata(config["id"])
|
||||
|
||||
assert meta is not None
|
||||
assert meta.width == 200
|
||||
assert meta.height == 300
|
||||
# epaper_spi always reports full hardware rotation
|
||||
assert meta.has_hardware_rotation is True
|
||||
# epaper_spi does not declare a byte order
|
||||
assert meta.byte_order is cv.UNDEFINED
|
||||
assert meta.draw_rounding == 0
|
||||
# no drawing methods configured -> no writer
|
||||
assert meta.has_writer is False
|
||||
|
||||
|
||||
def test_metadata_default_dimensions_from_model(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Callable[[str, Any], None],
|
||||
) -> None:
|
||||
"""A model with built-in dimensions reports those without explicit dimensions."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
|
||||
|
||||
# waveshare-4.26in is an ssd1677 derivative with default 800x480 dimensions
|
||||
config = CONFIG_SCHEMA(
|
||||
{
|
||||
"id": "wave_display",
|
||||
"model": "waveshare-4.26in",
|
||||
"dc_pin": 21,
|
||||
"busy_pin": 22,
|
||||
"reset_pin": 23,
|
||||
"cs_pin": 5,
|
||||
}
|
||||
)
|
||||
meta = get_display_metadata(config["id"])
|
||||
|
||||
assert meta is not None
|
||||
assert meta.width == 800
|
||||
assert meta.height == 480
|
||||
|
||||
|
||||
def test_metadata_has_writer_with_auto_clear(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Callable[[str, Any], None],
|
||||
) -> None:
|
||||
"""A display with auto_clear_enabled reports has_writer=True."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
|
||||
|
||||
config = CONFIG_SCHEMA(_base_config(auto_clear_enabled=True))
|
||||
meta = get_display_metadata(config["id"])
|
||||
|
||||
assert meta is not None
|
||||
assert meta.has_writer is True
|
||||
|
||||
|
||||
def test_metadata_rotation_propagated(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Callable[[str, Any], None],
|
||||
) -> None:
|
||||
"""The configured rotation is stored in the metadata."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
|
||||
|
||||
config = CONFIG_SCHEMA(_base_config(rotation=90))
|
||||
meta = get_display_metadata(config["id"])
|
||||
|
||||
assert meta is not None
|
||||
assert meta.rotation == 90
|
||||
|
||||
|
||||
def test_metadata_multiple_displays_independent(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Callable[[str, Any], None],
|
||||
) -> None:
|
||||
"""Each display gets its own independent metadata entry."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
|
||||
|
||||
CONFIG_SCHEMA(_base_config(id="disp_a", dimensions={"width": 200, "height": 300}))
|
||||
CONFIG_SCHEMA(_base_config(id="disp_b", dimensions={"width": 400, "height": 480}))
|
||||
|
||||
all_meta = get_all_display_metadata()
|
||||
assert all_meta["disp_a"].width == 200
|
||||
assert all_meta["disp_a"].height == 300
|
||||
assert all_meta["disp_b"].width == 400
|
||||
assert all_meta["disp_b"].height == 480
|
||||
|
||||
|
||||
def test_metadata_via_code_generation(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Full code generation registers metadata for the configured display."""
|
||||
generate_main(component_config_path("enable_pin_test.yaml"))
|
||||
|
||||
all_meta = get_all_display_metadata()
|
||||
assert len(all_meta) == 1
|
||||
meta = next(iter(all_meta.values()))
|
||||
# enable_pin_test.yaml: ssd1677 at 200x200
|
||||
assert meta.width == 200
|
||||
assert meta.height == 200
|
||||
assert meta.has_hardware_rotation is True
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Tests for epaper_spi configuration validation."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -13,13 +11,17 @@ from esphome.components.epaper_spi.display import (
|
||||
FINAL_VALIDATE_SCHEMA,
|
||||
MODELS,
|
||||
)
|
||||
from esphome.components.esp32 import KEY_BOARD, KEY_VARIANT, VARIANT_ESP32
|
||||
from esphome.components.esp32 import (
|
||||
KEY_BOARD,
|
||||
KEY_VARIANT,
|
||||
VARIANT_ESP32,
|
||||
VARIANT_ESP32S3,
|
||||
)
|
||||
from esphome.const import (
|
||||
CONF_BUSY_PIN,
|
||||
CONF_CS_PIN,
|
||||
CONF_DC_PIN,
|
||||
CONF_DIMENSIONS,
|
||||
CONF_ENABLE_PIN,
|
||||
CONF_HEIGHT,
|
||||
CONF_INIT_SEQUENCE,
|
||||
CONF_RESET_PIN,
|
||||
@@ -29,30 +31,6 @@ from esphome.const import (
|
||||
from esphome.types import ConfigType
|
||||
from tests.component_tests.types import SetCoreConfigCallable
|
||||
|
||||
# Pin options whose values must be valid on the chosen ESP32 variant.
|
||||
_PIN_CONF_KEYS = (
|
||||
CONF_CS_PIN,
|
||||
CONF_DC_PIN,
|
||||
CONF_RESET_PIN,
|
||||
CONF_BUSY_PIN,
|
||||
CONF_ENABLE_PIN,
|
||||
)
|
||||
|
||||
|
||||
def _pins_for(model: Any, config: ConfigType) -> list:
|
||||
"""Collect every GPIO the config will actually use (model defaults or injected)."""
|
||||
pins: list = []
|
||||
for key in _PIN_CONF_KEYS:
|
||||
# An injected value in the config takes precedence over the model default.
|
||||
value = config[key] if key in config else model.get_default(key)
|
||||
if not value: # get_default returns False for pins the model omits
|
||||
continue
|
||||
if isinstance(value, list):
|
||||
pins.extend(value)
|
||||
else:
|
||||
pins.append(value)
|
||||
return pins
|
||||
|
||||
|
||||
def run_schema_validation(
|
||||
config: ConfigType, with_final_validate: bool = False
|
||||
@@ -112,20 +90,29 @@ def test_basic_configuration_errors(
|
||||
def test_all_predefined_models(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Callable[[str, Any], None],
|
||||
choose_variant_with_pins: Callable[[list], None],
|
||||
) -> None:
|
||||
"""Test all predefined epaper models validate successfully with appropriate defaults."""
|
||||
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# Configure SPI component which is required by epaper_spi
|
||||
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
|
||||
|
||||
# Test all models, providing default values where necessary
|
||||
for name, model in MODELS.items():
|
||||
# SEEED models are designed for ESP32-S3 hardware
|
||||
if name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"):
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
},
|
||||
)
|
||||
else:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# Configure SPI component which is required by epaper_spi
|
||||
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
|
||||
|
||||
config = {"model": name}
|
||||
|
||||
# Add ID field
|
||||
@@ -154,10 +141,6 @@ def test_all_predefined_models(
|
||||
if not model.get_default(CONF_CS_PIN):
|
||||
config[CONF_CS_PIN] = 5
|
||||
|
||||
# Select an ESP32 variant on which all of this model's pins are valid
|
||||
# (some models default to high-numbered pins only present on the S3).
|
||||
choose_variant_with_pins(_pins_for(model, config))
|
||||
|
||||
run_schema_validation(config)
|
||||
|
||||
|
||||
@@ -169,19 +152,27 @@ def test_individual_models(
|
||||
model_name: str,
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Callable[[str, Any], None],
|
||||
choose_variant_with_pins: Callable[[list], None],
|
||||
) -> None:
|
||||
"""Test each epaper model individually to ensure it validates correctly."""
|
||||
model = MODELS[model_name]
|
||||
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
# SEEED models are designed for ESP32-S3 hardware
|
||||
if model_name in ("SEEED-EE04-MONO-4.26", "SEEED-RETERMINAL-E1002"):
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={
|
||||
KEY_BOARD: "esp32-s3-devkitc-1",
|
||||
KEY_VARIANT: VARIANT_ESP32S3,
|
||||
},
|
||||
)
|
||||
else:
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# Configure SPI component which is required by epaper_spi
|
||||
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
|
||||
|
||||
model = MODELS[model_name]
|
||||
config: dict[str, Any] = {"model": model_name, "id": "test_display"}
|
||||
|
||||
# Add required fields based on model defaults
|
||||
@@ -204,10 +195,6 @@ def test_individual_models(
|
||||
if not model.get_default(CONF_CS_PIN):
|
||||
config[CONF_CS_PIN] = 5
|
||||
|
||||
# Select an ESP32 variant on which all of this model's pins are valid
|
||||
# (some models default to high-numbered pins only present on the S3).
|
||||
choose_variant_with_pins(_pins_for(model, config))
|
||||
|
||||
# This should not raise any exceptions
|
||||
run_schema_validation(config)
|
||||
|
||||
@@ -355,102 +342,3 @@ def test_busy_pin_input_mode_ssd1677(
|
||||
reset_pin_config = result[CONF_RESET_PIN]
|
||||
assert "mode" in reset_pin_config
|
||||
assert reset_pin_config["mode"]["output"] is True
|
||||
|
||||
|
||||
def test_enable_pin_single(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Callable[[str, Any], None],
|
||||
) -> None:
|
||||
"""Test that a single enable_pin is accepted and normalised to a list of output pins."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# Configure SPI component which is required by epaper_spi
|
||||
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
|
||||
|
||||
result = run_schema_validation(
|
||||
{
|
||||
"id": "test_display",
|
||||
"model": "ssd1677",
|
||||
"dc_pin": 21,
|
||||
"busy_pin": 22,
|
||||
"reset_pin": 23,
|
||||
"cs_pin": 5,
|
||||
"enable_pin": 25,
|
||||
"dimensions": {
|
||||
"width": 200,
|
||||
"height": 200,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# A single pin is normalised to a list by cv.ensure_list
|
||||
assert CONF_ENABLE_PIN in result
|
||||
enable_pins = result[CONF_ENABLE_PIN]
|
||||
assert isinstance(enable_pins, list)
|
||||
assert len(enable_pins) == 1
|
||||
# enable pins are configured as outputs
|
||||
assert enable_pins[0]["mode"]["output"] is True
|
||||
|
||||
|
||||
def test_enable_pin_multiple(
|
||||
set_core_config: SetCoreConfigCallable,
|
||||
set_component_config: Callable[[str, Any], None],
|
||||
) -> None:
|
||||
"""Test that a list of enable_pins is accepted."""
|
||||
set_core_config(
|
||||
PlatformFramework.ESP32_IDF,
|
||||
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
|
||||
)
|
||||
|
||||
# Configure SPI component which is required by epaper_spi
|
||||
set_component_config("spi", {"id": "spi_bus", "clk_pin": 18, "mosi_pin": 19})
|
||||
|
||||
result = run_schema_validation(
|
||||
{
|
||||
"id": "test_display",
|
||||
"model": "ssd1677",
|
||||
"dc_pin": 21,
|
||||
"busy_pin": 22,
|
||||
"reset_pin": 23,
|
||||
"cs_pin": 5,
|
||||
"enable_pin": [25, 26],
|
||||
"dimensions": {
|
||||
"width": 200,
|
||||
"height": 200,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
assert CONF_ENABLE_PIN in result
|
||||
enable_pins = result[CONF_ENABLE_PIN]
|
||||
assert isinstance(enable_pins, list)
|
||||
assert len(enable_pins) == 2
|
||||
assert all(pin["mode"]["output"] is True for pin in enable_pins)
|
||||
|
||||
|
||||
def test_enable_pin_code_generation(
|
||||
generate_main: Callable[[str | Path], str],
|
||||
component_config_path: Callable[[str], Path],
|
||||
) -> None:
|
||||
"""Test that enable_pins are wired up in the generated C++ code."""
|
||||
main_cpp = generate_main(component_config_path("enable_pin_test.yaml"))
|
||||
|
||||
# Derive the auto-generated pin variable names from the set_pin() lines
|
||||
# rather than hard-coding them, so the test does not break when unrelated
|
||||
# codegen details shift the generated IDs.
|
||||
def pin_var_for(gpio_num: int) -> str:
|
||||
match = re.search(rf"(\w+)->set_pin\(::GPIO_NUM_{gpio_num}\);", main_cpp)
|
||||
assert match is not None, (
|
||||
f"GPIO_NUM_{gpio_num} pin not set up in generated code"
|
||||
)
|
||||
return match.group(1)
|
||||
|
||||
pin_25 = pin_var_for(25)
|
||||
pin_26 = pin_var_for(26)
|
||||
|
||||
# Both pin objects must be passed to the display via set_enable_pins() as a
|
||||
# std::vector initializer list, in the configured order.
|
||||
assert f"set_enable_pins({{{pin_25}, {pin_26}}});" in main_cpp
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
"""Tests for mpip_spi configuration validation."""
|
||||
|
||||
from collections.abc import Callable, Generator
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
# choose_variant_with_pins is provided by the shared parent conftest.
|
||||
from esphome import config_validation as cv
|
||||
from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANTS
|
||||
from esphome.components.esp32.gpio import validate_gpio_pin
|
||||
from esphome.const import CONF_INPUT, CONF_OUTPUT
|
||||
from esphome.core import CORE
|
||||
from esphome.pins import gpio_pin_schema
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -15,3 +21,34 @@ def mock_spi_final_validate():
|
||||
return_value=lambda config: None,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def choose_variant_with_pins() -> Generator[Callable[[list], None]]:
|
||||
"""
|
||||
Set the ESP32 variant for the given model based on pins. For ESP32 only since the other platforms
|
||||
do not have variants.
|
||||
"""
|
||||
|
||||
def chooser(pins: list) -> None:
|
||||
for variant in VARIANTS:
|
||||
try:
|
||||
CORE.data[KEY_ESP32][KEY_VARIANT] = variant
|
||||
for pin in pins:
|
||||
if pin is not None:
|
||||
pin = gpio_pin_schema(
|
||||
{
|
||||
CONF_INPUT: True,
|
||||
CONF_OUTPUT: True,
|
||||
},
|
||||
internal=True,
|
||||
)(pin)
|
||||
validate_gpio_pin(pin)
|
||||
return
|
||||
except cv.Invalid:
|
||||
continue
|
||||
raise cv.Invalid(
|
||||
f"No compatible variant found for pins: {', '.join(map(str, pins))}"
|
||||
)
|
||||
|
||||
yield chooser
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
<<: !include common.yaml
|
||||
|
||||
packages:
|
||||
spi: !include ../../test_build_components/common/spi/esp32-idf.yaml
|
||||
|
||||
display:
|
||||
- platform: ili9xxx
|
||||
spi_id: spi_bus
|
||||
id: main_lcd
|
||||
model: ili9342
|
||||
cs_pin: 20
|
||||
dc_pin: 13
|
||||
reset_pin: 21
|
||||
invert_colors: true
|
||||
lambda: |-
|
||||
it.fill(Color(0, 0, 0));
|
||||
it.image(0, 0, id(album_art));
|
||||
|
||||
generic_image:
|
||||
- platform: sendspin
|
||||
id: album_art
|
||||
format: JPEG
|
||||
type: RGB565
|
||||
resize: 240x240
|
||||
source: ALBUM
|
||||
on_image_display:
|
||||
- logger.log: "Album art displayed"
|
||||
on_image_error:
|
||||
- logger.log: "Album art error"
|
||||
- platform: sendspin
|
||||
id: artist_art
|
||||
format: PNG
|
||||
type: RGB565
|
||||
resize: 96x96
|
||||
source: ARTIST
|
||||
@@ -1 +0,0 @@
|
||||
<<: !include common-generic_image.yaml
|
||||
@@ -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}
|
||||
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
|
||||
@@ -266,11 +266,13 @@ def _make_component_stub(
|
||||
*,
|
||||
multi_conf: bool = False,
|
||||
is_platform_component: bool = False,
|
||||
is_target_platform: bool = False,
|
||||
config_schema=None,
|
||||
) -> MagicMock:
|
||||
stub = MagicMock()
|
||||
stub.multi_conf = multi_conf
|
||||
stub.is_platform_component = is_platform_component
|
||||
stub.is_target_platform = is_target_platform
|
||||
stub.config_schema = config_schema
|
||||
return stub
|
||||
|
||||
|
||||
@@ -89,8 +89,9 @@ def test_get_idedata_generates_and_caches(setup_core: Path) -> None:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "g++"}
|
||||
assert json.loads(cache.read_text()) == {"cxx_path": "g++"}
|
||||
prog_path = str(toolchain.get_elf_path())
|
||||
assert result == {"cxx_path": "g++", "prog_path": prog_path}
|
||||
assert json.loads(cache.read_text()) == {"cxx_path": "g++", "prog_path": prog_path}
|
||||
|
||||
|
||||
def test_get_idedata_uses_cache_when_valid(setup_core: Path) -> None:
|
||||
@@ -127,7 +128,7 @@ def test_get_idedata_regenerates_when_compile_commands_newer(setup_core: Path) -
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "fresh"}
|
||||
assert result == {"cxx_path": "fresh", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
|
||||
@@ -147,7 +148,40 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
mock_transform.assert_called_once()
|
||||
assert result == {"cxx_path": "regen"}
|
||||
assert result == {"cxx_path": "regen", "prog_path": str(toolchain.get_elf_path())}
|
||||
|
||||
|
||||
def test_get_idedata_prog_path_points_at_firmware_elf(setup_core: Path) -> None:
|
||||
"""The idedata exposes prog_path (the ELF) so consumers like build-action
|
||||
can locate firmware.factory.bin / firmware.ota.bin as its siblings."""
|
||||
compile_commands, _ = _setup_build(setup_core)
|
||||
compile_commands.parent.mkdir(parents=True, exist_ok=True)
|
||||
compile_commands.write_text("[]")
|
||||
|
||||
with patch(
|
||||
"esphome.espidf.idedata.idedata_from_build",
|
||||
return_value={"cxx_path": "g++"},
|
||||
):
|
||||
result = toolchain.get_idedata()
|
||||
|
||||
# Use Path semantics so the contract holds on Windows too (backslashes).
|
||||
prog_path = Path(result["prog_path"])
|
||||
assert prog_path.name == "firmware.elf"
|
||||
assert prog_path.parent.name == "build"
|
||||
|
||||
|
||||
def test_get_idf_env_sets_git_ceiling_directories(setup_core: Path) -> None:
|
||||
"""The IDF env caps git's upward search at the config directory.
|
||||
|
||||
This stops ESP-IDF's `git describe` from walking into an uninitialized or
|
||||
corrupt git repo in a parent directory and failing the build.
|
||||
"""
|
||||
toolchain._cache().env.clear()
|
||||
# Set IDF_PATH so the framework-install branch is skipped.
|
||||
with patch.dict(os.environ, {"IDF_PATH": str(setup_core)}):
|
||||
env = toolchain._get_idf_env(version="5.5.4")
|
||||
assert CORE.config_dir == setup_core
|
||||
assert str(CORE.config_dir) in env["GIT_CEILING_DIRECTORIES"].split(os.pathsep)
|
||||
|
||||
|
||||
def test_get_core_framework_version_from_core_data():
|
||||
|
||||
@@ -196,6 +196,33 @@ def test_is_ha_addon(monkeypatch, value, expected):
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_add_git_ceiling_directory_sets_when_unset():
|
||||
"""An empty env gets GIT_CEILING_DIRECTORIES set to the directory."""
|
||||
env: dict[str, str] = {}
|
||||
directory = Path("/home/user/config")
|
||||
helpers.add_git_ceiling_directory(env, directory)
|
||||
assert env["GIT_CEILING_DIRECTORIES"] == str(directory)
|
||||
|
||||
|
||||
def test_add_git_ceiling_directory_appends_to_existing():
|
||||
"""An existing value is preserved and the new directory is appended."""
|
||||
env = {"GIT_CEILING_DIRECTORIES": str(Path("/some/ceiling"))}
|
||||
directory = Path("/home/user/config")
|
||||
helpers.add_git_ceiling_directory(env, directory)
|
||||
assert env["GIT_CEILING_DIRECTORIES"].split(os.pathsep) == [
|
||||
str(Path("/some/ceiling")),
|
||||
str(directory),
|
||||
]
|
||||
|
||||
|
||||
def test_add_git_ceiling_directory_skips_duplicate():
|
||||
"""A directory already in the list is not appended again."""
|
||||
directory = Path("/home/user/config")
|
||||
env = {"GIT_CEILING_DIRECTORIES": str(directory)}
|
||||
helpers.add_git_ceiling_directory(env, directory)
|
||||
assert env["GIT_CEILING_DIRECTORIES"] == str(directory)
|
||||
|
||||
|
||||
def test_walk_files(fixture_path):
|
||||
path = fixture_path / "helpers"
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ from esphome.__main__ import (
|
||||
command_clean_all,
|
||||
command_config,
|
||||
command_config_hash,
|
||||
command_idedata,
|
||||
command_rename,
|
||||
command_run,
|
||||
command_update_all,
|
||||
@@ -689,6 +690,25 @@ def test_choose_upload_log_host_with_ota_device_with_ota_config() -> None:
|
||||
assert result == ["192.168.1.100"]
|
||||
|
||||
|
||||
def test_choose_upload_log_host_ota_mdns_disabled_uses_address_cache() -> None:
|
||||
"""A .local device with mDNS disabled resolves via the dashboard-supplied cache."""
|
||||
setup_core(
|
||||
config={
|
||||
CONF_API: {},
|
||||
CONF_OTA: [{CONF_PLATFORM: CONF_ESPHOME}],
|
||||
CONF_MDNS: {CONF_DISABLED: True},
|
||||
},
|
||||
address="esp32-a1s.local",
|
||||
)
|
||||
CORE.address_cache = AddressCache(mdns_cache={"esp32-a1s.local": ["192.168.1.50"]})
|
||||
|
||||
for purpose in (Purpose.LOGGING, Purpose.UPLOADING):
|
||||
result = choose_upload_log_host(
|
||||
default="OTA", check_default=None, purpose=purpose
|
||||
)
|
||||
assert result == ["192.168.1.50"]
|
||||
|
||||
|
||||
def test_choose_upload_log_host_with_ota_device_with_api_config() -> None:
|
||||
"""Test OTA device when API is configured (no upload without OTA in config)."""
|
||||
setup_core(config={CONF_API: {}}, address="192.168.1.100")
|
||||
@@ -3135,6 +3155,22 @@ def test_has_resolvable_address() -> None:
|
||||
setup_core(config={CONF_MDNS: {CONF_DISABLED: True}}, address=None)
|
||||
assert has_resolvable_address() is False
|
||||
|
||||
# mDNS disabled + .local, but the dashboard cached the address -> resolvable
|
||||
setup_core(
|
||||
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
|
||||
)
|
||||
CORE.address_cache = AddressCache(
|
||||
mdns_cache={"esphome-device.local": ["192.168.1.100"]}
|
||||
)
|
||||
assert has_resolvable_address() is True
|
||||
|
||||
# mDNS disabled + .local, cache present but missing this host -> not resolvable
|
||||
setup_core(
|
||||
config={CONF_MDNS: {CONF_DISABLED: True}}, address="esphome-device.local"
|
||||
)
|
||||
CORE.address_cache = AddressCache(mdns_cache={"other-device.local": ["10.0.0.1"]})
|
||||
assert has_resolvable_address() is False
|
||||
|
||||
|
||||
def test_has_name_add_mac_suffix() -> None:
|
||||
"""Test has_name_add_mac_suffix function."""
|
||||
@@ -6222,3 +6258,28 @@ def test_command_run_defaults_subscribe_states_true(
|
||||
mock_run_logs.assert_called_once_with(
|
||||
CORE.config, ["192.168.1.100"], subscribe_states=True
|
||||
)
|
||||
|
||||
|
||||
def test_command_idedata_esp_idf_prints_json(capsys: CaptureFixture) -> None:
|
||||
"""Under the native ESP-IDF toolchain, idedata is emitted as JSON."""
|
||||
setup_core()
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
data = {"cxx_path": "g++", "prog_path": "/build/firmware.elf"}
|
||||
|
||||
with patch("esphome.espidf.toolchain.get_idedata", return_value=data) as mock_get:
|
||||
result = command_idedata(MagicMock(), CORE.config)
|
||||
|
||||
assert result == 0
|
||||
mock_get.assert_called_once_with()
|
||||
assert json.loads(capsys.readouterr().out) == data
|
||||
|
||||
|
||||
def test_command_idedata_esp_idf_no_build_errors() -> None:
|
||||
"""Under ESP-IDF, a missing build (no idedata) returns an error, not a crash."""
|
||||
setup_core()
|
||||
CORE.toolchain = Toolchain.ESP_IDF
|
||||
|
||||
with patch("esphome.espidf.toolchain.get_idedata", return_value=None):
|
||||
result = command_idedata(MagicMock(), CORE.config)
|
||||
|
||||
assert result == 1
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user