From c9cf0ad92eb085ba2fccaedfda4e91abe6c029b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:37:36 -0400 Subject: [PATCH 01/20] Bump platformdirs from 4.12.3 to 4.12.4 (#20697) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a9dbaa3b8d7..fcbcb171246 100644 --- a/requirements.txt +++ b/requirements.txt @@ -31,7 +31,7 @@ smp==4.2.0; platform_system != "Darwin" or platform_machine != "x86_64" smp==4.1.0; platform_system == "Darwin" and platform_machine == "x86_64" requests==2.34.2 py7zr==1.1.3 -platformdirs==4.12.3 # native esp-idf toolchain global cache dir +platformdirs==4.12.4 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg From 576d3325febf66cdcd32e2637a330ef08b2f2c00 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:37:47 -1000 Subject: [PATCH 02/20] Bump astral-sh/setup-uv from 10.2.0 to 10.3.0 in /.github/actions/restore-python (#20700) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/actions/restore-python/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index affe8fc2d6b..4b8e10053ea 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -32,7 +32,7 @@ runs: # detects the activated venv via ``VIRTUAL_ENV`` so the venv layout # downstream jobs rely on is preserved. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can From c95c17c4e7bd0a948040a18bc1b8cc69195e844c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:17 -1000 Subject: [PATCH 03/20] Bump pyupgrade from 3.21.2 to 3.22.0 (#20698) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 727e8e2f9aa..e06494573c6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -36,7 +36,7 @@ repos: - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/asottile/pyupgrade - rev: v3.21.2 + rev: v3.22.0 hooks: - id: pyupgrade args: [--py312-plus] diff --git a/requirements_test.txt b/requirements_test.txt index 0f7d3762554..91ddfa7102d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,7 +1,7 @@ pylint==4.1.2 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -pyupgrade==3.21.2 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 426a4a2dce80c4bc823e77d298ba25d4d9ff5da2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:26 -1000 Subject: [PATCH 04/20] Bump filelock from 4.0.12 to 4.1.0 (#20696) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fcbcb171246..4ead538d8e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -33,7 +33,7 @@ requests==2.34.2 py7zr==1.1.3 platformdirs==4.12.4 # native esp-idf toolchain global cache dir ninja==1.13.2 # native host and esp8266 arduino toolchain build driver -filelock==4.0.12 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg +filelock==4.1.0 # inter-process locks (PlatformIO cache heal, git clone cache); >=3.32 for FileLock(fallback_to_soft=...), older versions silently drop the kwarg # esp-idf >= 5.0 requires this pyparsing >= 3.3.3 From 81e5b50087d7ab0fca84824a5092e65aabcf1266 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:38:35 -1000 Subject: [PATCH 05/20] Bump backports-zstd from 1.7.0 to 1.8.0 (#20695) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4ead538d8e1..ca8c489116d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ puremagic==2.2.0 ruamel.yaml==0.19.1 # dashboard_import ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 -backports.zstd==1.7.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 +backports.zstd==1.8.0; python_version < "3.14" # esphome.helpers.zstd_module; compression.zstd is in the standard library from 3.14 pillow==12.3.0 resvg-py==0.5.0 freetype-py==2.5.1 From d452dbce30fd4195c8003430b8abfdd249566f56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:38:39 +0000 Subject: [PATCH 06/20] Bump actions/download-artifact from 8.0.1 to 8.0.2 (#20703) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 5bcdb22cb86..f3457aad4ea 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -210,7 +210,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download image artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: compile-test-image.tar.zst - name: Load image diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 285d6869e12..fee9fea64e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1740,13 +1740,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: memory-analysis-pr path: ./memory-analysis diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index efccad9ad96..add0f80b243 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -195,7 +195,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Download digests - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: digests-* path: /tmp/digests From 7cb5906d2abaebf4e98bd44b0fa795623fa955ef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 01:38:42 +0000 Subject: [PATCH 07/20] Bump actions/upload-artifact from 7.0.1 to 7.0.2 (#20702) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci-docker.yml | 2 +- .github/workflows/ci.yml | 10 +++++----- .github/workflows/release.yml | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index d0bd1de9ef1..a516e868bef 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -97,7 +97,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: generated-proto-files path: | diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index f3457aad4ea..8546daa555f 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -128,7 +128,7 @@ jobs: - name: Upload compile-test image artifact if: matrix.os == 'ubuntu-24.04' && matrix.build_type == 'docker' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: # The tar is already compressed, so upload it as-is. archive: false # skips the redundant zip and makes the file name the artifact name diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fee9fea64e1..741a0d7deb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -477,7 +477,7 @@ jobs: # Tests that compare rendered output write the image they actually got here, so a # failure can be looked at without reproducing the whole build locally. if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: integration-test-artifacts-${{ matrix.bucket.name }} path: test_artifacts/ @@ -488,7 +488,7 @@ jobs: # script/update_integration_test_durations.py; only full matrix dev # runs produce usable data. if: github.ref == 'refs/heads/dev' && needs.determine-jobs.outputs.integration-run-all == 'true' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: junit-integration-${{ strategy.job-index }} path: junit-integration.xml @@ -528,7 +528,7 @@ jobs: script/check_import_time.py --check --har importtime.har - name: Upload waterfall HAR if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: import-time-waterfall path: importtime.har @@ -1647,7 +1647,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-target path: memory-analysis-target.json @@ -1710,7 +1710,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: memory-analysis-pr path: memory-analysis-pr.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index add0f80b243..c0991a6308f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -162,7 +162,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests From c5522abc7137eef0742020c2678c77d4ab26a7e9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:39:11 -0400 Subject: [PATCH 08/20] Bump github/codeql-action/analyze from 4.38.2 to 4.38.3 (#20699) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index da02a5dd09f..4d441c73c14 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -84,6 +84,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: "/language:${{matrix.language}}" From bc39d56bb48a9eb10b37d8e86194acf5c9eb3295 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:29 -1000 Subject: [PATCH 09/20] Bump CodSpeedHQ/action from 5.4.0 to 5.4.2 (#20705) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 741a0d7deb7..3e27a3aeb52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -626,7 +626,7 @@ jobs: apt-get install -y libc6-dbg - name: Run CodSpeed benchmarks - uses: CodSpeedHQ/action@c4fd08a3a159bd0cc208da1e0edf32b8c47d75e5 # v5.4.0 + uses: CodSpeedHQ/action@f842b9a3ffe213dbb81768fd7f7eeb4dfcf1c369 # v5.4.2 with: run: | . venv/bin/activate From 16134ee993ddb5f8e416f8c9fd8a63b72b5e325f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:38 -1000 Subject: [PATCH 10/20] Bump astral-sh/setup-uv from 10.2.0 to 10.3.0 (#20704) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci-api-proto.yml | 2 +- .github/workflows/ci.yml | 6 +++--- .github/workflows/sync-device-classes.yml | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index a516e868bef..9ca9d68d525 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -29,7 +29,7 @@ jobs: - name: Set up uv # ``--system`` (below) installs into the setup-python interpreter; # no venv is created or restored by this workflow. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull-request-only workflow: a save could never be shared and diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e27a3aeb52..d68bb93ec9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,7 @@ jobs: # detects the activated venv via ``VIRTUAL_ENV`` so downstream jobs # that ``. venv/bin/activate`` see an identical layout. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -438,7 +438,7 @@ jobs: - name: Set up uv # Only needed on cache miss to populate the venv. if: steps.cache-venv.outputs.cache-hit != 'true' - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can @@ -1448,7 +1448,7 @@ jobs: # install step (order-of-magnitude faster on cold boots, # with its own wheel cache). actions/setup-python still # provides the interpreter. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pull request saves land in per-PR scopes nothing else can diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index a0725374f92..ba0404011be 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -47,7 +47,7 @@ jobs: # setup-python interpreter so subsequent ``prek`` / # ``script/run-in-env.py`` steps find the deps without a # ``uv run`` prefix. - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0 + uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 with: enable-cache: true # Pin uv version so the action does not have to fetch the From 8fce95454ae731ec8fa55926c7b9e79e3f4fb25b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:39:49 -1000 Subject: [PATCH 11/20] Bump github/codeql-action/init from 4.38.2 to 4.38.3 (#20701) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4d441c73c14..caf8c6d8c8f 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -56,7 +56,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} From 59f55dbe9ee457948402e68b36c21e13e95a4b81 Mon Sep 17 00:00:00 2001 From: fredkotze <40389058+fredkotze@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:40:28 +1100 Subject: [PATCH 12/20] [epaper_spi] Add Waveshare 2.13in-G (JD79660) display models (#18141) Co-authored-by: J. Nick Koston --- .../components/epaper_spi/models/jd79660.py | 44 ++++++++++++++++++- .../epaper_spi/test.esp32-s3-idf.yaml | 34 ++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/esphome/components/epaper_spi/models/jd79660.py b/esphome/components/epaper_spi/models/jd79660.py index a70d3fe522e..783dc8953a5 100644 --- a/esphome/components/epaper_spi/models/jd79660.py +++ b/esphome/components/epaper_spi/models/jd79660.py @@ -87,6 +87,38 @@ jd79660.extend( ), ) +# Waveshare 2.13-G (V2 hardware/init revision, PCB marked "rev 2.1" or later) +# +# Vendor init derived from vendor sample code +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: panel is 122px wide, but vendor code declares the TRES register as a fixed +# 124 on this revision. width=124 is declared here (rather than 122) purely so the +# transferred bytes-per-row matches what the vendor init tells the IC to expect; the +# extra 2 columns fall outside the visible glass. The model width must equal the +# TRES value in initsequence, so overriding dimensions: in YAML brings the shear back. +# +# fmt: off +jd79660.extend( + "Waveshare-2.13in-G", + width=124, + height=250, + + initsequence=( + (0x61, 0x00, 0x7C, 0x00, 0xFA,), # TRES: 124x250 fixed (124 = TRES stride value) + (0xE9, 0x01,), + # Power On (0x04): Must be early part of init seq = Disabled later! + (0x04,), + ), + fast_update=( + (0xE0, 0x02,), + (0xE6, 0x5A,), + (0xA5,), + ), +) + # Waveshare 7.5-H # # Vendor init derived from vendor sample code @@ -130,7 +162,7 @@ jd79660.extend( # Note: busy pin uses LOW=busy, HIGH=idle. # # fmt: off -jd79660.extend( +gdey0213f51 = jd79660.extend( "goodisplay-gdey0213f51-2.13", width=128, height=250, @@ -154,3 +186,13 @@ jd79660.extend( (0x04,), ), ) + +# Waveshare 2.13-G (V1 hardware/init revision, older stock): the same panel and init as the Good +# Display entry above, byte for byte, so it only adds the name. Vendor sample: +# +# Compatible MIT license, see esphome/LICENSE file. +# +# Note: busy pin uses LOW=busy, HIGH=idle. Configure with inverted: true in YAML. +# Note: width=128 is the TRES value the init sequence programs, not the 122 visible columns; overriding +# dimensions: in YAML shears the image. Not tested on V1 hardware. +gdey0213f51.extend("Waveshare-2.13in-G-V1") diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index a2c343be836..f6091e5715c 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -87,6 +87,40 @@ display: number: GPIO4 inverted: true + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + + - platform: epaper_spi + spi_id: spi_bus + model: waveshare-2.13in-g-v1 + cs_pin: + allow_other_uses: true + number: GPIO5 + dc_pin: + allow_other_uses: true + number: GPIO17 + reset_pin: + allow_other_uses: true + number: GPIO16 + busy_pin: + allow_other_uses: true + number: GPIO4 + inverted: true + - platform: epaper_spi model: seeed-reterminal-e1002 - platform: epaper_spi From 9ee4314bc2642b9c4c53317a54ecf46e2facdca4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:41:30 -1000 Subject: [PATCH 13/20] Bump esptool from 5.4.0 to 5.5.0 (#20693) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ca8c489116d..5397959f63f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ tzlocal==5.4.4 # from time tzdata>=2026.5 # from time pyserial==3.5 platformio==6.2.0 -esptool==5.4.0 +esptool==5.5.0 click==8.5.0 aioesphomeapi==46.10.0 aiohappyeyeballs==2.7.1 # Happy Eyeballs for requests downloads; already pulled in by aioesphomeapi From 613973b4327fb933b7101c14740b350a1431abb5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:53:44 -0400 Subject: [PATCH 14/20] Bump ruff from 0.16.10 to 0.17.0 (#20694) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: esphome[bot] <115708604+esphome[bot]@users.noreply.github.com> Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- esphome/components/zigbee/zigbee_zephyr.py | 4 ++-- requirements_test.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e06494573c6..48bb3a69da4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.16.10 + rev: v0.17.0 hooks: # Run the linter. - id: ruff diff --git a/esphome/components/zigbee/zigbee_zephyr.py b/esphome/components/zigbee/zigbee_zephyr.py index d468c9881d0..f7da147b88f 100644 --- a/esphome/components/zigbee/zigbee_zephyr.py +++ b/esphome/components/zigbee/zigbee_zephyr.py @@ -1,4 +1,4 @@ -import datetime +import datetime as dt import random import esphome.codegen as cg @@ -176,7 +176,7 @@ async def _attr_to_code(config: ConfigType) -> None: basic_attrs.date_code, # Local build time, matching the esp32 implementation # (App.get_build_time() in C++). - datetime.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), + dt.datetime.now().astimezone().strftime("%Y%m%d %H%M%S"), ), zigbee_assign( basic_attrs.power_source, diff --git a/requirements_test.txt b/requirements_test.txt index 91ddfa7102d..65e8a63bfe9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ pylint==4.1.2 flake8==7.4.1 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py -ruff==0.16.10 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py +ruff==0.17.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py pyupgrade==3.22.0 # .pre-commit-config.yaml rev synced by script/sync_dependency_versions.py prek==0.5.5 # .github/workflows/ci.yml reads this pin yamlrocks==0.6.1 # used by script/sync_dependency_versions.py From 4b8af08155eebac3ae39da1412cb7b92f04bdf42 Mon Sep 17 00:00:00 2001 From: Keith Burzinski Date: Tue, 13 Oct 2026 20:57:59 -0500 Subject: [PATCH 15/20] [network] Notify listeners when the active interface changes (#20684) Co-authored-by: Claude Opus 5.5 --- esphome/components/mdns/__init__.py | 19 +++++++ esphome/components/mdns/mdns_component.cpp | 22 ++++++-- esphome/components/mdns/mdns_component.h | 21 +++++--- esphome/components/mdns/mdns_esp32.cpp | 5 +- esphome/components/mdns/mdns_rp2.cpp | 3 +- esphome/components/network/__init__.py | 33 +++++++++++- .../components/network/network_component.cpp | 22 +++++--- .../components/network/network_component.h | 32 ++++++++++++ esphome/core/defines.h | 6 +++ .../config/priority_ethernet_first_api.yaml | 28 ++++++++++ .../network/config/priority_rp2040_api.yaml | 26 ++++++++++ .../network/test_active_interface.py | 52 +++++++++++++++++++ 12 files changed, 247 insertions(+), 22 deletions(-) create mode 100644 tests/component_tests/network/config/priority_ethernet_first_api.yaml create mode 100644 tests/component_tests/network/config/priority_rp2040_api.yaml create mode 100644 tests/component_tests/network/test_active_interface.py diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index e49a3897fee..bc431599248 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -3,7 +3,9 @@ from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option from esphome.config_helpers import filter_source_files_from_platform, get_logger_level import esphome.config_validation as cv from esphome.const import ( + CONF_API, CONF_DISABLED, + CONF_ETHERNET, CONF_ID, CONF_OPENTHREAD, CONF_PORT, @@ -285,6 +287,23 @@ async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) + # With both wifi and ethernet, the _esphomelib "network" TXT value follows the + # connected interface. OpenThread hands its services to the SRP client once, + # so it keeps the fixed value. + if ( + CONF_API in CORE.config + and CONF_WIFI in CORE.config + and CONF_ETHERNET in CORE.config + and CONF_OPENTHREAD not in CORE.config + ): + from esphome.components import network + + cg.add_define("USE_MDNS_DYNAMIC_NETWORK_TXT") + # Where the network component tracks the default route (ESP32), follow + # it; otherwise mdns checks the connected interface every second. + if await network.register_active_interface_listener(var): + cg.add_define("USE_MDNS_ACTIVE_INTERFACE_LISTENER") + for service in config[CONF_SERVICES]: txt_records = [ await _mdns_txt_record_templated(var, txt_key, txt_value) diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 0e363852688..32a84671fea 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -62,12 +62,9 @@ const char *MDNSComponent::connected_network_() const { return nullptr; } -void MDNSComponent::update_network_txt_() { - if (this->network_txt_ == nullptr) - return; +void MDNSComponent::update_network_txt_(const char *network) { // With no interface connected, keep the last value instead of flapping to wifi - const char *network = this->connected_network_(); - if (network == nullptr || network == this->network_txt_) + if (this->network_txt_ == nullptr || network == nullptr || network == this->network_txt_) return; if (!this->publish_network_txt_(network)) { // The _esphomelib service is missing, so a retry would fail too @@ -80,6 +77,21 @@ void MDNSComponent::update_network_txt_() { this->network_record_->value = MDNS_STR(network); #endif } + +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +void MDNSComponent::on_active_interface(network::NetworkInterface interface) { + switch (interface) { + case network::NetworkInterface::NETWORK_INTERFACE_ETHERNET: + this->update_network_txt_(NETWORK_ETHERNET); + break; + case network::NetworkInterface::NETWORK_INTERFACE_WIFI: + this->update_network_txt_(NETWORK_WIFI); + break; + default: + break; + } +} +#endif #endif void MDNSComponent::setup_buffers_and_register_(PlatformRegisterFn platform_register) { diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index f2ea7c19149..06d10d2d818 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -32,10 +32,11 @@ #define USE_MDNS_DEVICE_INFO_TXT #endif -// With both wifi and ethernet, the _esphomelib "network" TXT value follows the connected interface. -// OpenThread hands its services to the SRP client once, so it keeps the fixed value. -#if defined(USE_API) && defined(USE_WIFI) && defined(USE_ETHERNET) && !defined(USE_OPENTHREAD) -#define USE_MDNS_DYNAMIC_NETWORK_TXT +// USE_MDNS_DYNAMIC_NETWORK_TXT (wifi + ethernet + api): the _esphomelib "network" TXT value follows the +// connected interface. With USE_MDNS_ACTIVE_INTERFACE_LISTENER (ESP32) the network component reports +// default route changes; otherwise the value is checked every NETWORK_TXT_CHECK_INTERVAL_MS. +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER +#include "esphome/components/network/network_component.h" #endif namespace esphome::mdns { @@ -79,6 +80,10 @@ class MDNSComponent final : public Component , public ethernet::EthernetIPStateListener #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + , + public network::ActiveInterfaceListener +#endif { public: void setup() override; @@ -140,6 +145,10 @@ class MDNSComponent final : public Component } #endif +#ifdef USE_MDNS_ACTIVE_INTERFACE_LISTENER + void on_active_interface(network::NetworkInterface interface) override; +#endif + #ifdef USE_MDNS_EVENT_DRIVEN_POLLING void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, const network::IPAddress &dns2) override; @@ -160,8 +169,8 @@ class MDNSComponent final : public Component static constexpr uint32_t NETWORK_TXT_CHECK_INTERVAL_MS = 1000; /// The first connected interface in network: priority order ("ethernet" or "wifi"), else nullptr. const char *connected_network_() const; - /// Check the connected interface and publish a changed "network" TXT value. - void update_network_txt_(); + /// Publish network as the "network" TXT value if it changed; nullptr keeps the current value. + void update_network_txt_(const char *network); /// Platform-specific: replace the "network" TXT value of the registered _esphomelib service. bool publish_network_txt_(const char *network); /// Advertised value; nullptr until the _esphomelib service is registered, and after a failed publish. diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index afa30eb5f30..d84a2551202 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -103,8 +103,9 @@ bool MDNSComponent::publish_network_txt_(const char *network) { void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); -#ifdef USE_MDNS_DYNAMIC_NETWORK_TXT - this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); +#if defined(USE_MDNS_DYNAMIC_NETWORK_TXT) && !defined(USE_MDNS_ACTIVE_INTERFACE_LISTENER) + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); #endif } diff --git a/esphome/components/mdns/mdns_rp2.cpp b/esphome/components/mdns/mdns_rp2.cpp index 603f3f0417c..f27b6b49812 100644 --- a/esphome/components/mdns/mdns_rp2.cpp +++ b/esphome/components/mdns/mdns_rp2.cpp @@ -64,7 +64,8 @@ bool MDNSComponent::publish_network_txt_(const char *network) { void MDNSComponent::setup() { #ifdef USE_MDNS_DYNAMIC_NETWORK_TXT // No-op until the first IP-up event registers the services - this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, [this]() { this->update_network_txt_(); }); + this->set_interval(NETWORK_TXT_CHECK_INTERVAL_MS, + [this]() { this->update_network_txt_(this->connected_network_()); }); #endif // arduino-pico stubs out LwipIntf::stateUpCB (the netif status callback LEAmDNS uses // on ESP8266 for auto-restart), so we must drive begin()/notifyAPChange() from our diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 1c08202d411..89835128fe2 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -100,6 +100,37 @@ if ( network_ns = cg.esphome_ns.namespace("network") NetworkComponent = network_ns.class_("NetworkComponent", cg.Component) + +_request_active_interface_listener_slot = cg.slot_counter( + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT" +) + + +def has_active_interface() -> bool: + """Return True when NetworkComponent tracks the interface holding the default route. + + That is ESP32 with more than one interface in 'network: priority:', the same + condition that emits USE_NETWORK_DEFAULT_ROUTE. + """ + return CORE.is_esp32 and len(CORE.config.get(DOMAIN, {}).get(CONF_PRIORITY, [])) > 1 + + +async def register_active_interface_listener(listener: cg.MockObj) -> bool: + """Register an ActiveInterfaceListener with the NetworkComponent. + + The listener is notified from the main loop when the interface holding the + default route changes. Returns False, and registers nothing, when + has_active_interface() is False; the caller then needs another way to + follow interface changes. + """ + if not has_active_interface(): + return False + _request_active_interface_listener_slot() + network = await cg.get_variable(CORE.config[DOMAIN][CONF_ID]) + cg.add(network.add_active_interface_listener(listener)) + return True + + IPAddress = network_ns.class_("IPAddress") @@ -402,7 +433,7 @@ async def to_code(config: ConfigType) -> None: # frameworks build from source. # The ethernet/wifi-only assumption behind the arbitration is enforced in # _final_validate() so a future unsupported type fails as a config error. - if len(priority_list) > 1 and CORE.is_esp32: + if has_active_interface(): cg.add_define("USE_NETWORK_DEFAULT_ROUTE") # Have lwIP switch to the DNS servers of the netif that owns the # default route whenever the arbitration changes it. diff --git a/esphome/components/network/network_component.cpp b/esphome/components/network/network_component.cpp index fad7a385fdd..ec6959c2d00 100644 --- a/esphome/components/network/network_component.cpp +++ b/esphome/components/network/network_component.cpp @@ -78,16 +78,24 @@ void NetworkComponent::loop() { // USE_NETWORK_PRIMARY_INTERFACE_WIFI is emitted for a wifi-first priority list; // it selects the reported address in util.cpp and doubles as the route-order // pivot here — the two uses must stay in sync. - esp_netif_t *best; + esp_netif_t *wifi = connected_wifi_netif(); + esp_netif_t *ethernet = connected_ethernet_netif(); #ifdef USE_NETWORK_PRIMARY_INTERFACE_WIFI - best = connected_wifi_netif(); - if (best == nullptr) - best = connected_ethernet_netif(); + esp_netif_t *best = wifi != nullptr ? wifi : ethernet; #else - best = connected_ethernet_netif(); - if (best == nullptr) - best = connected_wifi_netif(); + esp_netif_t *best = ethernet != nullptr ? ethernet : wifi; #endif + NetworkInterface active = NetworkInterface::NETWORK_INTERFACE_NONE; + if (best != nullptr) { + active = best == ethernet ? NetworkInterface::NETWORK_INTERFACE_ETHERNET : NetworkInterface::NETWORK_INTERFACE_WIFI; + } + if (active != this->active_interface_) { + this->active_interface_ = active; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + for (auto *listener : this->active_interface_listeners_) + listener->on_active_interface(active); +#endif + } if (best == nullptr) { // Forget the last winner: stopping its netif cleared lwIP's default route and // IDF's manual override suppresses re-election, so reconnect must re-assert it. diff --git a/esphome/components/network/network_component.h b/esphome/components/network/network_component.h index 870530974ea..eff08524a6f 100644 --- a/esphome/components/network/network_component.h +++ b/esphome/components/network/network_component.h @@ -4,12 +4,32 @@ #include "esphome/core/component.h" #ifdef USE_NETWORK_DEFAULT_ROUTE +#include "esphome/core/helpers.h" // Forward declaration matching esp_netif's own typedef; avoids pulling esp_netif.h // into this header. using esp_netif_t = struct esp_netif_obj; #endif namespace esphome::network { + +#ifdef USE_NETWORK_DEFAULT_ROUTE +/// The interface holding the default route. Lists only the interfaces loop() arbitrates +/// (ARBITRATED_NETWORK_TYPES in __init__.py); extend both together. +enum class NetworkInterface : uint8_t { + NETWORK_INTERFACE_NONE, + NETWORK_INTERFACE_ETHERNET, + NETWORK_INTERFACE_WIFI, +}; + +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT +/// Notified from the main loop when the interface holding the default route changes. +class ActiveInterfaceListener { + public: + virtual void on_active_interface(NetworkInterface interface) = 0; +}; +#endif +#endif + class NetworkComponent final : public Component { public: void setup() override; @@ -20,6 +40,14 @@ class NetworkComponent final : public Component { #ifdef USE_NETWORK_DEFAULT_ROUTE void loop() override; + /// The first connected interface in network: priority order; NONE while none is connected. + NetworkInterface get_active_interface() const { return this->active_interface_; } +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + void add_active_interface_listener(ActiveInterfaceListener *listener) { + this->active_interface_listeners_.push_back(listener); + } +#endif + protected: // Verify-lwIP-route interval for the settled state; keeps the global lwIP core // mutex off the hot loop path. @@ -27,6 +55,10 @@ class NetworkComponent final : public Component { // Last netif this component made the default; avoids redundant esp_netif calls. esp_netif_t *default_netif_{nullptr}; uint32_t last_route_check_{0}; +#ifdef NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT + StaticVector active_interface_listeners_; +#endif + NetworkInterface active_interface_{NetworkInterface::NETWORK_INTERFACE_NONE}; #endif }; } // namespace esphome::network diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 8d243043bde..1fa417d7afd 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -217,6 +217,7 @@ #define TCA8418_LISTENER_COUNT 1 #define USE_MEDIA_PLAYER #define USE_MEDIA_SOURCE +#define NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT 1 #define USE_NETWORK #define USE_NETWORK_DEFAULT_ROUTE #define USE_NETWORK_PRIMARY_INTERFACE_WIFI @@ -567,6 +568,10 @@ #endif #ifndef USE_OPENTHREAD #define USE_MDNS_SUPPORTS_ENABLE_DISABLE +#ifdef USE_ETHERNET +#define USE_MDNS_ACTIVE_INTERFACE_LISTENER +#define USE_MDNS_DYNAMIC_NETWORK_TXT +#endif #endif #endif @@ -647,6 +652,7 @@ #define USE_ETHERNET_SPI #endif #define USE_ETHERNET_W5500 +#define USE_MDNS_DYNAMIC_NETWORK_TXT #define USE_WIFI_IP_STATE_LISTENERS #define ESPHOME_WIFI_IP_STATE_LISTENERS 2 #define USE_ETHERNET_IP_STATE_LISTENERS diff --git a/tests/component_tests/network/config/priority_ethernet_first_api.yaml b/tests/component_tests/network/config/priority_ethernet_first_api.yaml new file mode 100644 index 00000000000..31ff65b5bc2 --- /dev/null +++ b/tests/component_tests/network/config/priority_ethernet_first_api.yaml @@ -0,0 +1,28 @@ +esphome: + name: test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 19 + mosi_pin: 21 + miso_pin: 23 + cs_pin: 18 + interrupt_pin: 36 + reset_pin: 22 + clock_speed: 10Mhz + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/config/priority_rp2040_api.yaml b/tests/component_tests/network/config/priority_rp2040_api.yaml new file mode 100644 index 00000000000..a74442032da --- /dev/null +++ b/tests/component_tests/network/config/priority_rp2040_api.yaml @@ -0,0 +1,26 @@ +esphome: + name: test + +rp2: + board: rpipicow + +wifi: + ssid: "test_ssid" + password: "test_password" + +ethernet: + type: W5500 + clk_pin: 18 + mosi_pin: 19 + miso_pin: 16 + cs_pin: 17 + interrupt_pin: 21 + reset_pin: 20 + mac_address: "02:AA:BB:CC:DD:01" + +network: + priority: + - ethernet + - wifi + +api: diff --git a/tests/component_tests/network/test_active_interface.py b/tests/component_tests/network/test_active_interface.py new file mode 100644 index 00000000000..2509f24ffb6 --- /dev/null +++ b/tests/component_tests/network/test_active_interface.py @@ -0,0 +1,52 @@ +"""mDNS follows the interface holding the default route through an +ActiveInterfaceListener, only where NetworkComponent tracks that interface +(ESP32 with more than one interface) and the network TXT record exists.""" + +from collections.abc import Callable +from pathlib import Path + +import pytest + +from esphome.core import CORE + +DYNAMIC_TXT_DEFINE = "USE_MDNS_DYNAMIC_NETWORK_TXT" +LISTENER_DEFINES = { + "USE_MDNS_ACTIVE_INTERFACE_LISTENER", + "NETWORK_ACTIVE_INTERFACE_LISTENER_COUNT", +} + + +def _defines() -> set[str]: + return {define.name for define in CORE.defines} + + +def test_mdns_registers_on_esp32_with_wifi_ethernet_and_api( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], +) -> None: + main_cpp = generate_main(component_config_path("priority_ethernet_first_api.yaml")) + + assert "add_active_interface_listener(" in main_cpp + assert _defines() >= LISTENER_DEFINES | {DYNAMIC_TXT_DEFINE} + + +@pytest.mark.parametrize( + ("fixture", "dynamic_txt"), + [ + # No api means no _esphomelib service, so no network TXT record + pytest.param("priority_ethernet_first.yaml", False, id="esp32_without_api"), + # No route arbitration on RP2; mDNS keeps its periodic check there + pytest.param("priority_rp2040_api.yaml", True, id="rp2040"), + ], +) +def test_mdns_does_not_register( + generate_main: Callable[[str | Path], str], + component_config_path: Callable[[str], Path], + fixture: str, + dynamic_txt: bool, +) -> None: + main_cpp = generate_main(component_config_path(fixture)) + + assert "add_active_interface_listener(" not in main_cpp + assert not LISTENER_DEFINES & _defines() + assert (DYNAMIC_TXT_DEFINE in _defines()) is dynamic_txt From f45e6f0bf47a818b8bcb4afff47e2a8a505306b4 Mon Sep 17 00:00:00 2001 From: Andres Signes Grimalt Date: Wed, 14 Oct 2026 04:03:18 +0200 Subject: [PATCH 16/20] [midea] Add display_control and send the display toggle over UART without a transmitter (#18671) Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/midea/air_conditioner.cpp | 34 +++++++++++++++---- esphome/components/midea/air_conditioner.h | 9 +++++ esphome/components/midea/climate.py | 24 +++++++++++++ tests/components/midea/common-uart_only.yaml | 6 ++++ tests/components/midea/common.yaml | 1 + .../midea/test-uart_only.esp32-idf.yaml | 8 +++++ 6 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 tests/components/midea/common-uart_only.yaml create mode 100644 tests/components/midea/test-uart_only.esp32-idf.yaml diff --git a/esphome/components/midea/air_conditioner.cpp b/esphome/components/midea/air_conditioner.cpp index 24bbfe76b0b..fce8ab99a9e 100644 --- a/esphome/components/midea/air_conditioner.cpp +++ b/esphome/components/midea/air_conditioner.cpp @@ -127,6 +127,19 @@ ClimateTraits AirConditioner::traits() { return traits; } +#ifdef USE_REMOTE_TRANSMITTER +static const LogString *display_control_str(DisplayControl control) { + switch (control) { + case DisplayControl::DISPLAY_CONTROL_UART: + return LOG_STR("UART"); + case DisplayControl::DISPLAY_CONTROL_IR: + return LOG_STR("IR"); + default: + return LOG_STR("AUTO"); + } +} +#endif + void AirConditioner::dump_config() { ESP_LOGCONFIG(Constants::TAG, "MideaDongle:\n" @@ -135,7 +148,10 @@ void AirConditioner::dump_config() { " [x] Request attempts: %d", this->base_.getPeriod(), this->base_.getTimeout(), this->base_.getNumAttempts()); #ifdef USE_REMOTE_TRANSMITTER - ESP_LOGCONFIG(Constants::TAG, " [x] Using RemoteTransmitter"); + ESP_LOGCONFIG(Constants::TAG, + " [x] Using RemoteTransmitter\n" + " [x] Display control: %s", + LOG_STR_ARG(display_control_str(this->display_control_))); #endif if (this->base_.getAutoconfStatus() == dudanov::midea::AUTOCONF_OK) { this->base_.getCapabilities().dump(); @@ -183,16 +199,20 @@ void AirConditioner::do_swing_step() { } void AirConditioner::do_display_toggle() { - if (this->base_.getCapabilities().supportLightControl()) { - this->base_.displayToggle(); - } else { #ifdef USE_REMOTE_TRANSMITTER + // AUTO uses IR unless the appliance reports light control over UART. Without a transmitter + // built in, every setting sends the UART request: Mundoclima, Rotenso and others accept it + // without advertising it in the 0xB5 capabilities report. + const bool use_ir = this->display_control_ == DisplayControl::DISPLAY_CONTROL_IR || + (this->display_control_ == DisplayControl::DISPLAY_CONTROL_AUTO && + !this->base_.getCapabilities().supportLightControl()); + if (use_ir) { IrSpecialData data(0x08); this->transmitter_.transmit(data); -#else - ESP_LOGW(Constants::TAG, "Action needs remote_transmitter component"); -#endif + return; } +#endif + this->base_.displayToggle(); } } // namespace esphome::midea::ac diff --git a/esphome/components/midea/air_conditioner.h b/esphome/components/midea/air_conditioner.h index 9977d2088ff..607027d9962 100644 --- a/esphome/components/midea/air_conditioner.h +++ b/esphome/components/midea/air_conditioner.h @@ -21,12 +21,20 @@ using climate::ClimateModeMask; using climate::ClimateSwingModeMask; using climate::ClimatePresetMask; +// How the display toggle reaches the appliance; the AUTO rules are in do_display_toggle() +enum class DisplayControl : uint8_t { + DISPLAY_CONTROL_UART, // always the UART request + DISPLAY_CONTROL_AUTO, // UART when the appliance reports it or no transmitter is built in, else IR + DISPLAY_CONTROL_IR, // always the IR remote, needs a transmitter +}; + class AirConditioner final : public ApplianceBase, public climate::Climate { public: void dump_config() override; void set_outdoor_temperature_sensor(Sensor *sensor) { this->outdoor_sensor_ = sensor; } void set_humidity_setpoint_sensor(Sensor *sensor) { this->humidity_sensor_ = sensor; } void set_power_sensor(Sensor *sensor) { this->power_sensor_ = sensor; } + void set_display_control(DisplayControl control) { this->display_control_ = control; } void on_status_change() override; /* ############### */ @@ -54,6 +62,7 @@ class AirConditioner final : public ApplianceBase ConfigType: + """`display_control: IR` needs a `remote_transmitter` to send through; UART and AUTO do not.""" + if config.get(CONF_DISPLAY_CONTROL) == "IR" and CONF_TRANSMITTER_ID not in config: + raise cv.Invalid( + f"'{CONF_DISPLAY_CONTROL}: IR' requires '{CONF_TRANSMITTER_ID}' to be set" + ) + return config + CONFIG_SCHEMA = cv.All( climate.climate_schema(AirConditioner) @@ -100,6 +121,7 @@ CONFIG_SCHEMA = cv.All( ), cv.Optional(CONF_BEEPER, default=False): cv.boolean, cv.Optional(CONF_AUTOCONF, default=True): cv.boolean, + cv.Optional(CONF_DISPLAY_CONTROL, default="AUTO"): validate_display_control, cv.Optional(CONF_SUPPORTED_MODES): cv.ensure_list(validate_modes), cv.Optional(CONF_SUPPORTED_SWING_MODES): cv.ensure_list( validate_swing_modes @@ -134,6 +156,7 @@ CONFIG_SCHEMA = cv.All( ) .extend(uart.UART_DEVICE_SCHEMA) .extend(cv.COMPONENT_SCHEMA), + validate_display_control_with_transmitter, cv.only_on( [ PLATFORM_ESP32, @@ -204,6 +227,7 @@ async def to_code(config): cg.add(var.set_transmitter(transmitter_)) cg.add(var.set_beeper_feedback(config[CONF_BEEPER])) cg.add(var.set_autoconf(config[CONF_AUTOCONF])) + cg.add(var.set_display_control(config[CONF_DISPLAY_CONTROL])) if CONF_SUPPORTED_MODES in config: cg.add(var.set_supported_modes(config[CONF_SUPPORTED_MODES])) if CONF_SUPPORTED_SWING_MODES in config: diff --git a/tests/components/midea/common-uart_only.yaml b/tests/components/midea/common-uart_only.yaml new file mode 100644 index 00000000000..d05df221732 --- /dev/null +++ b/tests/components/midea/common-uart_only.yaml @@ -0,0 +1,6 @@ +climate: + - platform: midea + id: midea_uart_only + name: Midea Climate UART Only + autoconf: true + display_control: UART diff --git a/tests/components/midea/common.yaml b/tests/components/midea/common.yaml index 25fc2debcd2..bade07be43f 100644 --- a/tests/components/midea/common.yaml +++ b/tests/components/midea/common.yaml @@ -33,6 +33,7 @@ climate: timeout: 2s beeper: false autoconf: true + display_control: AUTO visual: min_temperature: 17 °C max_temperature: 30 °C diff --git a/tests/components/midea/test-uart_only.esp32-idf.yaml b/tests/components/midea/test-uart_only.esp32-idf.yaml new file mode 100644 index 00000000000..ce09bbb7b7d --- /dev/null +++ b/tests/components/midea/test-uart_only.esp32-idf.yaml @@ -0,0 +1,8 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + midea: !include common-uart_only.yaml + +# The MideaUART library includes esp_wifi.h on ESP-IDF +wifi: + ssid: MySSID + password: password1 From 7b591b26d692b5b06e0f8927123831dacd2bed2e Mon Sep 17 00:00:00 2001 From: willumpie82 Date: Wed, 14 Oct 2026 04:19:58 +0200 Subject: [PATCH 17/20] [sy6970] Service the I2C watchdog so charge/LED settings persist (#19424) Co-authored-by: Willem Oldemans <> Co-authored-by: Claude Sonnet 5 Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston --- esphome/components/sy6970/__init__.py | 14 ++++++ esphome/components/sy6970/sy6970.cpp | 70 ++++++++++++++++++++++++++- esphome/components/sy6970/sy6970.h | 20 +++++++- tests/components/sy6970/common.yaml | 1 + 4 files changed, 101 insertions(+), 4 deletions(-) diff --git a/esphome/components/sy6970/__init__.py b/esphome/components/sy6970/__init__.py index 90bd9f46911..c1c770aa591 100644 --- a/esphome/components/sy6970/__init__.py +++ b/esphome/components/sy6970/__init__.py @@ -17,12 +17,22 @@ CONF_CHARGE_CURRENT = "charge_current" CONF_PRECHARGE_CURRENT = "precharge_current" CONF_CHARGE_ENABLED = "charge_enabled" CONF_ENABLE_ADC = "enable_adc" +CONF_I2C_WATCHDOG_TIMEOUT = "i2c_watchdog_timeout" sy6970_ns = cg.esphome_ns.namespace("sy6970") SY6970Component = sy6970_ns.class_( "SY6970Component", cg.PollingComponent, i2c.I2CDevice ) SY6970Listener = sy6970_ns.class_("SY6970Listener") +I2CWatchdogTimeout = sy6970_ns.enum("I2CWatchdogTimeout") + +# REG07[5:4]; the component kicks the watchdog itself, so the chip's 40S default can stay on +I2C_WATCHDOG_TIMEOUTS = { + "DISABLED": I2CWatchdogTimeout.I2C_WATCHDOG_DISABLED, + "40S": I2CWatchdogTimeout.I2C_WATCHDOG_40S, + "80S": I2CWatchdogTimeout.I2C_WATCHDOG_80S, + "160S": I2CWatchdogTimeout.I2C_WATCHDOG_160S, +} CONFIG_SCHEMA = ( cv.Schema( @@ -43,6 +53,9 @@ CONFIG_SCHEMA = ( ), cv.Optional(CONF_CHARGE_ENABLED, default=True): cv.boolean, cv.Optional(CONF_ENABLE_ADC, default=True): cv.boolean, + cv.Optional(CONF_I2C_WATCHDOG_TIMEOUT, default="40S"): cv.enum( + I2C_WATCHDOG_TIMEOUTS, upper=True + ), } ) .extend(cv.polling_component_schema("5s")) @@ -60,6 +73,7 @@ async def to_code(config: ConfigType) -> None: config[CONF_PRECHARGE_CURRENT], config[CONF_CHARGE_ENABLED], config[CONF_ENABLE_ADC], + config[CONF_I2C_WATCHDOG_TIMEOUT], ) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/sy6970/sy6970.cpp b/esphome/components/sy6970/sy6970.cpp index 3f381002cbd..5e1611ea558 100644 --- a/esphome/components/sy6970/sy6970.cpp +++ b/esphome/components/sy6970/sy6970.cpp @@ -6,6 +6,25 @@ namespace esphome::sy6970 { ESPHOME_LOG_TAG(TAG, "sy6970"); +static const LogString *i2c_watchdog_timeout_to_string(I2CWatchdogTimeout timeout) { + switch (timeout) { + case I2C_WATCHDOG_DISABLED: + return LOG_STR("Disabled"); + case I2C_WATCHDOG_40S: + return LOG_STR("40s"); + case I2C_WATCHDOG_80S: + return LOG_STR("80s"); + case I2C_WATCHDOG_160S: + return LOG_STR("160s"); + default: + return LOG_STR("Unknown"); + } +} + +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_ID = 0; +// Well under the 40 s default, so a failed kick or a timeout the chip did not accept still leaves margin +static constexpr uint32_t I2C_WATCHDOG_KICK_INTERVAL_MS = 10000; + bool SY6970Component::read_all_registers_() { // Read all registers from 0x00 to 0x14 in one transaction (21 bytes) // This includes unused registers 0x0F, 0x10 for performance @@ -51,6 +70,11 @@ void SY6970Component::setup() { ESP_LOGW(TAG, "Unexpected chip ID: 0x%02X (expected 0x00)", chip_id); } + // Service the watchdog first, so the settings below are not reverted while they are written + ESP_LOGV(TAG, "Setting I2C watchdog timeout to %s", + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); + this->set_i2c_watchdog_timeout(this->i2c_watchdog_timeout_); + // Apply configuration options (all have defaults now) ESP_LOGV(TAG, "Setting LED enabled to %s", ONOFF(this->led_enabled_)); this->set_led_enabled(this->led_enabled_); @@ -85,9 +109,11 @@ void SY6970Component::dump_config() { " Charge Current: %u mA\n" " Precharge Current: %u mA\n" " Charge Enabled: %s\n" - " ADC Enabled: %s", + " ADC Enabled: %s\n" + " I2C Watchdog Timeout: %s", ONOFF(this->led_enabled_), this->input_current_limit_, this->charge_voltage_, this->charge_current_, - this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_)); + this->precharge_current_, ONOFF(this->charge_enabled_), ONOFF(this->enable_adc_), + LOG_STR_ARG(i2c_watchdog_timeout_to_string(this->i2c_watchdog_timeout_))); LOG_I2C_DEVICE(this); LOG_UPDATE_INTERVAL(this); if (this->is_failed()) { @@ -198,4 +224,44 @@ void SY6970Component::set_enable_adc_measure(bool enabled) { this->update_register_(SY6970_REG_ADC_CONTROL, 0xC0, enabled ? 0xC0 : 0x00); } +void SY6970Component::set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout) { + if (this->is_failed()) + return; + + if (timeout != I2C_WATCHDOG_DISABLED) { + // Changing the timeout does not restart the chip's timer, which may be close to expiring + this->reset_i2c_watchdog(); + } + + // REG07 bits 5:4 (WATCHDOG[1:0]) + bool written = this->update_register_(SY6970_REG_TIMER_CONTROL, 0x30, static_cast(timeout) << 4); + if (!written) { + ESP_LOGW(TAG, "Failed to set I2C watchdog timeout to %s", LOG_STR_ARG(i2c_watchdog_timeout_to_string(timeout))); + } + + if (timeout == I2C_WATCHDOG_DISABLED && written) { + this->i2c_watchdog_timeout_ = timeout; + this->cancel_interval(I2C_WATCHDOG_KICK_INTERVAL_ID); + return; + } + if (timeout != I2C_WATCHDOG_DISABLED) { + this->i2c_watchdog_timeout_ = timeout; + } + // Keeps kicking after a failed write too: the chip may still be on its 40 s default + this->set_interval(I2C_WATCHDOG_KICK_INTERVAL_ID, I2C_WATCHDOG_KICK_INTERVAL_MS, + [this]() { this->reset_i2c_watchdog(); }); +} + +bool SY6970Component::reset_i2c_watchdog() { + if (this->is_failed()) + return false; + + // WD_RST, self-clearing + if (!this->update_register_(SY6970_REG_SYS_CONTROL, 0x40, 0x40)) { + ESP_LOGW(TAG, "I2C watchdog reset failed, settings may revert"); + return false; + } + return true; +} + } // namespace esphome::sy6970 diff --git a/esphome/components/sy6970/sy6970.h b/esphome/components/sy6970/sy6970.h index 06f0615ab4b..b9c4f1d4870 100644 --- a/esphome/components/sy6970/sy6970.h +++ b/esphome/components/sy6970/sy6970.h @@ -62,6 +62,15 @@ enum ChargeStatus { CHARGE_STATUS_CHARGE_DONE = 3, }; +// REG07[5:4]. When the watchdog elapses the chip reverts CHG_CONFIG and STAT_DIS to their defaults, +// so an enabled watchdog is kicked on its own interval. +enum I2CWatchdogTimeout { + I2C_WATCHDOG_DISABLED = 0, + I2C_WATCHDOG_40S = 1, + I2C_WATCHDOG_80S = 2, + I2C_WATCHDOG_160S = 3, +}; + // Structure to hold all register data read in one transaction struct SY6970Data { uint8_t registers[21]; // Registers 0x00-0x14 (includes unused 0x0F, 0x10) @@ -76,14 +85,16 @@ class SY6970Listener { class SY6970Component final : public PollingComponent, public i2c::I2CDevice { public: SY6970Component(bool led_enabled, uint16_t input_current_limit, uint16_t charge_voltage, uint16_t charge_current, - uint16_t precharge_current, bool charge_enabled, bool enable_adc) + uint16_t precharge_current, bool charge_enabled, bool enable_adc, + I2CWatchdogTimeout i2c_watchdog_timeout) : led_enabled_(led_enabled), input_current_limit_(input_current_limit), charge_voltage_(charge_voltage), charge_current_(charge_current), precharge_current_(precharge_current), charge_enabled_(charge_enabled), - enable_adc_(enable_adc) {} + enable_adc_(enable_adc), + i2c_watchdog_timeout_(i2c_watchdog_timeout) {} void setup() override; void dump_config() override; void update() override; @@ -99,6 +110,10 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { void set_charge_enabled(bool enabled); void set_led_enabled(bool enabled); void set_enable_adc_measure(bool enabled = true); + void set_i2c_watchdog_timeout(I2CWatchdogTimeout timeout); + + /// Kick the I2C watchdog (REG03 bit 6); runs on its own interval while enabled. Returns false when not written. + bool reset_i2c_watchdog(); protected: bool read_all_registers_(); @@ -116,6 +131,7 @@ class SY6970Component final : public PollingComponent, public i2c::I2CDevice { uint16_t precharge_current_; bool charge_enabled_; bool enable_adc_; + I2CWatchdogTimeout i2c_watchdog_timeout_; }; } // namespace esphome::sy6970 diff --git a/tests/components/sy6970/common.yaml b/tests/components/sy6970/common.yaml index 53699fe6fb8..74f57f28210 100644 --- a/tests/components/sy6970/common.yaml +++ b/tests/components/sy6970/common.yaml @@ -9,6 +9,7 @@ sy6970: precharge_current: 128 charge_enabled: true enable_adc: true + i2c_watchdog_timeout: 80S update_interval: 5s sensor: From bb1119d83f5ba0bb4b932aed88a3cf824a812d95 Mon Sep 17 00:00:00 2001 From: Christopher Reimer Date: Wed, 14 Oct 2026 04:34:03 +0200 Subject: [PATCH 18/20] [esp32_can] Support 20 kbit/s on ESP32 revision 2 or later (#20426) --- esphome/components/esp32_can/canbus.py | 22 ++++++++ esphome/components/esp32_can/esp32_can.cpp | 4 ++ .../esp32_can/test-20kbps.esp32-idf.yaml | 11 ++++ tests/unit_tests/components/test_esp32_can.py | 54 +++++++++++++++++++ 4 files changed, 91 insertions(+) create mode 100644 tests/components/esp32_can/test-20kbps.esp32-idf.yaml create mode 100644 tests/unit_tests/components/test_esp32_can.py diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index 2713bae241a..ae299370cd7 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -6,6 +6,8 @@ import esphome.codegen as cg from esphome.components import canbus from esphome.components.canbus import CONF_BIT_RATE, CanbusComponent, CanSpeed from esphome.components.esp32 import ( + CONF_MINIMUM_CHIP_REVISION, + KEY_ESP32, VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C5, @@ -20,6 +22,8 @@ from esphome.components.esp32 import ( ) import esphome.config_validation as cv from esphome.const import ( + CONF_ADVANCED, + CONF_FRAMEWORK, CONF_ID, CONF_MODE, CONF_RX_PIN, @@ -27,6 +31,7 @@ from esphome.const import ( CONF_TX_PIN, CONF_TX_QUEUE_LEN, ) +import esphome.final_validate as fv from esphome.types import ConfigType CODEOWNERS = ["@Sympatron"] @@ -49,6 +54,7 @@ CAN_MODES = { # See ESP-IDF Programming Guide --> API Reference --> Two-Wire Automotive Interface (TWAI) CAN_SPEEDS_ESP32 = { + "20KBPS": CanSpeed.CAN_20KBPS, "25KBPS": CanSpeed.CAN_25KBPS, "50KBPS": CanSpeed.CAN_50KBPS, "100KBPS": CanSpeed.CAN_100KBPS, @@ -114,6 +120,22 @@ CONFIG_SCHEMA = canbus.CANBUS_SCHEMA.extend( ) +def _final_validate(config: ConfigType) -> None: + if get_esp32_variant() == VARIANT_ESP32 and config[CONF_BIT_RATE] == "20KBPS": + esp32_config = fv.full_config.get()[KEY_ESP32] + min_revision = esp32_config[CONF_FRAMEWORK][CONF_ADVANCED].get( + CONF_MINIMUM_CHIP_REVISION + ) + if min_revision is None or min_revision < "2.0": + raise cv.Invalid( + "20KBPS on ESP32 requires esp32.framework.advanced.minimum_chip_revision: '2.0' or higher", + path=[CONF_BIT_RATE], + ) + + +FINAL_VALIDATE_SCHEMA = _final_validate + + def get_default_tx_enqueue_timeout(bit_rate: str) -> int: bit_rate_numeric = canbus.get_rate(bit_rate) bits_per_packet = 140 # ~max CAN message length diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index b98ba853e78..d84b58c5b0c 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -33,6 +33,10 @@ static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config case canbus::CAN_16KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_16KBITS(); return true; +#endif +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) || (defined(USE_ESP32_VARIANT_ESP32) && CONFIG_ESP32_REV_MIN_FULL >= 200) case canbus::CAN_20KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_20KBITS(); return true; diff --git a/tests/components/esp32_can/test-20kbps.esp32-idf.yaml b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml new file mode 100644 index 00000000000..fb60cb7363f --- /dev/null +++ b/tests/components/esp32_can/test-20kbps.esp32-idf.yaml @@ -0,0 +1,11 @@ +esp32: + framework: + advanced: + minimum_chip_revision: "2.0" + +canbus: + - platform: esp32_can + rx_pin: GPIO14 + tx_pin: GPIO12 + can_id: 4 + bit_rate: 20kbps diff --git a/tests/unit_tests/components/test_esp32_can.py b/tests/unit_tests/components/test_esp32_can.py new file mode 100644 index 00000000000..5b70ce1d812 --- /dev/null +++ b/tests/unit_tests/components/test_esp32_can.py @@ -0,0 +1,54 @@ +"""Tests for ESP32 CAN bitrate validation.""" + +import pytest + +from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT +from esphome.components.esp32_can import canbus +import esphome.config_validation as cv +from esphome.core import CORE +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + ("revision", "supported"), + [ + (None, False), + ("0.0", False), + ("1.0", False), + ("1.1", False), + ("2.0", True), + ("3.0", True), + ("3.1", True), + ], +) +def test_esp32_20kbps_minimum_revision(revision: str | None, supported: bool) -> None: + """Require the chip revision needed by the ESP-IDF timing macro.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: "ESP32"} + advanced = {} if revision is None else {"minimum_chip_revision": revision} + token = fv.full_config.set({"esp32": {"framework": {"advanced": advanced}}}) + try: + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + if supported: + canbus.FINAL_VALIDATE_SCHEMA(config) + else: + with pytest.raises(cv.Invalid, match="minimum_chip_revision") as error: + canbus.FINAL_VALIDATE_SCHEMA(config) + assert error.value.path == ["bit_rate"] + finally: + fv.full_config.reset(token) + + +@pytest.mark.parametrize("variant", ["ESP32", "ESP32C3", "ESP32S3"]) +def test_other_bitrates(variant: str) -> None: + """Existing bitrates must not require a minimum chip revision.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("25kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config) + + +@pytest.mark.parametrize("variant", ["ESP32C3", "ESP32S2", "ESP32S3", "ESP32C6"]) +def test_other_variants_20kbps(variant: str) -> None: + """Other supported ESP32 variants must retain 20 kbit/s support.""" + CORE.data[KEY_ESP32] = {KEY_VARIANT: variant} + config = {"bit_rate": canbus.validate_bit_rate("20kbps")} + canbus.FINAL_VALIDATE_SCHEMA(config) From 07029d00775a76427d0ff70011025750eef93fe6 Mon Sep 17 00:00:00 2001 From: Brandon Harvey <8107750+bharvey88@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:47:31 -0500 Subject: [PATCH 19/20] [sen6x] Add measurement control and maintenance actions (#18783) Co-authored-by: J. Nick Koston --- esphome/components/sen6x/sen6x.cpp | 111 ++++++++++++++++++++++++++++- esphome/components/sen6x/sen6x.h | 13 ++++ esphome/components/sen6x/sensor.py | 20 ++++++ tests/components/sen6x/common.yaml | 14 ++++ 4 files changed, 156 insertions(+), 2 deletions(-) diff --git a/esphome/components/sen6x/sen6x.cpp b/esphome/components/sen6x/sen6x.cpp index 91762e0f079..5087b112a8d 100644 --- a/esphome/components/sen6x/sen6x.cpp +++ b/esphome/components/sen6x/sen6x.cpp @@ -14,6 +14,15 @@ static constexpr uint32_t POLL_INTERVAL = 50; // 50 ms between poll attempts // Numeric timeout IDs. Each chain is sequential, so only one timeout per ID is active at a time. static constexpr uint32_t TIMEOUT_POLL = 1; static constexpr uint32_t TIMEOUT_SETUP_STEP = 2; +static constexpr uint32_t TIMEOUT_STARTUP = 3; +// Post-command wait times from the datasheet: start execution (4.8.1), stop execution (4.8.2), +// fan cleaning (4.8.22), SHT heater (4.8.23), CO2 conditioning after start on SEN63C/SEN69C (4.8.1) +static constexpr uint32_t START_MEASUREMENT_DELAY = 50; +static constexpr uint32_t STOP_MEASUREMENT_DELAY = 1400; +static constexpr uint32_t FAN_CLEANING_DELAY = 10000; +static constexpr uint32_t SHT_HEATER_DELAY = 20000; +static constexpr uint32_t CO2_CONDITIONING_DELAY = 24000; +static constexpr uint32_t STARTUP_DELAY = 60000; // ms before published values are considered valid static constexpr uint16_t SEN6X_CMD_GET_DATA_READY_STATUS = 0x0202; static constexpr uint16_t SEN6X_CMD_GET_FIRMWARE_VERSION = 0xD100; static constexpr uint16_t SEN6X_CMD_GET_PRODUCT_NAME = 0xD014; @@ -27,6 +36,9 @@ static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN68 = 0x0467; static constexpr uint16_t SEN6X_CMD_READ_MEASUREMENT_SEN69C = 0x04B5; static constexpr uint16_t SEN6X_CMD_START_MEASUREMENTS = 0x0021; +static constexpr uint16_t SEN6X_CMD_STOP_MEASUREMENTS = 0x0104; +static constexpr uint16_t SEN6X_CMD_START_FAN_CLEANING = 0x5607; +static constexpr uint16_t SEN6X_CMD_ACTIVATE_SHT_HEATER = 0x6765; static constexpr uint16_t SEN6X_CMD_RESET = 0xD304; static constexpr uint16_t SEN6X_CMD_VOC_ALGORITHM_TUNING = 0x60D0; static constexpr uint16_t SEN6X_CMD_NOX_ALGORITHM_TUNING = 0x60E1; @@ -191,7 +203,7 @@ void SEN6XComponent::finish_setup_() { return; } - this->set_timeout(60000, [this]() { this->startup_complete_ = true; }); + this->on_measurement_started_(); this->initialized_ = true; ESP_LOGD(TAG, "Initialized"); } @@ -240,7 +252,7 @@ void SEN6XComponent::dump_config() { } void SEN6XComponent::update() { - if (!this->initialized_) { + if (!this->initialized_ || !this->measuring_) { return; } @@ -442,6 +454,101 @@ void SEN6XComponent::parse_and_publish_measurements_() { this->status_clear_warning(); } +// True while the previous command's execution or settling time has not elapsed +bool SEN6XComponent::command_blocked_() const { return millis() - this->command_started_at_ < this->command_wait_; } + +void SEN6XComponent::set_command_wait_(uint32_t wait_ms) { + this->command_started_at_ = millis(); + this->command_wait_ = wait_ms; +} + +void SEN6XComponent::on_measurement_started_() { + this->measuring_ = true; + this->set_command_wait_(START_MEASUREMENT_DELAY); + // SEN63C/SEN69C condition the CO2 sensor for 24 s after a start; block restarts until then + if (this->sen6x_type_ == SEN63C || this->sen6x_type_ == SEN69C) { + this->co2_started_at_ = millis(); + this->co2_wait_ = CO2_CONDITIONING_DELAY; + } + // Values need the warm-up period again after every start + this->startup_complete_ = false; + this->set_timeout(TIMEOUT_STARTUP, STARTUP_DELAY, [this]() { this->startup_complete_ = true; }); +} + +void SEN6XComponent::start_measurement() { + if (!this->initialized_ || this->measuring_) + return; + // The CO2 conditioning window only blocks restarting, not other commands + if (this->command_blocked_() || millis() - this->co2_started_at_ < this->co2_wait_) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Start measurement failed (%d)", this->last_error_); + return; + } + this->on_measurement_started_(); +} + +void SEN6XComponent::stop_measurement() { + if (!this->initialized_ || !this->measuring_) + return; + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + // Stop any in-flight polling before the device goes idle + this->cancel_timeout(TIMEOUT_POLL); + if (!this->write_command(SEN6X_CMD_STOP_MEASUREMENTS)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Stop measurement failed (%d)", this->last_error_); + return; + } + this->measuring_ = false; + this->set_command_wait_(STOP_MEASUREMENT_DELAY); +} + +void SEN6XComponent::start_fan_cleaning() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.22) + if (this->measuring_) { + ESP_LOGW(TAG, "Fan cleaning requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_START_FAN_CLEANING)) { + this->status_set_warning(); + ESP_LOGW(TAG, "Fan cleaning failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(FAN_CLEANING_DELAY); +} + +void SEN6XComponent::activate_sht_heater() { + if (!this->initialized_) + return; + // The device only accepts this command in idle mode (datasheet section 4.8.23) + if (this->measuring_) { + ESP_LOGW(TAG, "SHT heater requires idle mode; stop the measurement first"); + return; + } + if (this->command_blocked_()) { + ESP_LOGW(TAG, "Device busy"); + return; + } + if (!this->write_command(SEN6X_CMD_ACTIVATE_SHT_HEATER)) { + this->status_set_warning(); + ESP_LOGW(TAG, "SHT heater failed (%d)", this->last_error_); + return; + } + this->set_command_wait_(SHT_HEATER_DELAY); +} + SEN6XComponent::Sen6xType SEN6XComponent::infer_type_from_product_name_(const std::string &product_name) { if (product_name == "SEN62") return SEN62; diff --git a/esphome/components/sen6x/sen6x.h b/esphome/components/sen6x/sen6x.h index 64ce3371fce..588a466d714 100644 --- a/esphome/components/sen6x/sen6x.h +++ b/esphome/components/sen6x/sen6x.h @@ -58,6 +58,10 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S NOX_STD_INITIAL, gain_factor}; } + void start_measurement(); + void stop_measurement(); + void start_fan_cleaning(); + void activate_sht_heater(); protected: Sen6xType infer_type_from_product_name_(const std::string &product_name); @@ -65,6 +69,9 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S void finish_setup_(); bool write_config_words_(uint16_t i2c_command, const uint16_t *data, uint8_t len); bool write_tuning_parameters_(uint16_t i2c_command, const GasTuning &tuning); + bool command_blocked_() const; + void set_command_wait_(uint32_t wait_ms); + void on_measurement_started_(); void poll_data_ready_(); void read_measurements_(); void parse_and_publish_measurements_(); @@ -74,6 +81,11 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S optional voc_tuning_params_; optional nox_tuning_params_; Sen6xType sen6x_type_{UNKNOWN}; + // Post-command wait windows, stored as start time plus duration so elapsed-time checks survive millis() wrap + uint32_t command_started_at_{0}; + uint32_t command_wait_{0}; + uint32_t co2_started_at_{0}; + uint32_t co2_wait_{0}; uint16_t read_cmd_{0}; uint8_t setup_step_index_{0}; uint8_t firmware_version_major_{0}; @@ -81,6 +93,7 @@ class SEN6XComponent final : public PollingComponent, public sensirion_common::S uint8_t poll_retries_remaining_{0}; uint8_t read_words_{0}; bool initialized_{false}; + bool measuring_{false}; bool startup_complete_{false}; }; diff --git a/esphome/components/sen6x/sensor.py b/esphome/components/sen6x/sensor.py index 4c0242f2e3f..a5c46801887 100644 --- a/esphome/components/sen6x/sensor.py +++ b/esphome/components/sen6x/sensor.py @@ -1,3 +1,5 @@ +from esphome import automation +from esphome.automation import maybe_simple_id import esphome.codegen as cg from esphome.components import i2c, sensirion_common, sensor from esphome.components.const import CONF_NOX_INDEX, CONF_VOC_INDEX @@ -209,3 +211,21 @@ async def to_code(config: ConfigType) -> None: args.append(std_initial) args.append(tuning[CONF_GAIN_FACTOR]) cg.add(getattr(var, setter)(*args)) + + +SEN6X_ACTION_SCHEMA = maybe_simple_id( + { + cv.Required(CONF_ID): cv.use_id(SEN6XComponent), + } +) + + +for _name, _call in ( + ("sen6x.start_measurement", "start_measurement()"), + ("sen6x.stop_measurement", "stop_measurement()"), + ("sen6x.start_fan_cleaning", "start_fan_cleaning()"), + ("sen6x.activate_sht_heater", "activate_sht_heater()"), +): + automation.register_apply_action( + _name, SEN6X_ACTION_SCHEMA, automation.ApplyCall(_call) + ) diff --git a/tests/components/sen6x/common.yaml b/tests/components/sen6x/common.yaml index c9b6f22c0fc..6ab80a76b21 100644 --- a/tests/components/sen6x/common.yaml +++ b/tests/components/sen6x/common.yaml @@ -48,3 +48,17 @@ sensor: formaldehyde: name: Formaldehyde address: 0x6B + +button: + - platform: template + name: Fan Cleaning + on_press: + - sen6x.stop_measurement: sen6x_sensor + - delay: 2s + - sen6x.start_fan_cleaning: sen6x_sensor + - delay: 11s + - sen6x.start_measurement: sen6x_sensor + - platform: template + name: SHT Heater + on_press: + - sen6x.activate_sht_heater: sen6x_sensor From 40e12cd0d893f496c78ccbacdb5df529e64cd783 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 13 Oct 2026 17:01:47 -1000 Subject: [PATCH 20/20] [image] Add CodSpeed benchmarks for image, runtime image and display block draws (#20649) --- .../benchmarks/components/display/__init__.py | 7 + .../display/bench_draw_pixels_at.cpp | 82 ++++++ tests/benchmarks/components/image/__init__.py | 7 + .../components/image/bench_image_draw.cpp | 245 ++++++++++++++++++ .../bench_runtime_image_draw.cpp | 125 +++++++++ tests/testing_helpers.py | 22 ++ 6 files changed, 488 insertions(+) create mode 100644 tests/benchmarks/components/display/__init__.py create mode 100644 tests/benchmarks/components/display/bench_draw_pixels_at.cpp create mode 100644 tests/benchmarks/components/image/__init__.py create mode 100644 tests/benchmarks/components/image/bench_image_draw.cpp create mode 100644 tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp diff --git a/tests/benchmarks/components/display/__init__.py b/tests/benchmarks/components/display/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/display/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/display/bench_draw_pixels_at.cpp b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp new file mode 100644 index 00000000000..da96dd83b9a --- /dev/null +++ b/tests/benchmarks/components/display/bench_draw_pixels_at.cpp @@ -0,0 +1,82 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" + +namespace esphome::display::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated block stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// A buffered display without its own draw_pixels_at(), so every block goes +// through the base class fallback and the DisplayBuffer pixel path. +class BenchDisplay : public DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + DisplayType get_display_type() override { return DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; +}; + +// Deterministic pseudo random pixel bytes, sized for the widest format. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 3; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +// One full block the way LVGL hands a display its rendering. +static void draw_block(benchmark::State &state, ColorBitness bitness, bool big_endian, + DisplayRotation rotation = DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + BenchDisplay display; + display.set_rotation(rotation); + for (auto _ : state) { + display.draw_pixels_at(0, 0, kWidth, kHeight, data.get(), COLOR_ORDER_RGB, bitness, big_endian, 0, 0, 0); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void DrawPixelsAt_RGB565_BigEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, true); } +BENCHMARK(DrawPixelsAt_RGB565_BigEndian); + +static void DrawPixelsAt_RGB565_LittleEndian(benchmark::State &state) { draw_block(state, COLOR_BITNESS_565, false); } +BENCHMARK(DrawPixelsAt_RGB565_LittleEndian); + +static void DrawPixelsAt_RGB888(benchmark::State &state) { draw_block(state, COLOR_BITNESS_888, true); } +BENCHMARK(DrawPixelsAt_RGB888); + +static void DrawPixelsAt_RGB332(benchmark::State &state) { draw_block(state, COLOR_BITNESS_332, true); } +BENCHMARK(DrawPixelsAt_RGB332); + +static void DrawPixelsAt_RGB565_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_565, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB565_Rotated); + +static void DrawPixelsAt_RGB888_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_888, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB888_Rotated); + +static void DrawPixelsAt_RGB332_Rotated(benchmark::State &state) { + draw_block(state, COLOR_BITNESS_332, true, DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(DrawPixelsAt_RGB332_Rotated); + +} // namespace esphome::display::benchmarks diff --git a/tests/benchmarks/components/image/__init__.py b/tests/benchmarks/components/image/__init__.py new file mode 100644 index 00000000000..34eec8f3748 --- /dev/null +++ b/tests/benchmarks/components/image/__init__.py @@ -0,0 +1,7 @@ +from tests.testing_helpers import ComponentManifestOverride, add_defines_to_code + + +def override_manifest(manifest: ComponentManifestOverride) -> None: + # Loops that only exist on boards with PSRAM are gated on this define; + # set it so the host benchmark measures the same code those boards run. + add_defines_to_code(manifest, "USE_PSRAM") diff --git a/tests/benchmarks/components/image/bench_image_draw.cpp b/tests/benchmarks/components/image/bench_image_draw.cpp new file mode 100644 index 00000000000..6bdd0acffbc --- /dev/null +++ b/tests/benchmarks/components/image/bench_image_draw.cpp @@ -0,0 +1,245 @@ +#include + +#include +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/image/image.h" + +namespace esphome::image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; +// Square frame so a rotated draw of the image stays inside it. +static constexpr int kStride = kWidth > kHeight ? kWidth : kHeight; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is Image::draw() plus the DisplayBuffer pixel path every +// buffered display shares. Everything fits the host cache, so this counts +// the per pixel work, not what a frame buffer in PSRAM costs. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kStride * kStride)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kStride; } + int get_height_internal() override { return kStride; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kStride + x] = color.r; } + + std::unique_ptr frame_; + + public: + uint8_t pixel(int x, int y) const { return this->frame_[y * kStride + x]; } +}; + +// Models a driver with a bulk path: rows of little endian RGB565 are copied +// straight into a 16 bit frame, as the TFT drivers do for their own format. +// Image::draw() only reaches it once it hands opaque images to +// draw_pixels_at(); until then this case matches ImageDraw_RGB565. +class BulkBenchDisplay : public BenchDisplay { + public: + BulkBenchDisplay() : frame565_(std::make_unique(kStride * kStride)) {} + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override { + if (bitness != display::COLOR_BITNESS_565 || big_endian) { + BenchDisplay::draw_pixels_at(x_start, y_start, w, h, ptr, order, bitness, big_endian, x_offset, y_offset, x_pad); + return; + } + const size_t stride = x_offset + w + x_pad; + for (int y = 0; y < h; y++) { + memcpy(this->frame565_.get() + (y_start + y) * kStride + x_start, ptr + ((y_offset + y) * stride + x_offset) * 2, + w * 2); + } + } + + protected: + std::unique_ptr frame565_; +}; + +// Draws the image at rotation 0 and 90 and checks the second frame is the +// transpose of the first, which is what a wrong traversal order would break. +static bool frames_match(Image &image) { + BenchDisplay plain; + BenchDisplay rotated; + rotated.set_rotation(display::DISPLAY_ROTATION_90_DEGREES); + image.draw(0, 0, &plain, display::COLOR_ON, display::COLOR_OFF); + image.draw(0, 0, &rotated, display::COLOR_ON, display::COLOR_OFF); + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + // DisplayBuffer maps (x, y) to (width - y - 1, x) at 90 degrees + if (plain.pixel(x, y) != rotated.pixel(kStride - y - 1, x)) + return false; + } + } + return true; +} + +// Deterministic pseudo random pixel bytes so the alpha branch sees a mix of +// pixels. Sized for the widest format, the image reads only what it needs. +static std::unique_ptr make_pixels() { + constexpr size_t bytes = kWidth * kHeight * 4; + auto data = std::make_unique(bytes); + uint32_t seed = 11897; + for (size_t i = 0; i < bytes; i++) { + seed = seed * 1103515245u + 12345u; + data[i] = static_cast(seed >> 16); + } + return data; +} + +static void draw_image(benchmark::State &state, ImageType type, Transparency transparency, + display::DisplayRotation rotation = display::DISPLAY_ROTATION_0_DEGREES) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, type, transparency); + BenchDisplay display; + display.set_rotation(rotation); + if (!frames_match(image)) { + state.SkipWithError("rotated draw is not the transpose of the plain draw"); + return; + } + + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void ImageDraw_Binary(benchmark::State &state) { draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_Binary); + +static void ImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE); +} +BENCHMARK(ImageDraw_Grayscale); + +static void ImageDraw_RGB565(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB565); + +static void ImageDraw_RGB(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB); + +// The same opaque RGB565 image on a display with a bulk path. +static void ImageDraw_RGB565_Bulk(benchmark::State &state) { + auto data = make_pixels(); + Image image(data.get(), kWidth, kHeight, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE); + BulkBenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} +BENCHMARK(ImageDraw_RGB565_Bulk); + +static void ImageDraw_RGB_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Rotated); + +static void ImageDraw_Binary_Transparent(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Binary_Transparent); + +static void ImageDraw_Grayscale_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey); + +static void ImageDraw_Grayscale_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_Grayscale_Alpha); + +static void ImageDraw_RGB_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB_Alpha); + +static void ImageDraw_RGB_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB_ChromaKey); + +static void ImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey); + +static void ImageDraw_RGB565_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB565_Alpha); + +static void ImageDraw_RGB111(benchmark::State &state) { draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE); } +BENCHMARK(ImageDraw_RGB111); + +static void ImageDraw_RGB111_Alpha(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL); +} +BENCHMARK(ImageDraw_RGB111_Alpha); + +// The same cases with the display rotated 90 degrees, which walks the other loop order. +static void ImageDraw_Binary_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Rotated); + +static void ImageDraw_Grayscale_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Rotated); + +static void ImageDraw_RGB565_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Rotated); + +static void ImageDraw_Binary_Transparent_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_BINARY, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Binary_Transparent_Rotated); + +static void ImageDraw_Grayscale_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_ChromaKey_Rotated); + +static void ImageDraw_Grayscale_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_GRAYSCALE, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_Grayscale_Alpha_Rotated); + +static void ImageDraw_RGB_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_Alpha_Rotated); + +static void ImageDraw_RGB_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB_ChromaKey_Rotated); + +static void ImageDraw_RGB565_ChromaKey_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_CHROMA_KEY, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_ChromaKey_Rotated); + +static void ImageDraw_RGB565_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB565, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB565_Alpha_Rotated); + +static void ImageDraw_RGB111_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_OPAQUE, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Rotated); + +static void ImageDraw_RGB111_Alpha_Rotated(benchmark::State &state) { + draw_image(state, IMAGE_TYPE_RGB111, TRANSPARENCY_ALPHA_CHANNEL, display::DISPLAY_ROTATION_90_DEGREES); +} +BENCHMARK(ImageDraw_RGB111_Alpha_Rotated); + +} // namespace esphome::image::benchmarks diff --git a/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp new file mode 100644 index 00000000000..fd3cf6fb773 --- /dev/null +++ b/tests/benchmarks/components/runtime_image/bench_runtime_image_draw.cpp @@ -0,0 +1,125 @@ +#include + +#include +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/components/runtime_image/runtime_image.h" + +namespace esphome::runtime_image::benchmarks { + +static constexpr int kWidth = 128; +static constexpr int kHeight = 96; + +// One byte per pixel frame buffer with no hardware behind it, so the +// measurement is the image draw plus the DisplayBuffer pixel path every +// buffered display shares. +class BenchDisplay : public display::DisplayBuffer { + public: + BenchDisplay() : frame_(std::make_unique(kWidth * kHeight)) {} + void update() override {} + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + int get_width_internal() override { return kWidth; } + int get_height_internal() override { return kHeight; } + + protected: + void draw_absolute_pixel_internal(int x, int y, Color color) override { this->frame_[y * kWidth + x] = color.r; } + + std::unique_ptr frame_; +}; + +// A runtime image filled the way a decoder fills it, one draw_pixel() per +// pixel, then published as decoded so draw() uses the buffer. +class BenchImage : public RuntimeImage { + public: + BenchImage(image::ImageType type, image::Transparency transparency, bool big_endian) + : RuntimeImage(JPEG, type, transparency, nullptr, big_endian, kWidth, kHeight) {} + + bool fill() { + if (this->resize(kWidth, kHeight) == 0) + return false; + uint32_t seed = 11897; + for (int y = 0; y < kHeight; y++) { + for (int x = 0; x < kWidth; x++) { + seed = seed * 1103515245u + 12345u; + // Every fourth pixel is transparent so the chroma key cases hit both branches + const uint8_t alpha = (x & 3) == 0 ? 0x00 : 0xFF; + this->draw_pixel(x, y, Color(seed >> 24, seed >> 16, seed >> 8, alpha)); + } + } + this->width_ = this->buffer_width_; + this->height_ = this->buffer_height_; + this->data_start_ = this->buffer_; + return true; + } +}; + +// The decoder side: writing every pixel of the image into its buffer. +static void fill_image(benchmark::State &state, image::ImageType type, image::Transparency transparency) { + BenchImage image(type, transparency, false); + for (auto _ : state) { + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + benchmark::DoNotOptimize(image.get_data_start()); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +// The display side: drawing the decoded image onto a buffered display. +static void draw_image(benchmark::State &state, image::ImageType type, image::Transparency transparency, + bool big_endian = false) { + BenchImage image(type, transparency, big_endian); + if (!image.fill()) { + state.SkipWithError("buffer allocation failed"); + return; + } + BenchDisplay display; + for (auto _ : state) { + image.draw(0, 0, &display, display::COLOR_ON, display::COLOR_OFF); + } + state.SetItemsProcessed(state.iterations() * kWidth * kHeight); +} + +static void RuntimeImageFill_RGB565(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB565); + +static void RuntimeImageFill_RGB565_ChromaKey(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageFill_RGB565_ChromaKey); + +static void RuntimeImageFill_RGB(benchmark::State &state) { + fill_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageFill_RGB); + +static void RuntimeImageDraw_RGB565(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB565); + +static void RuntimeImageDraw_RGB565_BigEndian(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_OPAQUE, true); +} +BENCHMARK(RuntimeImageDraw_RGB565_BigEndian); + +static void RuntimeImageDraw_RGB565_ChromaKey(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB565, image::TRANSPARENCY_CHROMA_KEY); +} +BENCHMARK(RuntimeImageDraw_RGB565_ChromaKey); + +static void RuntimeImageDraw_RGB(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_RGB, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_RGB); + +static void RuntimeImageDraw_Grayscale(benchmark::State &state) { + draw_image(state, image::IMAGE_TYPE_GRAYSCALE, image::TRANSPARENCY_OPAQUE); +} +BENCHMARK(RuntimeImageDraw_Grayscale); + +} // namespace esphome::runtime_image::benchmarks diff --git a/tests/testing_helpers.py b/tests/testing_helpers.py index 20b76697a19..23bef718694 100644 --- a/tests/testing_helpers.py +++ b/tests/testing_helpers.py @@ -1,6 +1,7 @@ from typing import Any from esphome.loader import ComponentManifest, _replace_component_manifest +from esphome.types import ConfigType class ComponentManifestOverride: @@ -61,3 +62,24 @@ def set_testing_manifest(domain: str, manifest: ComponentManifestOverride) -> No directory provides an ``override_manifest`` function. """ _replace_component_manifest(domain, manifest) + + +def add_defines_to_code(manifest: ComponentManifestOverride, *defines: str) -> None: + """Keep the component's real ``to_code`` and add the given defines after it. + + For host test and benchmark builds of code that is gated on a platform + define, such as loops that only exist on boards with PSRAM. + """ + from esphome import codegen as cg + + manifest.enable_codegen() + original_to_code = manifest.to_code + + async def to_code(config: ConfigType) -> None: + await original_to_code(config) + for define in defines: + cg.add_define(define) + + if hasattr(original_to_code, "priority"): + to_code.priority = original_to_code.priority + manifest.to_code = to_code