Compare commits

...

17 Commits

Author SHA1 Message Date
J. Nick Koston
5b1618331f DO NOT MERGE: prove memory-impact no-common-platform fix in CI
Chained on top of the memory-impact fix to exercise it in real CI.

Changed components and their base-test platforms:
- const: variant-only test (test-display.esp32-s3-idf.yaml, no base test).
  --base-only cannot build it, so the fix must exclude it; it only injects
  an esp32-s3-idf platform hint.
- shelly_dimmer: base test esp8266-ard (real C++).
- daikin: base test esp8266-ard (real C++), the working third component.

Without the fix, the esp32-s3-idf hint (and const's variant counted as a
supported platform) selects esp32-s3-idf, which none of the three can build
under --base-only, yielding 0 builds and a failed memory extraction. With the
fix, const is excluded, the unbuildable hint is ignored in favor of the common
esp8266-ard platform, and shelly_dimmer + daikin generate memory impact.
2026-06-03 21:47:52 -05:00
J. Nick Koston
3ad851993f [ci] Fix memory impact build selecting unbuildable platform
Memory impact analysis gathered candidate platforms from all test files
(including variant test-*.yaml) but the CI build runs
test_build_components.py with --base-only, which only compiles base
test.<platform>.yaml files. It could therefore select a platform that no
changed component has a base test for, leaving the merged build with
nothing to compile and failing memory extraction.

Detect platforms using the same base-test discovery as the runner via a
shared get_component_test_platforms helper, and keep only components that
have a base test on the selected platform, falling back to the platform
supported by the most components when a hint picks an unbuildable one.
2026-06-03 21:18:09 -05:00
Jonathan Swoboda
0d7d091e71 [esp32_ble_server] Fix duplicate Device Information Service with string UUIDs (#16784) 2026-06-04 09:46:17 +12:00
Jonathan Swoboda
0fcfd1e3d6 [rp2040] Fix lwipopts template load on Windows extended-length paths (#16783) 2026-06-04 09:46:08 +12:00
Jonathan Swoboda
74a1ff9fc7 [esp32][core] Restore ESP-IDF version on logs/upload fast path and clean build on framework change (#16770) 2026-06-04 09:46:01 +12:00
Jonathan Swoboda
78d8a93fff [remote_base] Fix RC5 decoding at either receive polarity (#16767) 2026-06-04 09:45:56 +12:00
Jon Little
92819d8658 [logger] Fix USB JTAG VFS symbols linked when logging is disabled (#15721)
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-06-03 16:54:34 -04:00
Clyde Stubbs
7b8cbe2de1 [sdl] Add option to choose display screen (#16363) 2026-06-03 14:18:16 -04:00
Leonardo Rivera
3b0f669f47 [gree] Fix HEAT_COOL advertised when supports_heat is false; restrict YAN swing to vertical (#16199) 2026-06-03 13:38:00 -04:00
dependabot[bot]
87735d71a0 Bump actions/checkout from 6.0.2 to 6.0.3 (#16776)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 11:46:22 -04:00
dependabot[bot]
eba70dc193 Bump github/codeql-action from 4.36.0 to 4.36.1 (#16775)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 11:46:07 -04:00
dependabot[bot]
712ef2ec0e Bump esptool from 5.2.0 to 5.3.0 (#16774)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-03 11:45:52 -04:00
Clyde Stubbs
2009f6cc5f [lvgl] Fix indicator updates (#16780) 2026-06-03 11:43:48 -04:00
PolarGoose
89ddd34cb9 [dmsr] [breaking] Fix decryption that uses custom auth key. Add CRC to telegram sensor. Automatic hex string detection in equipment_id fields. Support EON Hungary smart meters (#16561) 2026-06-03 10:55:51 -04:00
Jonathan Swoboda
e4980713d1 [core] esphome clean wipes the whole build directory (#16772) 2026-06-03 07:35:23 -04:00
Clyde Stubbs
997ab11687 [lvgl][mipi_spi][mipi_rgb][mipi_dsi][display] Metadata (#16702) 2026-06-03 15:21:33 +10:00
Jesse Hills
792e1ff304 [i2c] Add basic host platform support (#14489)
Co-authored-by: Jonathan Swoboda <154711427+swoboda1337@users.noreply.github.com>
2026-06-03 07:12:50 +12:00
70 changed files with 1784 additions and 514 deletions

View File

@@ -1 +1 @@
27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f
a30d2e50f2cac76e9c504eb7e5b250070dc92df23469c44a7eb8e52e26fd375d

View File

@@ -24,7 +24,7 @@ jobs:
if: github.event.pull_request.state == 'open' && (github.event.action != 'labeled' || github.event.sender.type != 'Bot')
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Generate a token
id: generate-token

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0

View File

@@ -42,7 +42,7 @@ jobs:
- "docker"
# - "lint"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:

View File

@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Run tests
working-directory: .github/scripts/auto-label-pr

View File

@@ -49,7 +49,7 @@ jobs:
- name: Check out code from base repository
if: steps.pr.outputs.skip != 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Always check out from the base repository (esphome/esphome), never from forks
# Use the PR's target branch to ensure we run trusted code from the main repo

View File

@@ -28,7 +28,7 @@ jobs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Generate cache-key
id: cache-key
run: echo key="${{ hashFiles('requirements.txt', 'requirements_dev.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT
@@ -74,7 +74,7 @@ jobs:
if: needs.determine-jobs.outputs.python-linters == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -97,7 +97,7 @@ jobs:
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -123,7 +123,7 @@ jobs:
if: needs.determine-jobs.outputs.import-time == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -151,11 +151,11 @@ jobs:
if: needs.determine-jobs.outputs.device-builder == 'true'
steps:
- name: Check out esphome (this PR)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
path: esphome
- name: Check out esphome/device-builder
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: esphome/device-builder
ref: main
@@ -221,7 +221,7 @@ jobs:
if: needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
id: restore-python
uses: ./.github/actions/restore-python
@@ -281,7 +281,7 @@ jobs:
benchmarks: ${{ steps.determine.outputs.benchmarks }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Fetch enough history to find the merge base
fetch-depth: 2
@@ -353,7 +353,7 @@ jobs:
bucket: ${{ fromJson(needs.determine-jobs.outputs.integration-test-buckets) }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python 3.13
id: python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@@ -405,7 +405,7 @@ jobs:
if: github.event_name == 'pull_request' && (needs.determine-jobs.outputs.cpp-unit-tests-run-all == 'true' || needs.determine-jobs.outputs.cpp-unit-tests-components != '[]')
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -434,7 +434,7 @@ jobs:
(github.event_name == 'pull_request' && needs.determine-jobs.outputs.benchmarks == 'true')
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -490,7 +490,7 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -575,7 +575,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -670,7 +670,7 @@ jobs:
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
# Need history for HEAD~1 to work for checking changed files
fetch-depth: 2
@@ -764,7 +764,7 @@ jobs:
version: 1.0
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -889,7 +889,7 @@ jobs:
TEST_COMPONENTS: ${{ needs.determine-jobs.outputs.native-idf-components }}
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
@@ -971,7 +971,7 @@ jobs:
if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') && needs.determine-jobs.outputs.core-ci == 'true'
steps:
- name: Check out code from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -997,7 +997,7 @@ jobs:
skip: ${{ steps.check-script.outputs.skip || steps.check-tests.outputs.skip }}
steps:
- name: Check out target branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.base_ref }}
@@ -1179,7 +1179,7 @@ jobs:
flash_usage: ${{ steps.extract.outputs.flash_usage }}
steps:
- name: Check out PR branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:
@@ -1248,7 +1248,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
steps:
- name: Check out code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Restore Python
uses: ./.github/actions/restore-python
with:

View File

@@ -26,7 +26,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.base.sha }}
sparse-checkout: |

View File

@@ -29,7 +29,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.pull_request.base.sha }}

View File

@@ -52,11 +52,11 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/init@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -84,6 +84,6 @@ jobs:
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
uses: github/codeql-action/analyze@87557b9c84dde89fdd9b10e88954ac2f4248e463 # v4.36.1
with:
category: "/language:${{matrix.language}}"

View File

@@ -16,7 +16,7 @@ jobs:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:

View File

@@ -20,7 +20,7 @@ jobs:
branch_build: ${{ steps.tag.outputs.branch_build }}
deploy_env: ${{ steps.tag.outputs.deploy_env }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Get tag
id: tag
# yamllint disable rule:line-length
@@ -60,7 +60,7 @@ jobs:
contents: read # actions/checkout to build the sdist/wheel
id-token: write # OIDC token for PyPI Trusted Publishing (pypa/gh-action-pypi-publish)
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@@ -92,7 +92,7 @@ jobs:
os: "ubuntu-24.04-arm"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
@@ -168,7 +168,7 @@ jobs:
- ghcr
- dockerhub
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1

View File

@@ -28,10 +28,10 @@ jobs:
permission-pull-requests: write # pulls.create / pulls.update to open or refresh the sync PR
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Checkout Home Assistant
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
repository: home-assistant/core
path: lib/home-assistant

View File

@@ -695,6 +695,11 @@ def _wrap_to_code(name, comp, yaml_util):
def write_cpp(config: ConfigType) -> int:
from esphome import writer
# Refresh the storage sidecar and clean an incompatible previous build
# before regenerating any sources. This may full-wipe the build dir, so it
# has to run before write_cpp_file writes src/.
writer.update_storage_json()
if not get_bool_env(ENV_NOGITIGNORE):
writer.write_gitignore()
@@ -1631,7 +1636,7 @@ def command_clean(args: ArgsProtocol, config: ConfigType) -> int | None:
from esphome import writer
try:
writer.clean_build()
writer.clean_build(full=True)
except OSError as err:
_LOGGER.error("Error deleting build files: %s", err)
return 1

View File

@@ -7,7 +7,6 @@ from esphome.components.esp32 import get_esp32_variant, idf_version
import esphome.config_validation as cv
from esphome.core import CORE
from esphome.helpers import mkdir_p, write_file_if_changed
from esphome.writer import update_storage_json
def get_available_components() -> list[str] | None:
@@ -213,11 +212,6 @@ target_link_options(${{COMPONENT_LIB}} PUBLIC
def write_project(minimal: bool = False) -> None:
"""Write ESP-IDF project files."""
# Refresh <data_dir>/storage/<name>.yaml.json so the dashboard's
# /info and /downloads endpoints can locate the build (they 404
# otherwise). This mirrors the PlatformIO build-gen path's call
# in build_gen/platformio.py:write_ini().
update_storage_json()
mkdir_p(CORE.build_path)
mkdir_p(CORE.relative_src_path())

View File

@@ -1,7 +1,7 @@
from esphome.const import __version__
from esphome.core import CORE
from esphome.helpers import mkdir_p, read_file, write_file_if_changed
from esphome.writer import find_begin_end, update_storage_json
from esphome.writer import find_begin_end
INI_AUTO_GENERATE_BEGIN = "; ========== AUTO GENERATED CODE BEGIN ==========="
INI_AUTO_GENERATE_END = "; =========== AUTO GENERATED CODE END ============"
@@ -58,7 +58,6 @@ def get_ini_content():
def write_ini(content):
update_storage_json()
path = CORE.relative_build_path("platformio.ini")
if path.is_file():

View File

@@ -1,3 +1,5 @@
// DNM: do not merge. Trivial touch to mark daikin changed; this is the working
// esp8266-ard component that should generate memory impact (see PR #16788).
#include "daikin.h"
#include "esphome/components/remote_base/remote_base.h"

View File

@@ -3,11 +3,18 @@ from dataclasses import dataclass
from esphome import automation, core
from esphome.automation import maybe_simple_id
import esphome.codegen as cg
from esphome.components.const import KEY_METADATA
from esphome.components.const import (
BYTE_ORDER_BIG,
CONF_BYTE_ORDER,
CONF_DRAW_ROUNDING,
KEY_METADATA,
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_DIMENSIONS,
CONF_FROM,
CONF_HEIGHT,
CONF_ID,
CONF_LAMBDA,
CONF_PAGE_ID,
@@ -16,10 +23,11 @@ from esphome.const import (
CONF_TO,
CONF_TRIGGER_ID,
CONF_UPDATE_INTERVAL,
CONF_WIDTH,
SCHEDULER_DONT_RUN,
)
from esphome.core import CORE, CoroPriority, coroutine_with_priority
from esphome.cpp_generator import MockObj
from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority
from esphome.final_validate import full_config
DOMAIN = "display"
IS_PLATFORM_COMPONENT = True
@@ -159,29 +167,97 @@ async def setup_display_core_(var, config):
class DisplayMetaData:
width: int = 0
height: int = 0
has_writer: bool = False
has_hardware_rotation: bool = False
byte_order: str = BYTE_ORDER_BIG
has_writer: bool = False
rotation: int = 0
draw_rounding: int = 0
def _get_metadata_list() -> list[tuple]:
"""Get the raw metadata list. Each entry is (id, DisplayMetaData)."""
return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, [])
def get_all_display_metadata() -> dict[str, DisplayMetaData]:
"""Get all display metadata."""
return CORE.data.setdefault(DOMAIN, {}).setdefault(KEY_METADATA, {})
"""Get all display metadata as a dict keyed by resolved ID strings.
Must not be called before IDs have been finalised.
"""
entries = _get_metadata_list()
assert all(id_.id is not None for id_, _ in entries), (
"get_all_display_metadata called before display IDs have been resolved"
)
return {id_.id: meta for id_, meta in entries}
def get_display_metadata(display_id: str) -> DisplayMetaData | None:
"""Get display metadata by ID for use by other components."""
return get_all_display_metadata().get(display_id, DisplayMetaData())
def get_display_metadata(display_id: ID) -> DisplayMetaData:
"""Get display metadata by ID object
Must not be called before IDs have been finalised.
"""
for id_, meta in _get_metadata_list():
if id_ is display_id:
return meta
assert id_.id is not None, (
"get_display_metadata called before display IDs have been resolved"
)
if id_.id == display_id.id:
return meta
# No metadata found, display driver may not yet support it.
# Read the raw config to populate the returned data
global_config = full_config.get()
path = global_config.get_path_for_id(display_id)[:-1]
disp_config = global_config.get_config_for_path(path)
dimensions = disp_config.get(CONF_DIMENSIONS, (0, 0))
if isinstance(dimensions, dict):
dimensions = (dimensions.get(CONF_WIDTH, 0), dimensions.get(CONF_HEIGHT, 0))
elif not isinstance(dimensions, tuple) or len(dimensions) != 2:
dimensions = (0, 0)
meta = DisplayMetaData(
width=dimensions[0],
height=dimensions[1],
has_hardware_rotation=False,
byte_order=disp_config.get(CONF_BYTE_ORDER, cv.UNDEFINED),
has_writer=disp_config.get(CONF_AUTO_CLEAR_ENABLED) is True
or disp_config.get(CONF_PAGES) is not None
or disp_config.get(CONF_LAMBDA) is not None
or disp_config.get(CONF_SHOW_TEST_CARD) is True,
rotation=disp_config.get(CONF_ROTATION, 0),
draw_rounding=disp_config.get(CONF_DRAW_ROUNDING, 0),
)
_get_metadata_list().append((display_id, meta))
return meta
def add_metadata(
id: str | MockObj,
id: ID,
width: int,
height: int,
has_writer: bool,
has_hardware_rotation: bool = False,
byte_order: str = BYTE_ORDER_BIG,
has_writer: bool = False,
rotation: int = 0,
draw_rounding: int = 0,
):
get_all_display_metadata()[str(id)] = DisplayMetaData(
width, height, has_writer, has_hardware_rotation
entries = _get_metadata_list()
assert not any(existing_id is id for existing_id, _ in entries), (
f"Duplicate display metadata for ID {id}"
)
entries.append(
(
id,
DisplayMetaData(
width=width,
height=height,
has_hardware_rotation=has_hardware_rotation,
byte_order=byte_order,
has_writer=has_writer,
rotation=rotation,
draw_rounding=draw_rounding,
),
)
)

View File

@@ -87,7 +87,7 @@ async def to_code(config):
cg.add_build_flag("-DDSMR_WATER_MBUS_ID=" + str(config[CONF_WATER_MBUS_ID]))
cg.add_build_flag("-DDSMR_THERMAL_MBUS_ID=" + str(config[CONF_THERMAL_MBUS_ID]))
cg.add_library("esphome/dsmr_parser", "1.4.0")
cg.add_library("esphome/dsmr_parser", "1.8.0")
def final_validate(config: ConfigType) -> ConfigType:

View File

@@ -153,8 +153,9 @@ void Dsmr::receive_encrypted_telegram_() {
bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram) {
this->stop_requesting_data_();
ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.content().size());
ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast<int>(telegram.content().size()), telegram.content().data());
ESP_LOGV(TAG, "Trying to parse telegram (%zu bytes)", telegram.full_content().size());
ESP_LOGVV(TAG, "Telegram content:\n %.*s", static_cast<int>(telegram.full_content().size()),
telegram.full_content().data());
MyData data;
if (const bool res = dsmr_parser::DsmrParser::parse(data, telegram); !res) {
@@ -167,7 +168,7 @@ bool Dsmr::parse_telegram_(const dsmr_parser::DsmrUnencryptedTelegram &telegram)
// Publish the telegram, after publishing the sensors so it can also trigger action based on latest values
if (this->s_telegram_ != nullptr) {
this->s_telegram_->publish_state(telegram.content().data(), telegram.content().size());
this->s_telegram_->publish_state(telegram.full_content().data(), telegram.full_content().size());
}
return true;
}

View File

@@ -74,7 +74,8 @@ class Dsmr : public Component, public uart::UARTDevice {
receive_timeout_(receive_timeout),
request_pin_(request_pin),
buffer_(max_telegram_length),
packet_accumulator_(buffer_, crc_check) {
packet_accumulator_(buffer_, crc_check),
dlms_decryptor_(gcm_decryptor_, crc_check) {
this->set_decryption_key_(decryption_key);
}
@@ -97,7 +98,11 @@ class Dsmr : public Component, public uart::UARTDevice {
// Remove before 2026.8.0
ESPDEPRECATED("Use 'decryption_key' configuration parameter. This method will be removed in 2026.8.0", "2026.2.0")
void set_decryption_key(const std::string &decryption_key) { this->set_decryption_key_(decryption_key.c_str()); }
void set_decryption_key(const std::string &decryption_key) {
// Some YAML configs pass a string longer than 32 symbols. We only need the first 32 symbols,
// otherwise `Aes128GcmDecryptionKey::from_hex` will fail.
this->set_decryption_key_(std::string(decryption_key, 0, 32).c_str());
}
// Sensor setters
#define DSMR_SET_SENSOR(s) \
@@ -143,7 +148,7 @@ class Dsmr : public Component, public uart::UARTDevice {
std::vector<uint8_t> buffer_;
dsmr_parser::PacketAccumulator packet_accumulator_;
Aes128GcmDecryptorImpl gcm_decryptor_;
dsmr_parser::DlmsPacketDecryptor dlms_decryptor_{gcm_decryptor_};
dsmr_parser::DlmsPacketDecryptor dlms_decryptor_;
std::array<uint8_t, 256> uart_chunk_reading_buf_;
};
} // namespace esphome::dsmr

View File

@@ -248,10 +248,6 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_POWER,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("electricity_switch_position"): sensor.sensor_schema(
accuracy_decimals=3,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("electricity_failures"): sensor.sensor_schema(
accuracy_decimals=0,
state_class=STATE_CLASS_MEASUREMENT,
@@ -808,6 +804,10 @@ CONFIG_SCHEMA = cv.Schema(
device_class=DEVICE_CLASS_DURATION,
state_class=STATE_CLASS_MEASUREMENT,
),
cv.Optional("electricity_switch_position"): cv.invalid(
"'electricity_switch_position' has moved to the 'text_sensor' platform."
"Move it under 'text_sensor' to fix."
),
}
).extend(cv.COMPONENT_SCHEMA)

View File

@@ -14,6 +14,7 @@ CONFIG_SCHEMA = cv.Schema(
cv.Optional("p1_version"): text_sensor.text_sensor_schema(),
cv.Optional("p1_version_be"): text_sensor.text_sensor_schema(),
cv.Optional("timestamp"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_switch_position"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_tariff"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_tariff_il"): text_sensor.text_sensor_schema(),
cv.Optional("electricity_failure_log"): text_sensor.text_sensor_schema(),

View File

@@ -62,6 +62,26 @@ MANUFACTURER_NAME_CHARACTERISTIC_UUID = 0x2A29
MODEL_CHARACTERISTIC_UUID = 0x2A24
FIRMWARE_VERSION_CHARACTERISTIC_UUID = 0x2A26
# Suffix of the Bluetooth Base UUID used to expand 16/32 bit UUIDs to 128 bit.
_BASE_UUID_SUFFIX = "-0000-1000-8000-00805F9B34FB"
def uuid_is(uuid: int | str, uuid16: int) -> bool:
"""Return True if a validated UUID refers to the given 16-bit short UUID.
A service/characteristic UUID may be an ``int`` (from ``cv.hex_uint32_t``) or an
uppercase string in 16, 32 or 128 bit form (from ``bt_uuid``), so every
representation of the same UUID must be considered equivalent.
"""
if isinstance(uuid, int):
return uuid == uuid16
return uuid.upper() in (
f"{uuid16:04X}",
f"{uuid16:08X}",
f"{uuid16:08X}{_BASE_UUID_SUFFIX}",
)
# Core key to store the global configuration
KEY_NOTIFY_REQUIRED = "notify_required"
KEY_SET_VALUE = "set_value"
@@ -195,7 +215,7 @@ def create_description_cud(char_config):
return char_config
# If the config displays a description, there cannot be a descriptor with the CUD UUID
for desc in char_config[CONF_DESCRIPTORS]:
if desc[CONF_UUID] == CUD_DESCRIPTOR_UUID:
if uuid_is(desc[CONF_UUID], CUD_DESCRIPTOR_UUID):
raise cv.Invalid(
f"Characteristic {char_config[CONF_UUID]} has a description, but a CUD descriptor is already present"
)
@@ -218,7 +238,7 @@ def create_notify_cccd(char_config):
return char_config
# If the CCCD descriptor is already present, return the config
for desc in char_config[CONF_DESCRIPTORS]:
if desc[CONF_UUID] == CCCD_DESCRIPTOR_UUID:
if uuid_is(desc[CONF_UUID], CCCD_DESCRIPTOR_UUID):
# Check if the WRITE property is set
if not desc[CONF_WRITE]:
raise cv.Invalid(
@@ -244,7 +264,7 @@ def create_device_information_service(config):
# If there is already a device information service,
# there cannot be CONF_MODEL, CONF_MANUFACTURER or CONF_FIRMWARE_VERSION properties
for service in config[CONF_SERVICES]:
if service[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID:
if uuid_is(service[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID):
if (
CONF_MODEL in config
or CONF_MANUFACTURER in config
@@ -592,7 +612,7 @@ async def to_code(config):
)
for char_conf in service_config[CONF_CHARACTERISTICS]:
await to_code_characteristic(service_var, char_conf)
if service_config[CONF_UUID] == DEVICE_INFORMATION_SERVICE_UUID:
if uuid_is(service_config[CONF_UUID], DEVICE_INFORMATION_SERVICE_UUID):
cg.add(var.set_device_information_service(service_var))
else:
cg.add(var.enqueue_start_service(service_var))

View File

@@ -5,7 +5,23 @@ namespace esphome::gree {
static const char *const TAG = "gree.climate";
climate::ClimateTraits GreeClimate::traits() {
auto t = climate_ir::ClimateIR::traits();
// ClimateIR unconditionally includes HEAT_COOL in the base mode set; remove it when heat is not supported.
if (!this->supports_heat_) {
auto modes = t.get_supported_modes();
modes.erase(climate::CLIMATE_MODE_HEAT_COOL);
t.set_supported_modes(modes);
}
return t;
}
void GreeClimate::set_model(Model model) {
if (model == GREE_YAN) {
// YAN only has a vertical vane; the horizontal swing IR bytes are not defined for this model.
this->swing_modes_.erase(climate::CLIMATE_SWING_HORIZONTAL);
this->swing_modes_.erase(climate::CLIMATE_SWING_BOTH);
}
if (model == GREE_YX1FF) {
this->fan_modes_.insert(climate::CLIMATE_FAN_QUIET); // YX1FF 4 speed
this->presets_.insert(climate::CLIMATE_PRESET_NONE); // YX1FF sleep mode

View File

@@ -94,6 +94,7 @@ class GreeClimate : public climate_ir::ClimateIR {
protected:
// Transmit via IR the state of this climate controller.
void transmit_state() override;
climate::ClimateTraits traits() override;
uint8_t operation_mode_();
uint8_t fan_speed_();

View File

@@ -1,4 +1,6 @@
import logging
import re
import sys
from esphome import pins
import esphome.codegen as cg
@@ -29,6 +31,7 @@ from esphome.config_helpers import filter_source_files_from_platform
import esphome.config_validation as cv
from esphome.const import (
CONF_ADDRESS,
CONF_DEVICE,
CONF_FREQUENCY,
CONF_I2C,
CONF_I2C_ID,
@@ -40,6 +43,7 @@ from esphome.const import (
CONF_TIMEOUT,
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_HOST,
PLATFORM_NRF52,
PLATFORM_RP2040,
PlatformFramework,
@@ -56,6 +60,7 @@ InternalI2CBus = i2c_ns.class_("InternalI2CBus", I2CBus)
ArduinoI2CBus = i2c_ns.class_("ArduinoI2CBus", InternalI2CBus, cg.Component)
IDFI2CBus = i2c_ns.class_("IDFI2CBus", InternalI2CBus, cg.Component)
ZephyrI2CBus = i2c_ns.class_("ZephyrI2CBus", I2CBus, cg.Component)
HostI2CBus = i2c_ns.class_("HostI2CBus", I2CBus, cg.Component)
I2CDevice = i2c_ns.class_("I2CDevice")
ESP32_I2C_CAPABILITIES = {
@@ -83,6 +88,12 @@ CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled"
MULTI_CONF = True
def validate_device(value):
if not re.match(r"^/(?:[^/]+/)*[^/]+$", value):
raise cv.Invalid("Device must be an absolute device path (e.g., /dev/i2c-0)")
return value
def _bus_declare_type(value):
if CORE.is_esp32:
return cv.declare_id(IDFI2CBus)(value)
@@ -90,6 +101,8 @@ def _bus_declare_type(value):
return cv.declare_id(ArduinoI2CBus)(value)
if CORE.using_zephyr:
return cv.declare_id(ZephyrI2CBus)(value)
if CORE.is_host:
return cv.declare_id(HostI2CBus)(value)
raise NotImplementedError
@@ -121,15 +134,48 @@ def validate_config(config):
return config
def validate_host_config(config):
if CORE.is_host:
# Host I2C is currently only supported on Linux
if not sys.platform.lower().startswith("linux"):
raise cv.Invalid(
"I2C is only supported on Linux for the host platform. "
f"Current platform: {sys.platform}"
)
if CONF_SDA in config or CONF_SCL in config:
raise cv.Invalid(
"'sda' and 'scl' are not supported on host platform; use 'device' instead."
)
if CONF_SDA_PULLUP_ENABLED in config or CONF_SCL_PULLUP_ENABLED in config:
raise cv.Invalid("Pull-up configuration is not supported on host platform.")
if CONF_DEVICE not in config:
raise cv.Invalid(
"'device' is required for host platform (e.g., /dev/i2c-0)."
)
return config
CONFIG_SCHEMA = cv.All(
cv.Schema(
{
cv.GenerateID(): _bus_declare_type,
cv.Optional(CONF_SDA, default="SDA"): pins.internal_gpio_pin_number,
cv.SplitDefault(
CONF_SDA,
esp32="SDA",
esp8266="SDA",
rp2040="SDA",
nrf52="SDA",
): pins.internal_gpio_pin_number,
cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32=True): cv.All(
cv.only_on_esp32, cv.boolean
),
cv.Optional(CONF_SCL, default="SCL"): pins.internal_gpio_pin_number,
cv.SplitDefault(
CONF_SCL,
esp32="SCL",
esp8266="SCL",
rp2040="SCL",
nrf52="SCL",
): pins.internal_gpio_pin_number,
cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All(
cv.only_on_esp32, cv.boolean
),
@@ -139,6 +185,7 @@ CONFIG_SCHEMA = cv.All(
esp8266="50kHz",
rp2040="50kHz",
nrf52="100kHz",
host="50kHz",
): cv.All(
cv.frequency,
cv.float_range(min=0, min_included=False),
@@ -155,10 +202,22 @@ CONFIG_SCHEMA = cv.All(
),
cv.boolean,
),
cv.Optional(CONF_DEVICE): cv.All(
cv.only_on(PLATFORM_HOST), validate_device
),
}
).extend(cv.COMPONENT_SCHEMA),
cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_NRF52]),
cv.only_on(
[
PLATFORM_ESP32,
PLATFORM_ESP8266,
PLATFORM_RP2040,
PLATFORM_NRF52,
PLATFORM_HOST,
]
),
validate_config,
validate_host_config,
)
@@ -217,7 +276,13 @@ FINAL_VALIDATE_SCHEMA = _final_validate
async def to_code(config):
cg.add_global(i2c_ns.using)
cg.add_define("USE_I2C")
if CORE.using_zephyr:
if CORE.is_host:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
cg.add(var.set_device(config[CONF_DEVICE]))
cg.add(var.set_frequency(int(config[CONF_FREQUENCY])))
cg.add(var.set_scan(config[CONF_SCAN]))
elif CORE.using_zephyr:
zephyr_add_prj_conf("I2C", True)
i2c = "i2c0"
if zephyr_data()[KEY_BOARD] == "xiao_ble":
@@ -244,25 +309,40 @@ async def to_code(config):
var = cg.new_Pvariable(
config[CONF_ID], MockObj(f"DEVICE_DT_GET(DT_NODELABEL({i2c}))")
)
await cg.register_component(var, config)
cg.add(var.set_sda_pin(config[CONF_SDA]))
if CONF_SDA_PULLUP_ENABLED in config:
cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED]))
cg.add(var.set_scl_pin(config[CONF_SCL]))
if CONF_SCL_PULLUP_ENABLED in config:
cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED]))
cg.add(var.set_frequency(int(config[CONF_FREQUENCY])))
cg.add(var.set_scan(config[CONF_SCAN]))
if CONF_TIMEOUT in config:
cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds)))
if CONF_LOW_POWER_MODE in config:
cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE])))
else:
var = cg.new_Pvariable(config[CONF_ID])
await cg.register_component(var, config)
await cg.register_component(var, config)
cg.add(var.set_sda_pin(config[CONF_SDA]))
if CONF_SDA_PULLUP_ENABLED in config:
cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED]))
cg.add(var.set_scl_pin(config[CONF_SCL]))
if CONF_SCL_PULLUP_ENABLED in config:
cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED]))
cg.add(var.set_sda_pin(config[CONF_SDA]))
if CONF_SDA_PULLUP_ENABLED in config:
cg.add(var.set_sda_pullup_enabled(config[CONF_SDA_PULLUP_ENABLED]))
cg.add(var.set_scl_pin(config[CONF_SCL]))
if CONF_SCL_PULLUP_ENABLED in config:
cg.add(var.set_scl_pullup_enabled(config[CONF_SCL_PULLUP_ENABLED]))
cg.add(var.set_frequency(int(config[CONF_FREQUENCY])))
cg.add(var.set_scan(config[CONF_SCAN]))
if CONF_TIMEOUT in config:
cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds)))
if CORE.using_arduino and not CORE.is_esp32:
cg.add_library("Wire", None)
if CONF_LOW_POWER_MODE in config:
cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE])))
cg.add(var.set_frequency(int(config[CONF_FREQUENCY])))
cg.add(var.set_scan(config[CONF_SCAN]))
if CONF_TIMEOUT in config:
cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds)))
if CORE.using_arduino and not CORE.is_esp32:
cg.add_library("Wire", None)
if CONF_LOW_POWER_MODE in config:
cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE])))
def i2c_device_schema(default_address):
@@ -365,5 +445,6 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform(
PlatformFramework.ESP32_IDF,
},
"i2c_bus_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR},
"i2c_bus_host.cpp": {PlatformFramework.HOST_NATIVE},
}
)

View File

@@ -0,0 +1,297 @@
#ifdef USE_HOST
#if defined(__linux__)
#include "i2c_bus_host.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include <fcntl.h>
#include <linux/i2c-dev.h>
#include <linux/i2c.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <cerrno>
#include <cstdint>
#include <cstring>
namespace esphome::i2c {
static const char *const TAG = "i2c.host";
HostI2CBus::~HostI2CBus() {
if (this->file_descriptor_ != -1) {
close(this->file_descriptor_);
this->file_descriptor_ = -1;
}
}
void HostI2CBus::setup() {
ESP_LOGCONFIG(TAG, "Setting up I2C bus...");
// Open I2C device file
this->file_descriptor_ = open(this->device_.c_str(), O_RDWR);
if (this->file_descriptor_ == -1) {
int err = errno;
if (err == ENOENT) {
this->update_error_("not found");
} else if (err == EACCES) {
this->update_error_("permission denied");
} else {
this->update_error_(std::string("failed to open: ") + strerror(err));
}
this->mark_failed();
return;
}
this->initialized_ = true;
ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str());
// Run bus scan if enabled
if (this->scan_) {
this->i2c_scan_();
}
}
void HostI2CBus::dump_config() {
ESP_LOGCONFIG(TAG, "I2C Bus:");
ESP_LOGCONFIG(TAG, " Device: %s", this->device_.c_str());
// Bus frequency cannot be set from userspace via i2c-dev; report it as informational only
ESP_LOGCONFIG(TAG, " Frequency: %u Hz (informational; not applied on host)", this->frequency_);
if (!this->first_error_.empty()) {
ESP_LOGE(TAG, " Setup Error: %s", this->first_error_.c_str());
}
if (this->scan_) {
ESP_LOGI(TAG, " Scan Results:");
for (const auto &s : this->scan_results_) {
if (s.second) {
ESP_LOGI(TAG, " 0x%02X: Found", s.first);
}
}
}
}
ErrorCode HostI2CBus::write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count,
uint8_t *read_buffer, size_t read_count) {
if (!this->initialized_) {
ESP_LOGE(TAG, "I2C bus not initialized");
return ERROR_NOT_INITIALIZED;
}
ESP_LOGVV(TAG, "I2C write_readv addr=0x%02X write=%zu read=%zu", address, write_count, read_count);
// Handle special case: probe (no write data, no read data)
// This is used for device detection during bus scanning
if (write_count == 0 && read_count == 0) {
struct i2c_msg msg;
msg.addr = address;
msg.flags = 0;
msg.len = 0;
msg.buf = nullptr;
struct i2c_rdwr_ioctl_data rdwr_data;
rdwr_data.msgs = &msg;
rdwr_data.nmsgs = 1;
int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data);
if (ret < 0) {
int err = errno;
// If I2C_RDWR not supported, try SMBus Quick command (what i2cdetect uses)
if (err == EOPNOTSUPP || err == ENOSYS) {
ESP_LOGVV(TAG, "I2C_RDWR probe failed, trying SMBus Quick for addr=0x%02X", address);
if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT
return this->map_errno_to_error_code_(errno);
}
// Use I2C_SMBUS ioctl with Quick command
union i2c_smbus_data data;
struct i2c_smbus_ioctl_data args;
args.read_write = I2C_SMBUS_WRITE;
args.command = 0;
args.size = I2C_SMBUS_QUICK;
args.data = &data;
ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args);
if (ret < 0) {
return this->map_errno_to_error_code_(errno);
}
return ERROR_OK;
}
return this->map_errno_to_error_code_(err);
}
return ERROR_OK;
}
// i2c_msg.len is a 16-bit field; reject transfers that would silently truncate
if (write_count > UINT16_MAX || read_count > UINT16_MAX) {
ESP_LOGE(TAG, "I2C transfer too large: write=%zu read=%zu (max %u)", write_count, read_count,
(unsigned) UINT16_MAX);
return ERROR_TOO_LARGE;
}
// Prepare messages for combined write-read transaction
struct i2c_msg msgs[2];
int num_msgs = 0;
// Add write message if write data present
if (write_count > 0) {
msgs[num_msgs].addr = address;
msgs[num_msgs].flags = 0; // Write
msgs[num_msgs].len = write_count;
msgs[num_msgs].buf = const_cast<uint8_t *>(write_buffer);
num_msgs++;
}
// Add read message if read data requested
if (read_count > 0) {
msgs[num_msgs].addr = address;
msgs[num_msgs].flags = I2C_M_RD; // Read
msgs[num_msgs].len = read_count;
msgs[num_msgs].buf = read_buffer;
num_msgs++;
}
// Execute I2C transaction
struct i2c_rdwr_ioctl_data rdwr_data;
rdwr_data.msgs = msgs;
rdwr_data.nmsgs = num_msgs;
int ret = ioctl(this->file_descriptor_, I2C_RDWR, &rdwr_data);
if (ret < 0) {
int err = errno;
if (err == EOPNOTSUPP || err == ENOSYS) {
ESP_LOGV(TAG, "I2C_RDWR not supported, using I2C_SLAVE fallback for addr=0x%02X", address); // NOLINT
if (ioctl(this->file_descriptor_, I2C_SLAVE, address) < 0) { // NOLINT
ESP_LOGV(TAG, "I2C_SLAVE ioctl failed: %s", strerror(errno)); // NOLINT
return this->map_errno_to_error_code_(errno);
}
// Perform write if needed
if (write_count > 0) {
ssize_t written = ::write(this->file_descriptor_, write_buffer, write_count);
if (written != (ssize_t) write_count) {
int write_err = errno;
// If write() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort
if (write_err == EOPNOTSUPP || write_err == ENOSYS) {
ESP_LOGV(TAG, "I2C_SLAVE write not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT
// Use I2C_SMBUS_I2C_BLOCK_DATA for writes up to 32 bytes
// Standard SMBus mapping: first byte is command, remaining bytes are data
if (write_count < 1) {
ESP_LOGE(TAG, "Write size too small for I2C_SMBUS");
return ERROR_INVALID_ARGUMENT;
}
if (write_count > I2C_SMBUS_BLOCK_MAX + 1) {
ESP_LOGE(TAG, "Write size %zu exceeds I2C_SMBUS_BLOCK_MAX+1 (%d)", write_count, I2C_SMBUS_BLOCK_MAX + 1);
return ERROR_INVALID_ARGUMENT;
}
union i2c_smbus_data data;
// Standard SMBus: first byte = command, rest = data
uint8_t command = write_buffer[0];
size_t data_len = write_count - 1;
data.block[0] = data_len;
if (data_len > 0) {
memcpy(&data.block[1], write_buffer + 1, data_len);
}
struct i2c_smbus_ioctl_data args;
args.read_write = I2C_SMBUS_WRITE;
args.command = command;
args.size = I2C_SMBUS_I2C_BLOCK_DATA;
args.data = &data;
ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args);
if (ret < 0) {
ESP_LOGV(TAG, "I2C_SMBUS write failed: %s", strerror(errno));
return this->map_errno_to_error_code_(errno);
}
} else {
ESP_LOGV(TAG, "I2C write failed: %s", strerror(write_err));
return this->map_errno_to_error_code_(write_err);
}
}
}
// Perform read if needed
if (read_count > 0) {
ssize_t bytes_read = ::read(this->file_descriptor_, read_buffer, read_count);
if (bytes_read != (ssize_t) read_count) {
int read_err = errno;
// If read() also fails with EOPNOTSUPP, try I2C_SMBUS as last resort
if (read_err == EOPNOTSUPP || read_err == ENOSYS) {
ESP_LOGV(TAG, "I2C_SLAVE read not supported, trying I2C_SMBUS for addr=0x%02X", address); // NOLINT
// Use I2C_SMBUS_I2C_BLOCK_DATA for reads up to 32 bytes
if (read_count > I2C_SMBUS_BLOCK_MAX) {
ESP_LOGE(TAG, "Read size %zu exceeds I2C_SMBUS_BLOCK_MAX (%d)", read_count, I2C_SMBUS_BLOCK_MAX);
return ERROR_INVALID_ARGUMENT;
}
union i2c_smbus_data data;
data.block[0] = read_count;
struct i2c_smbus_ioctl_data args;
args.read_write = I2C_SMBUS_READ;
args.command = 0; // Start register/command
args.size = I2C_SMBUS_I2C_BLOCK_DATA;
args.data = &data;
ret = ioctl(this->file_descriptor_, I2C_SMBUS, &args);
if (ret < 0) {
ESP_LOGV(TAG, "I2C_SMBUS read failed: %s", strerror(errno));
return this->map_errno_to_error_code_(errno);
}
// I2C_SMBUS_I2C_BLOCK_DATA returns the actual byte count in block[0];
// a short read means we did not receive all requested bytes
if (data.block[0] < read_count) {
ESP_LOGV(TAG, "I2C_SMBUS short read: got %u, expected %zu", data.block[0], read_count);
return ERROR_NOT_ACKNOWLEDGED;
}
// Copy data from SMBus buffer to output buffer
memcpy(read_buffer, &data.block[1], read_count);
} else {
ESP_LOGV(TAG, "I2C read failed: %s", strerror(read_err));
return this->map_errno_to_error_code_(read_err);
}
}
}
ESP_LOGVV(TAG, "I2C transaction successful (I2C_SLAVE method)"); // NOLINT
return ERROR_OK;
}
ESP_LOGV(TAG, "I2C transaction failed: %s", strerror(err));
return this->map_errno_to_error_code_(err);
}
ESP_LOGVV(TAG, "I2C transaction successful");
return ERROR_OK;
}
ErrorCode HostI2CBus::map_errno_to_error_code_(int err) {
switch (err) {
case ENXIO:
return ERROR_NOT_ACKNOWLEDGED;
case ETIMEDOUT:
return ERROR_TIMEOUT;
case EINVAL:
return ERROR_INVALID_ARGUMENT;
case ENODEV:
case ENOTTY:
return ERROR_NOT_INITIALIZED;
case EOPNOTSUPP:
case ENOSYS:
// Operation not supported - some I2C adapters don't support zero-length transactions
ESP_LOGVV(TAG, "I2C adapter does not support this operation (likely zero-length probe)");
return ERROR_NOT_ACKNOWLEDGED;
default:
ESP_LOGV(TAG, "Unmapped error code: %d (%s)", err, strerror(err));
return ERROR_UNKNOWN;
}
}
void HostI2CBus::update_error_(const std::string &error) {
if (this->first_error_.empty()) {
this->first_error_ = error;
}
ESP_LOGE(TAG, "[%s] %s", this->device_.c_str(), error.c_str());
}
} // namespace esphome::i2c
#else
#error "HostI2CBus is only supported on Linux"
#endif // defined(__linux__)
#endif // USE_HOST

View File

@@ -0,0 +1,41 @@
#pragma once
#ifdef USE_HOST
#include "esphome/core/component.h"
#include "esphome/core/log.h"
#include "i2c_bus.h"
namespace esphome::i2c {
class HostI2CBus : public I2CBus, public Component {
public:
~HostI2CBus() override;
void setup() override;
void dump_config() override;
float get_setup_priority() const override { return setup_priority::BUS; }
ErrorCode write_readv(uint8_t address, const uint8_t *write_buffer, size_t write_count, uint8_t *read_buffer,
size_t read_count) override;
void set_device(const std::string &device) { this->device_ = device; }
void set_scan(bool scan) { this->scan_ = scan; }
void set_frequency(uint32_t frequency) { this->frequency_ = frequency; }
const std::string &get_device() const { return this->device_; }
protected:
void update_error_(const std::string &error);
ErrorCode map_errno_to_error_code_(int err);
std::string device_;
uint32_t frequency_{50000};
int file_descriptor_{-1};
bool initialized_{false};
std::string first_error_;
};
} // namespace esphome::i2c
#endif // USE_HOST

View File

@@ -461,7 +461,11 @@ async def _late_logger_init(config: ConfigType) -> None:
cg.add_define("USE_LOGGER_USB_SERIAL_JTAG")
# USB Serial JTAG code is compiled when platform supports it.
# Enable secondary USB serial JTAG console so the VFS functions are available.
if CORE.is_esp32 and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG:
if (
CORE.is_esp32
and config[CONF_HARDWARE_UART] != USB_SERIAL_JTAG
and has_serial_logging
):
require_usb_serial_jtag_secondary()
require_vfs_termios()
except cv.Invalid:

View File

@@ -6,7 +6,7 @@
#include <driver/uart.h>
#ifdef USE_LOGGER_USB_SERIAL_JTAG
#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG
#include <driver/usb_serial_jtag.h>
#if ESP_IDF_VERSION < ESP_IDF_VERSION_VAL(5, 3, 0)
#include <esp_vfs_dev.h>
@@ -29,7 +29,7 @@ namespace esphome::logger {
static const char *const TAG = "logger";
#ifdef USE_LOGGER_USB_SERIAL_JTAG
#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG
static void init_usb_serial_jtag_() {
setvbuf(stdin, NULL, _IONBF, 0); // Disable buffering on stdin
@@ -108,7 +108,9 @@ void Logger::pre_setup() {
#endif
#ifdef USE_LOGGER_USB_SERIAL_JTAG
case UART_SELECTION_USB_SERIAL_JTAG:
#ifdef USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG
init_usb_serial_jtag_();
#endif
break;
#endif
}

View File

@@ -7,6 +7,7 @@ import re
from esphome.automation import Trigger, build_automation, validate_automation
import esphome.codegen as cg
from esphome.components.const import (
BYTE_ORDER_BIG,
CONF_BYTE_ORDER,
CONF_COLOR_DEPTH,
CONF_DRAW_ROUNDING,
@@ -30,12 +31,10 @@ from esphome.components.image import (
from esphome.components.psram import DOMAIN as PSRAM_DOMAIN
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BUFFER_SIZE,
CONF_ESPHOME,
CONF_GROUP,
CONF_ID,
CONF_LAMBDA,
CONF_LOG_LEVEL,
CONF_ON_IDLE,
CONF_PAGES,
@@ -214,61 +213,73 @@ def multi_conf_validate(configs: list[dict]):
def final_validation(config_list):
if len(config_list) != 1:
multi_conf_validate(config_list)
global_config = full_config.get()
# Resolve byte_order from display metadata before multi-config validation
for config in config_list:
metas = [get_display_metadata(disp) for disp in config[df.CONF_DISPLAYS]]
if any(m.has_writer for m in metas):
raise cv.Invalid(
"Using lambda:, pages:, auto_clear_enabled: true, or show_test_card: true in display config is not compatible with LVGL"
)
if any(m.rotation != 0 for m in metas):
raise cv.Invalid(
"use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead"
)
config[CONF_DRAW_ROUNDING] = max(
[m.draw_rounding for m in metas] + [config[CONF_DRAW_ROUNDING]]
)
display_byte_orders = {
m.byte_order for m in metas if m.byte_order is not cv.UNDEFINED
}
if len(display_byte_orders) > 1:
raise cv.Invalid(
"All displays configured for an LVGL instance must use the same byte_order"
)
if display_byte_orders:
display_order = next(iter(display_byte_orders))
if CONF_BYTE_ORDER in config:
if config[CONF_BYTE_ORDER] != display_order:
raise cv.Invalid(
"LVGL byte order must match the display byte order",
[CONF_BYTE_ORDER],
)
else:
config[CONF_BYTE_ORDER] = display_order
if CONF_BYTE_ORDER not in config:
config[CONF_BYTE_ORDER] = BYTE_ORDER_BIG
if (pages := config.get(CONF_PAGES)) and all(p[df.CONF_SKIP] for p in pages):
raise cv.Invalid("At least one page must not be skipped")
for display_id in config[df.CONF_DISPLAYS]:
path = global_config.get_path_for_id(display_id)[:-1]
display = global_config.get_config_for_path(path)
if CONF_LAMBDA in display or CONF_PAGES in display:
raise cv.Invalid(
"Using lambda: or pages: in display config is not compatible with LVGL"
)
# treating 0 as false is intended here.
if display.get(CONF_ROTATION):
raise cv.Invalid(
"use of 'rotation' in the display config is not compatible with LVGL, please set rotation in the LVGL config instead"
)
if display.get(CONF_AUTO_CLEAR_ENABLED) is True:
raise cv.Invalid(
"Using auto_clear_enabled: true in display config not compatible with LVGL"
)
if draw_rounding := display.get(CONF_DRAW_ROUNDING):
config[CONF_DRAW_ROUNDING] = max(
draw_rounding, config[CONF_DRAW_ROUNDING]
)
buffer_frac = config[CONF_BUFFER_SIZE]
if CORE.is_esp32 and buffer_frac > 0.5 and PSRAM_DOMAIN not in global_config:
df.LOGGER.warning("buffer_size: may need to be reduced without PSRAM")
for w in get_focused_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if (
df.CONF_ADJUSTABLE in widget_conf
and not widget_conf[df.CONF_ADJUSTABLE]
):
raise cv.Invalid(
"A non adjustable arc may not be focused",
path,
)
for w in get_refreshed_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()):
raise cv.Invalid(
f"Widget '{w}' does not have any dynamic properties to refresh",
)
# Do per-widget type final validation for update actions
for widget_type, update_configs in df.get_updated_widgets().items():
for conf in update_configs:
for id_conf in conf.get(CONF_ID, ()):
name = id_conf[CONF_ID]
path = global_config.get_path_for_id(name)
widget_conf = global_config.get_config_for_path(path[:-1])
widget_type.final_validate(name, conf, widget_conf, path[1:])
if len(config_list) != 1:
multi_conf_validate(config_list)
for w in get_focused_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if df.CONF_ADJUSTABLE in widget_conf and not widget_conf[df.CONF_ADJUSTABLE]:
raise cv.Invalid(
"A non adjustable arc may not be focused",
path,
)
for w in get_refreshed_widgets():
path = global_config.get_path_for_id(w)
widget_conf = global_config.get_config_for_path(path[:-1])
if not any(isinstance(v, (Lambda, dict)) for v in widget_conf.values()):
raise cv.Invalid(
f"Widget '{w}' does not have any dynamic properties to refresh",
)
# Do per-widget type final validation for update actions
for widget_type, update_configs in df.get_updated_widgets().items():
for conf in update_configs:
for id_conf in conf.get(CONF_ID, ()):
name = id_conf[CONF_ID]
path = global_config.get_path_for_id(name)
widget_conf = global_config.get_config_for_path(path[:-1])
widget_type.final_validate(name, conf, widget_conf, path[1:])
async def to_code(configs):
@@ -367,8 +378,7 @@ async def to_code(configs):
# options will have CONF_ROTATION true if rotation is changed in an automation.
if CONF_ROTATION in config or df.get_options().get(CONF_ROTATION) is True:
if all(
get_display_metadata(str(disp)).has_hardware_rotation
for disp in displays
get_display_metadata(disp).has_hardware_rotation for disp in displays
):
rotation_type = RotationType.ROTATION_HARDWARE
df.LOGGER.info("LVGL will use hardware rotation via display driver")
@@ -583,7 +593,7 @@ LVGL_SCHEMA = cv.All(
cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of(
*df.LV_LOG_LEVELS, upper=True
),
cv.Optional(CONF_BYTE_ORDER, default="big_endian"): cv.one_of(
cv.Optional(CONF_BYTE_ORDER): cv.one_of(
"big_endian", "little_endian", lower=True
),
cv.Optional(df.CONF_STYLE_DEFINITIONS): cv.ensure_list(

View File

@@ -290,6 +290,7 @@ class Widget:
# Properties for linear equations
self.slope = None
self.y_int = None
self.parent = None
@staticmethod
def create(name, var, wtype: WidgetType, config: dict = None):

View File

@@ -430,7 +430,8 @@ class MeterType(WidgetType):
tvar, LV_PART.MAIN, await arc_style.get_var()
)
lw = Widget.create(iid, tvar, arc_indicator_type)
await set_indicator_values(lw, v)
lw.parent = scale_var
await set_indicator_values(scale_var, lw, v)
if t == CONF_TICK_STYLE:
# No object created for this
@@ -482,7 +483,8 @@ class MeterType(WidgetType):
if option in v:
props["line_" + option] = v[option]
lw = await widget_to_code(props, line_indicator_type, scale_var)
await set_indicator_values(lw, v)
lw.parent = scale_var
await set_indicator_values(scale_var, lw, v)
if t == CONF_IMAGE:
add_lv_use(CONF_IMAGE)
@@ -501,7 +503,8 @@ class MeterType(WidgetType):
}
iw = await widget_to_code(props, image_indicator_type, scale_var)
await iw.set_property(CONF_SRC, await lv_image.process(src))
await set_indicator_values(iw, v)
iw.parent = scale_var
await set_indicator_values(scale_var, iw, v)
# Hide the scale line
lv.obj_set_style_arc_opa(scale_var, LV_OPA.TRANSP, LV_PART.MAIN)
@@ -607,27 +610,27 @@ async def indicator_update_to_code(config, action_id, template_arg, args):
widget = await get_widgets(config)
async def set_value(w: Widget):
await set_indicator_values(w, config)
await set_indicator_values(w.parent, w, config)
return await action_to_code(
widget, set_value, action_id, template_arg, args, config
)
async def set_indicator_values(indicator: Widget, config):
async def set_indicator_values(scale: MockObj, indicator: Widget, config):
"""Update scale section values (replaces meter indicator values)"""
start_value = await get_start_value(config)
end_value = await get_end_value(config)
if indicator.type is arc_indicator_type:
# For scale sections, we update the range
if start_value is not None and end_value is not None:
lv.scale_section_set_range(indicator.obj, start_value, end_value)
lv.scale_set_section_range(scale, indicator.obj, start_value, end_value)
elif start_value is not None:
# If only start value, use it as both start and end (single point)
lv.scale_section_set_range(indicator.obj, start_value, start_value)
lv.scale_set_section_range(scale, indicator.obj, start_value, start_value)
elif end_value is not None:
# If only end value, assume range from 0 to end_value
lv.scale_section_set_range(indicator.obj, 0, end_value)
lv.scale_set_section_range(scale, indicator.obj, 0, end_value)
return
if start_value is None:

View File

@@ -37,6 +37,7 @@ from esphome.components.mipi import (
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_COLOR_ORDER,
CONF_DIMENSIONS,
CONF_DISABLED,
@@ -167,7 +168,21 @@ def _config_schema(config):
},
extra=cv.ALLOW_EXTRA,
)(config)
return model_schema(config)(config)
config = model_schema(config)(config)
model = MODELS[config[CONF_MODEL].upper()]
width, height, _offset_width, _offset_height = model.get_dimensions(config)
display.add_metadata(
config[CONF_ID],
width,
height,
has_hardware_rotation=False,
byte_order=config[CONF_BYTE_ORDER],
has_writer=requires_buffer(config)
or config.get(CONF_AUTO_CLEAR_ENABLED) is True,
rotation=config.get(CONF_ROTATION, 0),
draw_rounding=config.get(CONF_DRAW_ROUNDING, 0),
)
return config
def _final_validate(config):

View File

@@ -39,6 +39,7 @@ from esphome.components.rpi_dpi_rgb.display import (
)
import esphome.config_validation as cv
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BLUE,
CONF_COLOR_ORDER,
CONF_CS_PIN,
@@ -226,11 +227,25 @@ def _config_schema(config):
extra=cv.ALLOW_EXTRA,
)(config)
schema = model_schema(config)
return cv.All(
config = cv.All(
schema,
cv.only_on_esp32,
only_on_variant(supported=[VARIANT_ESP32S3, VARIANT_ESP32P4]),
)(config)
model = MODELS[config[CONF_MODEL].upper()]
width, height, _offset_width, _offset_height = model.get_dimensions(config)
display.add_metadata(
config[CONF_ID],
width,
height,
model.rotation_as_transform(config),
byte_order=config[CONF_BYTE_ORDER],
has_writer=requires_buffer(config)
or config.get(CONF_AUTO_CLEAR_ENABLED) is True,
rotation=config.get(CONF_ROTATION, 0),
draw_rounding=config.get(CONF_DRAW_ROUNDING, 0),
)
return config
CONFIG_SCHEMA = _config_schema

View File

@@ -30,6 +30,7 @@ from esphome.components.spi import TYPE_OCTAL, TYPE_QUAD, TYPE_SINGLE
import esphome.config_validation as cv
from esphome.config_validation import ALLOW_EXTRA
from esphome.const import (
CONF_AUTO_CLEAR_ENABLED,
CONF_BRIGHTNESS,
CONF_BUFFER_SIZE,
CONF_COLOR_ORDER,
@@ -47,6 +48,7 @@ from esphome.const import (
CONF_MIRROR_Y,
CONF_MODEL,
CONF_RESET_PIN,
CONF_ROTATION,
CONF_SWAP_XY,
CONF_TRANSFORM,
CONF_WIDTH,
@@ -267,6 +269,28 @@ def customise_schema(config):
if bus_mode != TYPE_QUAD and CONF_DC_PIN not in config:
raise cv.Invalid(f"DC pin is required in {bus_mode} mode")
denominator(config)
model = MODELS[config[CONF_MODEL]]
has_hardware_transform = config.get(
CONF_TRANSFORM
) != CONF_DISABLED and model.transforms == {
CONF_MIRROR_X,
CONF_MIRROR_Y,
CONF_SWAP_XY,
}
width, height, _offset_width, _offset_height = model.get_dimensions(
config, not has_hardware_transform
)
display.add_metadata(
config[CONF_ID],
width,
height,
has_hardware_transform,
byte_order=config[CONF_BYTE_ORDER],
has_writer=requires_buffer(config)
or config.get(CONF_AUTO_CLEAR_ENABLED) is True,
rotation=config.get(CONF_ROTATION, 0),
draw_rounding=config.get(CONF_DRAW_ROUNDING, 0),
)
return config
@@ -338,7 +362,6 @@ def get_instance(config):
buffer_type = cg.uint8 if color_depth == 8 else cg.uint16
frac = denominator(config)
madctl = model.get_madctl(model.get_base_transform(config), config)
has_writer = requires_buffer(config)
templateargs = [
buffer_type,
bufferpixels,
@@ -352,9 +375,6 @@ def get_instance(config):
madctl,
has_hardware_transform,
]
display.add_metadata(
config[CONF_ID], width, height, has_writer, has_hardware_transform
)
# If a buffer is required, use MipiSpiBuffer, otherwise use MipiSpi
if requires_buffer(config):
templateargs.extend(

View File

@@ -7,6 +7,7 @@ static const char *const TAG = "remote.rc5";
static constexpr uint32_t BIT_TIME_US = 889;
static constexpr uint8_t NBITS = 14;
static constexpr uint8_t NHALFBITS = NBITS * 2;
void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) {
static bool toggle = false;
@@ -35,52 +36,63 @@ void RC5Protocol::encode(RemoteTransmitData *dst, const RC5Data &data) {
}
toggle = !toggle;
}
optional<RC5Data> RC5Protocol::decode(RemoteReceiveData src) {
RC5Data out{
.address = 0,
.command = 0,
};
uint8_t field_bit;
if (src.expect_space(BIT_TIME_US) && src.expect_mark(BIT_TIME_US)) {
field_bit = 1;
} else if (src.expect_space(2 * BIT_TIME_US)) {
field_bit = 0;
} else {
return {};
}
if (!(((src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US)) ||
(src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) &&
(((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) &&
(src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) ||
((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) &&
(src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US)))))) {
return {};
}
uint32_t out_data = 0;
for (int bit = NBITS - 4; bit >= 1; bit--) {
if ((src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) &&
(src.expect_mark(BIT_TIME_US) || src.peek_mark(2 * BIT_TIME_US))) {
out_data |= 0 << bit;
} else if ((src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) &&
(src.expect_space(BIT_TIME_US) || src.peek_space(2 * BIT_TIME_US))) {
out_data |= 1 << bit;
// Expand the runs into half-bit levels (true = mark). Each run is exactly one
// half-bit (BIT_TIME_US) or two (2 * BIT_TIME_US); stop at anything else.
//
// halfbits[0] is reserved for the leading half-bit, which is always dropped --
// S1 is 1, so its first half sits at the idle level (at either polarity) and
// merges into the pre-frame idle. Captured half-bits start at index 1.
bool halfbits[NHALFBITS + 2];
uint8_t n = 1;
for (uint32_t i = 0; n <= NHALFBITS && src.is_valid(i); i++) {
if (src.peek_mark(BIT_TIME_US, i)) {
halfbits[n++] = true;
} else if (src.peek_space(BIT_TIME_US, i)) {
halfbits[n++] = false;
} else if (src.peek_mark(2 * BIT_TIME_US, i)) {
halfbits[n++] = true;
halfbits[n++] = true;
} else if (src.peek_space(2 * BIT_TIME_US, i)) {
halfbits[n++] = false;
halfbits[n++] = false;
} else {
return {};
break;
}
}
if (src.expect_space(BIT_TIME_US) || src.expect_space(2 * BIT_TIME_US)) {
out_data |= 0;
} else if (src.expect_mark(BIT_TIME_US) || src.expect_mark(2 * BIT_TIME_US)) {
out_data |= 1;
// Expect a full frame once the leading half is restored: 27 captured halves
// (n == 28) or 26 when the final bit also ends on idle and its trailing half
// is dropped too (n == 27). A dropped edge half is the inverse of its partner
// (a Manchester bit always transitions mid-bit), so reconstruct the leading
// half (always) and the trailing half (only when it was dropped).
if (n != NHALFBITS && n != NHALFBITS - 1) {
return {};
}
halfbits[0] = !halfbits[1];
if (n == NHALFBITS - 1) {
halfbits[n] = !halfbits[n - 1];
}
out.command = (uint8_t) (out_data & 0x3F) + (1 - field_bit) * 64u;
out.address = (out_data >> 6) & 0x1F;
return out;
const bool carrier = halfbits[1];
uint16_t bits = 0;
for (uint8_t i = 0; i < NBITS; i++) {
const bool first = halfbits[2 * i];
const bool second = halfbits[2 * i + 1];
if (first == second) {
return {}; // no midpoint transition -> not a valid Manchester bit
}
bits = (bits << 1) | (second == carrier ? 1 : 0);
}
const bool field_bit = bits & (1 << 12); // S2: the inverted 7th command bit
return RC5Data{
.address = static_cast<uint8_t>((bits >> 6) & 0x1F),
.command = static_cast<uint8_t>((bits & 0x3F) | (field_bit ? 0 : 0x40)),
};
}
void RC5Protocol::dump(const RC5Data &data) {
ESP_LOGI(TAG, "Received RC5: address=0x%02X, command=0x%02X", data.address, data.command);
}

View File

@@ -501,18 +501,21 @@ def _generate_lwipopts_h() -> None:
in the build directory, and a pre-build script injects this directory
into the compiler include path before the framework's own include dir.
"""
from jinja2 import Environment, FileSystemLoader
from jinja2 import Environment
lwip_defines = CORE.data[KEY_RP2040].get(KEY_LWIP_OPTS)
if not lwip_defines:
return
template_dir = Path(__file__).parent
jinja_env = Environment(
loader=FileSystemLoader(str(template_dir)),
keep_trailing_newline=True,
# Read the template via pathlib and render from a string rather than using
# FileSystemLoader. jinja2's loader joins the search path with posixpath, which
# breaks on Windows extended-length paths (\\?\C:\...) where forward slashes are
# not accepted, causing a spurious TemplateNotFound (see issue #16732).
template_text = (Path(__file__).parent / "lwipopts.h.jinja").read_text(
encoding="utf-8"
)
template = jinja_env.get_template("lwipopts.h.jinja")
jinja_env = Environment(keep_trailing_newline=True)
template = jinja_env.from_string(template_text)
content = template.render(**lwip_defines)
lwip_dir = CORE.relative_build_path("lwip_override")

View File

@@ -20,6 +20,7 @@ Sdl = sdl_ns.class_("Sdl", display.Display, cg.Component)
sdl_window_flags = cg.global_ns.enum("SDL_WindowFlags")
CONF_CENTERED_ON_DISPLAY = "centered_on_display"
CONF_SDL_OPTIONS = "sdl_options"
CONF_SDL_ID = "sdl_id"
CONF_WINDOW_OPTIONS = "window_options"
@@ -31,6 +32,8 @@ WINDOW_OPTIONS = (
"resizable",
)
SDL_WINDOWPOS_CENTERED_MASK = 0x2FFF0000
def get_sdl_options(value):
if value != "":
@@ -47,6 +50,20 @@ def get_window_options():
return {cv.Optional(option, default=False): cv.boolean for option in WINDOW_OPTIONS}
def _validate_position(config: dict) -> dict:
if CONF_CENTERED_ON_DISPLAY in config:
if CONF_X in config or CONF_Y in config:
raise cv.Invalid(
f"Cannot specify '{CONF_CENTERED_ON_DISPLAY}' with '{CONF_X}' and '{CONF_Y}' options"
)
return config
if CONF_X in config and CONF_Y in config:
return config
if CONF_X in config or CONF_Y in config:
raise cv.Invalid(f"Must specify both '{CONF_X}' and '{CONF_Y}' options")
raise cv.Invalid("Must specify either 'x' and 'y' or 'centered_on_display'")
CONFIG_SCHEMA = cv.All(
display.FULL_DISPLAY_SCHEMA.extend(
cv.Schema(
@@ -66,10 +83,13 @@ CONFIG_SCHEMA = cv.All(
{
cv.Optional(CONF_POSITION): cv.Schema(
{
cv.Required(CONF_X): cv.int_,
cv.Required(CONF_Y): cv.int_,
cv.Optional(CONF_X): cv.int_,
cv.Optional(CONF_Y): cv.int_,
cv.Optional(CONF_CENTERED_ON_DISPLAY): cv.int_range(
0, 128
),
}
),
).add_extra(_validate_position),
**get_window_options(),
}
),
@@ -105,7 +125,15 @@ async def to_code(config):
cg.add(var.set_window_options(create_flags))
if position := window_options.get(CONF_POSITION):
cg.add(var.set_position(position[CONF_X], position[CONF_Y]))
if (centered := position.get(CONF_CENTERED_ON_DISPLAY)) is not None:
cg.add(
var.set_position(
SDL_WINDOWPOS_CENTERED_MASK | centered,
SDL_WINDOWPOS_CENTERED_MASK | centered,
)
)
else:
cg.add(var.set_position(position[CONF_X], position[CONF_Y]))
if lamb := config.get(CONF_LAMBDA):
lambda_ = await cg.process_lambda(

View File

@@ -28,7 +28,7 @@ class Sdl : public display::Display {
this->height_ = height;
}
void set_window_options(uint32_t window_options) { this->window_options_ = window_options; }
void set_position(uint16_t pos_x, uint16_t pos_y) {
void set_position(int32_t pos_x, int32_t pos_y) {
this->pos_x_ = pos_x;
this->pos_y_ = pos_y;
}
@@ -54,8 +54,8 @@ class Sdl : public display::Display {
int width_{};
int height_{};
uint32_t window_options_{0};
int pos_x_{SDL_WINDOWPOS_UNDEFINED};
int pos_y_{SDL_WINDOWPOS_UNDEFINED};
int32_t pos_x_{SDL_WINDOWPOS_UNDEFINED};
int32_t pos_y_{SDL_WINDOWPOS_UNDEFINED};
SDL_Renderer *renderer_{};
SDL_Window *window_{};
SDL_Texture *texture_{};

View File

@@ -1,3 +1,5 @@
// DNM: do not merge. Trivial touch to mark shelly_dimmer changed so the
// memory-impact CI job exercises the no-common-platform fix (see PR #16788).
#include "esphome/core/defines.h"
#include "esphome/core/helpers.h"

View File

@@ -358,11 +358,12 @@
#define USE_LOGGER_USB_SERIAL_JTAG
#elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \
defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \
defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) || \
defined(USE_ESP32_VARIANT_ESP32S31)
defined(USE_ESP32_VARIANT_ESP32H21) || defined(USE_ESP32_VARIANT_ESP32H4) || defined(USE_ESP32_VARIANT_ESP32P4) || \
defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32S31)
#define USE_LOGGER_USB_CDC
#define USE_LOGGER_UART_SELECTION_USB_CDC
#define USE_LOGGER_USB_SERIAL_JTAG
#define USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG
#endif
#endif

View File

@@ -1005,7 +1005,9 @@ def _check_esphome_idf_framework_install(
idf_tools_path = framework_path / "tools" / "idf_tools.py"
_LOGGER.info("Checking ESP-IDF %s framework ...", version)
# Logged every invocation (not just on install) so the user can verify the
# override. A changed URL needs ``esphome clean`` to force a re-download.
# override. A changed URL needs ``esphome clean-all`` to force a re-download
# (``esphome clean`` only wipes the build dir, not the extracted framework
# under <data_dir>/idf/frameworks/<version>).
if source_url:
_LOGGER.info("Using framework source override: %s", source_url)

View File

@@ -16,7 +16,7 @@ from esphome.const import (
KEY_TARGET_PLATFORM,
Toolchain,
)
from esphome.core import CORE
from esphome.core import CORE, EsphomeError
from esphome.helpers import write_file_if_changed
from esphome.types import CoreType
@@ -101,6 +101,7 @@ class StorageJSON:
core_platform: str | None = None,
toolchain: str | None = None,
area: str | None = None,
framework_version: str | None = None,
) -> None:
# Version of the storage JSON schema
assert storage_version is None or isinstance(storage_version, int)
@@ -141,6 +142,8 @@ class StorageJSON:
self.toolchain = toolchain
# The area of the node
self.area = area
# The framework version the build used (for esp32, the resolved ESP-IDF version)
self.framework_version = framework_version
def as_dict(self):
return {
@@ -162,6 +165,7 @@ class StorageJSON:
"core_platform": self.core_platform,
"toolchain": self.toolchain,
"area": self.area,
"framework_version": self.framework_version,
}
def to_json(self):
@@ -173,10 +177,12 @@ class StorageJSON:
@staticmethod
def from_esphome_core(esph: CoreType, old: StorageJSON | None) -> StorageJSON:
hardware = esph.target_platform.upper()
framework_version: str | None = None
if esph.is_esp32:
from esphome.components import esp32
hardware = esp32.get_esp32_variant(esph)
framework_version = str(esp32.idf_version())
return StorageJSON(
storage_version=1,
name=esph.name,
@@ -200,6 +206,7 @@ class StorageJSON:
core_platform=esph.target_platform,
toolchain=esph.toolchain.value if esph.toolchain is not None else None,
area=esph.area,
framework_version=framework_version,
)
@staticmethod
@@ -249,6 +256,7 @@ class StorageJSON:
core_platform = storage.get("core_platform")
toolchain = storage.get("toolchain")
area = storage.get("area")
framework_version = storage.get("framework_version")
return StorageJSON(
storage_version,
name,
@@ -268,6 +276,7 @@ class StorageJSON:
core_platform,
toolchain,
area,
framework_version,
)
@staticmethod
@@ -311,10 +320,24 @@ class StorageJSON:
# esp32.get_esp32_variant(). target_platform on disk is the variant
# (e.g. "ESP32S3"); core_platform is the family (e.g. "esp32").
if target_platform == const.PLATFORM_ESP32:
from esphome.components.esp32.const import KEY_ESP32
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
from esphome.const import KEY_VARIANT
CORE.data[KEY_ESP32] = {KEY_VARIANT: self.target_platform}
esp32_data = {KEY_VARIANT: self.target_platform}
if self.framework_version:
import esphome.config_validation as cv
try:
esp32_data[KEY_IDF_VERSION] = cv.Version.parse(
self.framework_version
)
except ValueError as err:
raise EsphomeError(
f"Could not parse the framework version "
f"{self.framework_version!r} from {storage_path()}. "
f"Please clean the build files and recompile."
) from err
CORE.data[KEY_ESP32] = esp32_data
def __eq__(self, o) -> bool:
return isinstance(o, StorageJSON) and self.as_dict() == o.as_dict()

View File

@@ -93,9 +93,12 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
``src_version`` differs, ``build_path`` differs, the build
``toolchain`` differs (e.g. switching between the PlatformIO and
native ESP-IDF toolchains, which produce incompatible build trees),
or a previously loaded integration was removed in *new*. Adding
integrations or changing unrelated fields (friendly name, esphome
version, etc.) does not trigger a clean.
the ``framework`` or ``framework_version`` differs (e.g. switching
arduino <-> esp-idf, or bumping the ESP-IDF version, which also
produce incompatible build trees), or a previously loaded
integration was removed in *new*. Adding integrations or changing
unrelated fields (friendly name, esphome version, etc.) does not
trigger a clean.
Used by esphome-device-builder (esphome/device-builder) to gate
its remote-build artifact materialiser so a local → remote → local
@@ -113,6 +116,10 @@ def storage_should_clean(old: StorageJSON | None, new: StorageJSON) -> bool:
return True
if old.toolchain != new.toolchain:
return True
if old.framework != new.framework:
return True
if old.framework_version != new.framework_version:
return True
# Check if any components have been removed
return bool(old.loaded_integrations - new.loaded_integrations)
@@ -126,6 +133,13 @@ def storage_should_update_cmake_cache(old: StorageJSON, new: StorageJSON) -> boo
def update_storage_json() -> None:
"""Refresh the storage sidecar and clean an incompatible build.
Runs at the start of ``write_cpp`` -- BEFORE any source/project files are
regenerated -- so the clean below can safely ``full``-wipe the whole build
directory (a switch of toolchain/framework/version also drops the stale
project scaffolding, not just the compiled objects).
"""
path = storage_path()
old = StorageJSON.load(path)
new = StorageJSON.from_esphome_core(CORE, old)
@@ -146,7 +160,7 @@ def update_storage_json() -> None:
)
else:
_LOGGER.info("Core config or version changed, cleaning build files...")
clean_build(clear_pio_cache=False)
clean_build(clear_pio_cache=False, full=True)
elif storage_should_update_cmake_cache(old, new):
_LOGGER.info("Integrations changed, cleaning cmake cache...")
clean_cmake_cache()
@@ -483,48 +497,89 @@ def write_cpp(code_s):
def clean_cmake_cache():
pioenvs = CORE.relative_pioenvs_path()
if pioenvs.is_dir():
pioenvs_cmake_path = pioenvs / CORE.name / "CMakeCache.txt"
if pioenvs_cmake_path.is_file():
_LOGGER.info("Deleting %s", pioenvs_cmake_path)
pioenvs_cmake_path.unlink()
# Drop the CMake cache so a component-set change forces a reconfigure.
# PlatformIO keeps it under .pioenvs/<name>/; the native ESP-IDF toolchain
# keeps it under build/ (where espidf's has_outdated_files() treats a
# missing CMakeCache.txt as stale). Only one exists for a given build.
cmake_cache_paths = (
CORE.relative_pioenvs_path(CORE.name, "CMakeCache.txt"),
CORE.relative_build_path("build", "CMakeCache.txt"),
)
for cmake_cache_path in cmake_cache_paths:
if cmake_cache_path.is_file():
_LOGGER.info("Deleting %s", cmake_cache_path)
cmake_cache_path.unlink()
def clean_build(clear_pio_cache: bool = True):
def clean_build(clear_pio_cache: bool = True, *, full: bool = False):
"""Remove build artifacts.
By default only the compiled outputs are removed (``.pioenvs`` /
``.piolibdeps`` / the native ESP-IDF ``build`` and ``managed_components``
dirs) while the generated ``src/`` and project files are kept. This is what
in-build callers need: they regenerate a source/sdkconfig and then force a
rebuild without discarding the sources they just wrote.
``full=True`` wipes the entire build directory instead. Used by the
``esphome clean`` command and by the pre-build clean in
``update_storage_json`` (which runs before sources are regenerated) -- in
both cases nothing is mid-regeneration, so the next compile rebuilds from
scratch. It also drops stale project scaffolding the allow-list keeps (e.g. a
leftover platformio.ini / CMakeLists.txt from the other toolchain), making a
toolchain switch reliable.
"""
# Allow skipping cache cleaning for integration tests
if os.environ.get("ESPHOME_SKIP_CLEAN_BUILD"):
_LOGGER.warning("Skipping build cleaning (ESPHOME_SKIP_CLEAN_BUILD set)")
return
pioenvs = CORE.relative_pioenvs_path()
if pioenvs.is_dir():
_LOGGER.info("Deleting %s", pioenvs)
rmtree(pioenvs)
piolibdeps = CORE.relative_piolibdeps_path()
if piolibdeps.is_dir():
_LOGGER.info("Deleting %s", piolibdeps)
rmtree(piolibdeps)
dependencies_lock = CORE.relative_build_path("dependencies.lock")
if dependencies_lock.is_file():
_LOGGER.info("Deleting %s", dependencies_lock)
dependencies_lock.unlink()
if full:
if CORE.build_path is not None:
build_path = Path(CORE.build_path)
if build_path.is_dir():
_LOGGER.info("Deleting %s", build_path)
rmtree(build_path)
else:
pioenvs = CORE.relative_pioenvs_path()
if pioenvs.is_dir():
_LOGGER.info("Deleting %s", pioenvs)
rmtree(pioenvs)
piolibdeps = CORE.relative_piolibdeps_path()
if piolibdeps.is_dir():
_LOGGER.info("Deleting %s", piolibdeps)
rmtree(piolibdeps)
dependencies_lock = CORE.relative_build_path("dependencies.lock")
if dependencies_lock.is_file():
_LOGGER.info("Deleting %s", dependencies_lock)
dependencies_lock.unlink()
# Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir
# and the Component Manager's fetched managed components live under
# the project's build path, not under .pioenvs / .piolibdeps.
for name in ("build", "managed_components"):
idf_path = CORE.relative_build_path(name)
if idf_path.is_dir():
_LOGGER.info("Deleting %s", idf_path)
rmtree(idf_path)
# The idedata cache is derived from the build but lives under the data dir,
# not the build path, so it must be removed separately in both modes.
idedata_cache = CORE.relative_internal_path("idedata", f"{CORE.name}.json")
if idedata_cache.is_file():
_LOGGER.info("Deleting %s", idedata_cache)
idedata_cache.unlink()
# Native ESP-IDF toolchain artifacts: the IDF CMake/ninja build dir
# and the Component Manager's fetched managed components live under
# the project's build path, not under .pioenvs / .piolibdeps.
for name in ("build", "managed_components"):
idf_path = CORE.relative_build_path(name)
if idf_path.is_dir():
_LOGGER.info("Deleting %s", idf_path)
rmtree(idf_path)
if not clear_pio_cache:
return
# The native ESP-IDF toolchain caches PlatformIO libraries converted to IDF
# components under <data_dir>/pio_components, shared across builds and keyed
# by source hash (the analog of PlatformIO's global package cache). Drop it
# on an explicit clean so a corrupt/stale converted lib is re-fetched.
pio_components = CORE.relative_internal_path("pio_components")
if pio_components.is_dir():
_LOGGER.info("Deleting %s", pio_components)
rmtree(pio_components)
# Clean PlatformIO cache to resolve CMake compiler detection issues
# This helps when toolchain paths change or get corrupted
try:

View File

@@ -37,7 +37,7 @@ lib_deps_base =
wjtje/qr-code-generator-library@1.7.0 ; qr_code
functionpointer/arduino-MLX90393@1.0.2 ; mlx90393
pavlodn/HaierProtocol@0.9.31 ; haier
esphome/dsmr_parser@1.4.0 ; dsmr
esphome/dsmr_parser@1.8.0 ; dsmr
https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps
; This is using the repository until a new release is published to PlatformIO
https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library

View File

@@ -9,7 +9,7 @@ tzlocal==5.3.1 # from time
tzdata>=2026.2 # from time
pyserial==3.5
platformio==6.1.19
esptool==5.2.0
esptool==5.3.0
click==8.3.3
esphome-dashboard==20260425.0
aioesphomeapi==45.3.1

View File

@@ -70,6 +70,7 @@ from helpers import (
get_changed_components,
get_component_from_path,
get_component_test_files,
get_component_test_platforms,
get_components_with_dependencies,
get_cpp_changed_components,
get_fixture_to_test_files,
@@ -77,7 +78,6 @@ from helpers import (
get_target_branch,
git_ls_files,
is_validate_only_file,
parse_test_filename,
root_path,
)
from split_components_for_ci import create_intelligent_batches
@@ -169,24 +169,6 @@ MEMORY_IMPACT_FALLBACK_COMPONENT = "api" # Representative component for core ch
MEMORY_IMPACT_FALLBACK_PLATFORM = Platform.ESP32_IDF # Most representative platform
MEMORY_IMPACT_MAX_COMPONENTS = 40 # Max components before results become nonsensical
# Platform-specific components that can only be built on their respective platforms
# These components contain platform-specific code and cannot be cross-compiled
# Regular components (wifi, logger, api, etc.) are cross-platform and not listed here
PLATFORM_SPECIFIC_COMPONENTS = frozenset(
{
"esp32", # ESP32 platform implementation
"esp8266", # ESP8266 platform implementation
"rp2040", # Raspberry Pi Pico / RP2040 platform implementation
"libretiny", # LibreTiny base platform implementation
"bk72xx", # Beken BK72xx platform implementation (uses LibreTiny)
"rtl87xx", # Realtek RTL87xx platform implementation (uses LibreTiny)
"ln882x", # Winner Micro LN882x platform implementation (uses LibreTiny)
"host", # Host platform (for testing on development machine)
"nrf52", # Nordic nRF52 platform implementation (uses Zephyr)
"zephyr", # Zephyr RTOS platform implementation
}
)
# Platform preference order for memory impact analysis
# This order is used when no platform-specific hints are detected from filenames
# Priority rationale:
@@ -1006,23 +988,24 @@ def detect_memory_impact_config(
] = {} # Track which platforms each component supports
for component in sorted(changed_component_set):
# Look for test files on preferred platforms
test_files = get_component_test_files(component, all_variants=True)
if not test_files:
continue
# Check if component has tests for any preferred platform
available_platforms = [
platform
for test_file in test_files
if (platform := parse_test_filename(test_file)[1]) != "all"
and platform in MEMORY_IMPACT_PLATFORM_PREFERENCE
]
# Discover the platforms this component has BASE tests for, using the
# same logic as the build runner (get_component_test_platforms wraps the
# shared get_component_test_files + parse_test_filename helpers). Base
# tests only: the memory impact CI build runs test_build_components.py
# with --base-only, which compiles base test.<platform>.yaml files but
# never variant test-<variant>.<platform>.yaml files. Counting
# variant-only platforms here would let us select a platform the build
# then has nothing to compile for, producing no memory output.
available_platforms = {
Platform(platform)
for platform in get_component_test_platforms(component)
if platform in MEMORY_IMPACT_PLATFORM_PREFERENCE
}
if not available_platforms:
continue
component_platforms_map[component] = set(available_platforms)
component_platforms_map[component] = available_platforms
components_with_tests.append(component)
# If no components have tests, don't run memory impact
@@ -1084,20 +1067,52 @@ def detect_memory_impact_config(
)
platform = _select_platform_by_count(platform_counts)
# Filter out platform-specific components that are incompatible with selected platform
# Platform components (esp32, esp8266, rp2040, etc.) can only build on their own platform
# Other components (wifi, logger, etc.) are cross-platform and can build anywhere
compatible_components = [
component
for component in components_with_tests
if component not in PLATFORM_SPECIFIC_COMPONENTS
or platform in component_platforms_map.get(component, set())
]
# Keep only components that have a base test on the selected platform.
# The merged build runs test_build_components.py -t <platform> --base-only,
# so a component without a base test.<platform>.yaml compiles nothing and
# contributes no memory output. This also covers platform-specific
# components (esp32, esp8266, etc.), which only have tests on their own
# platform. When components don't share a common platform we build the
# largest subset that does, dropping the rest.
def components_supporting(target: Platform) -> list[str]:
return [
component
for component in components_with_tests
if target in component_platforms_map.get(component, set())
]
compatible_components = components_supporting(platform)
# A platform hint (or no-common-platform fallback) can pick a platform that
# no changed component actually has a base test for, leaving nothing to
# build. In that case fall back to the platform supported by the most
# components, which by construction is backed by at least one base test.
if not compatible_components:
platform = _select_platform_by_count(
Counter(
p for platforms in component_platforms_map.values() for p in platforms
)
)
compatible_components = components_supporting(platform)
# If no components are compatible with the selected platform, don't run
if not compatible_components:
return {"should_run": "false"}
# Log components dropped because they lack a base test on the selected
# platform so partial-subset builds are visible in CI logs.
dropped_components = [
component
for component in components_with_tests
if component not in compatible_components
]
if dropped_components:
print(
f"Memory impact: Dropping components without a base test on "
f"{platform}: {dropped_components}",
file=sys.stderr,
)
# Debug output
print("Memory impact analysis:", file=sys.stderr)
print(f" Changed components: {sorted(changed_component_set)}", file=sys.stderr)

View File

@@ -149,6 +149,31 @@ def get_component_test_files(
return files
def get_component_test_platforms(component: str, *, base_only: bool = True) -> set[str]:
"""Return the set of platforms a component has compilable test files for.
Uses the same discovery as ``test_build_components.py`` (``get_component_test_files``
+ ``parse_test_filename``) so callers agree with what the build runner would
actually compile. With ``base_only=True`` (the default, matching the
memory-impact build's ``--base-only``), only base ``test.<platform>.yaml``
files are considered; variant ``test-<variant>.<platform>.yaml`` files are
excluded. The ``"all"`` platform sentinel is excluded.
Args:
component: Component name (e.g. "wifi")
base_only: If True, only consider base test files (default).
Returns:
Set of platform identifiers (e.g. {"esp32-idf", "esp8266-ard"}).
"""
platforms: set[str] = set()
for test_file in get_component_test_files(component, all_variants=not base_only):
platform = parse_test_filename(test_file)[1]
if platform != "all":
platforms.add(platform)
return platforms
def is_validate_only_file(test_file: Path) -> bool:
"""Return True if the given path is a config-only validate file.

View File

@@ -42,6 +42,7 @@ from script.analyze_component_buses import (
from script.helpers import (
get_component_test_files,
is_validate_only_file,
parse_test_filename,
split_conflicting_groups,
)
from script.merge_component_configs import merge_component_configs
@@ -122,21 +123,6 @@ def find_component_tests(
return dict(component_tests)
def parse_test_filename(test_file: Path) -> tuple[str, str]:
"""Parse test filename to extract test name and platform.
Args:
test_file: Path to test file
Returns:
Tuple of (test_name, platform)
"""
parts = test_file.stem.split(".")
if len(parts) == 2:
return parts[0], parts[1] # test, platform
return parts[0], "all"
def get_platform_base_files(base_dir: Path) -> dict[str, list[Path]]:
"""Get all platform base files.

View File

@@ -4,77 +4,145 @@ from unittest.mock import patch
import pytest
from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE
from esphome.components.display import (
DisplayMetaData,
add_metadata,
get_all_display_metadata,
get_display_metadata,
)
from esphome.cpp_generator import MockObj
from esphome.config import Config
from esphome.core import ID
from esphome.final_validate import full_config
def test_add_metadata_with_string_id():
"""Test adding metadata with a plain string ID."""
def test_add_metadata_basic():
"""Test adding metadata with an ID object."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("my_display", 320, 240, True)
meta = get_display_metadata("my_display")
add_metadata(ID("my_display"), 320, 240)
meta = get_display_metadata(ID("my_display"))
assert meta == DisplayMetaData(
width=320, height=240, has_writer=True, has_hardware_rotation=False
width=320,
height=240,
has_hardware_rotation=False,
byte_order=BYTE_ORDER_BIG,
)
def test_add_metadata_with_mockobj_id():
"""Test adding metadata with a MockObj ID (converted via str())."""
def test_add_metadata_with_all_fields():
"""Test adding metadata with all fields set."""
with patch("esphome.components.display.CORE.data", {}):
mock_id = MockObj("my_display_obj")
add_metadata(mock_id, 480, 320, False, has_hardware_rotation=True)
meta = get_display_metadata("my_display_obj")
add_metadata(
ID("my_display"),
480,
320,
has_hardware_rotation=True,
byte_order=BYTE_ORDER_LITTLE,
)
meta = get_display_metadata(ID("my_display"))
assert meta == DisplayMetaData(
width=480, height=320, has_writer=False, has_hardware_rotation=True
width=480,
height=320,
has_hardware_rotation=True,
byte_order=BYTE_ORDER_LITTLE,
)
def test_add_metadata_hardware_rotation_default():
"""Test that has_hardware_rotation defaults to False."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("disp", 128, 64, False)
meta = get_display_metadata("disp")
add_metadata(ID("disp"), 128, 64)
meta = get_display_metadata(ID("disp"))
assert meta.has_hardware_rotation is False
assert meta.byte_order == BYTE_ORDER_BIG
def test_get_display_metadata_missing_returns_none():
"""Test that querying a non-existent ID returns None."""
def test_add_metadata_with_byte_order():
"""Test adding metadata with explicit byte_order."""
with patch("esphome.components.display.CORE.data", {}):
data = get_display_metadata("no_such_display")
assert data.width == 0
assert data.height == 0
assert data.has_writer is False
add_metadata(ID("disp"), 240, 320, byte_order=BYTE_ORDER_LITTLE)
meta = get_display_metadata(ID("disp"))
assert meta.byte_order == BYTE_ORDER_LITTLE
def test_get_display_metadata_missing_reads_raw_config():
"""Querying a non-existent ID falls back to raw config lookup."""
with patch("esphome.components.display.CORE.data", {}):
# Set up a minimal full_config with a display entry so the fallback
# path in get_display_metadata can find the display config.
fc = Config()
fc["display"] = [
{
"id": ID("no_such_display", True),
"auto_clear_enabled": True,
"dimensions": {"width": 320, "height": 240},
"byte_order": BYTE_ORDER_LITTLE,
"rotation": 90,
},
{
"id": ID("other_display", True),
"auto_clear_enabled": "undefined",
"dimensions": (1024, 600),
},
]
fc.declare_ids.append((ID("no_such_display", True), ["display", 0, "id"]))
fc.declare_ids.append((ID("other_display", True), ["display", 1, "id"]))
full_config.set(fc)
data = get_display_metadata(ID("no_such_display"))
assert data.width == 320
assert data.height == 240
assert data.has_hardware_rotation is False
assert data.has_writer is True
assert data.byte_order == BYTE_ORDER_LITTLE
assert data.rotation == 90
data = get_display_metadata(ID("other_display"))
assert data.width == 1024
assert data.height == 600
assert data.has_writer is False
def test_add_multiple_displays():
"""Test adding metadata for multiple displays."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("disp_a", 320, 240, True)
add_metadata("disp_b", 128, 64, False, has_hardware_rotation=True)
add_metadata(ID("disp_a"), 320, 240)
add_metadata(ID("disp_b"), 128, 64, has_hardware_rotation=True)
all_meta = get_all_display_metadata()
assert len(all_meta) == 2
assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, False)
assert all_meta["disp_b"] == DisplayMetaData(128, 64, False, True)
assert all_meta["disp_a"] == DisplayMetaData(320, 240, False)
assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, BYTE_ORDER_BIG)
def test_add_metadata_overwrites_existing():
"""Test that adding metadata for the same ID overwrites the previous entry."""
def test_add_duplicate_id_asserts():
"""Adding metadata for the same ID object twice should assert."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata("disp", 320, 240, True)
add_metadata("disp", 640, 480, False, has_hardware_rotation=True)
meta = get_display_metadata("disp")
assert meta == DisplayMetaData(640, 480, False, True)
id_obj = ID("disp")
add_metadata(id_obj, 320, 240)
with pytest.raises(AssertionError, match="Duplicate"):
add_metadata(id_obj, 640, 480)
def test_metadata_is_frozen():
"""Test that DisplayMetaData instances are immutable (frozen dataclass)."""
meta = DisplayMetaData(320, 240, True, False)
meta = DisplayMetaData(320, 240, False, BYTE_ORDER_BIG)
with pytest.raises(AttributeError):
meta.width = 640
with pytest.raises(AttributeError):
meta.byte_order = BYTE_ORDER_LITTLE
def test_get_all_metadata_asserts_on_unresolved_id():
"""get_all_display_metadata should assert if any ID has id=None."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata(ID(None), 320, 240)
with pytest.raises(AssertionError, match="resolved"):
get_all_display_metadata()
def test_get_metadata_asserts_on_unresolved_id():
"""get_display_metadata should assert if any ID has id=None."""
with patch("esphome.components.display.CORE.data", {}):
add_metadata(ID(None), 320, 240)
with pytest.raises(AssertionError, match="resolved"):
get_display_metadata(ID("anything"))

View File

@@ -0,0 +1,47 @@
"""Tests for esp32_ble_server configuration helpers."""
import pytest
from esphome.components.esp32_ble_server import (
CCCD_DESCRIPTOR_UUID,
CUD_DESCRIPTOR_UUID,
DEVICE_INFORMATION_SERVICE_UUID,
uuid_is,
)
@pytest.mark.parametrize(
"uuid",
[
DEVICE_INFORMATION_SERVICE_UUID, # int form (cv.hex_uint32_t)
"180A", # 16 bit short form (bt_uuid)
"180a", # lowercase is normalized by bt_uuid but guard anyway
"0000180A", # 32 bit form
"0000180A-0000-1000-8000-00805F9B34FB", # full 128 bit form
],
)
def test_uuid_is_matches_all_representations(uuid) -> None:
"""All representations of the same 16 bit UUID must compare equal."""
assert uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID)
@pytest.mark.parametrize(
"uuid",
[
0x1818, # Cycling Power Service (different int)
"1818", # different 16 bit short form
"0000180B", # adjacent UUID
"0000180A-0000-1000-8000-00805F9B34FC", # wrong base UUID suffix
],
)
def test_uuid_is_rejects_other_uuids(uuid) -> None:
"""A different UUID must not be mistaken for the device information service."""
assert not uuid_is(uuid, DEVICE_INFORMATION_SERVICE_UUID)
@pytest.mark.parametrize("uuid16", [CUD_DESCRIPTOR_UUID, CCCD_DESCRIPTOR_UUID])
def test_uuid_is_matches_descriptor_short_strings(uuid16) -> None:
"""Reserved descriptor UUIDs match whether given as int or short string."""
assert uuid_is(uuid16, uuid16)
assert uuid_is(f"{uuid16:04X}", uuid16)
assert uuid_is(f"{uuid16:08X}", uuid16)

View File

@@ -0,0 +1,177 @@
"""Tests for LVGL final_validation display metadata checks."""
from __future__ import annotations
import pytest
from esphome.components.const import BYTE_ORDER_BIG, BYTE_ORDER_LITTLE, CONF_BYTE_ORDER
from esphome.components.display import add_metadata
from esphome.components.lvgl import final_validation
from esphome.config import Config
from esphome.config_validation import Invalid
from esphome.const import KEY_CORE, KEY_TARGET_FRAMEWORK, KEY_TARGET_PLATFORM
from esphome.core import CORE, ID
from esphome.final_validate import full_config
@pytest.fixture(autouse=True)
def _setup_core():
"""Ensure CORE.data has enough context for final_validation."""
CORE.data[KEY_CORE] = {
KEY_TARGET_PLATFORM: "host",
KEY_TARGET_FRAMEWORK: "",
}
full_config.set(Config())
yield
CORE.reset()
def _register_displays(*display_ids: str) -> None:
"""Register display IDs in full_config so get_path_for_id works."""
fc = full_config.get()
display_list = [{"id": ID(d, True)} for d in display_ids]
fc["display"] = display_list
for i, disp_id in enumerate(display_ids):
fc.declare_ids.append((ID(disp_id, True), ["display", i, "id"]))
def _make_lvgl_config(
display_ids: list[str],
byte_order: str | None = None,
) -> dict:
"""Build a minimal LVGL config dict for final_validation."""
_register_displays(*display_ids)
config = {
"displays": [ID(d, True) for d in display_ids],
"log_level": "WARN",
"color_depth": 16,
"transparency_key": 0x000400,
"draw_rounding": 2,
"buffer_size": 0,
}
if byte_order is not None:
config[CONF_BYTE_ORDER] = byte_order
return config
class TestByteOrderAutoConfig:
"""Test that LVGL auto-configures byte_order from display metadata."""
def test_inherits_big_endian_from_display(self) -> None:
"""LVGL should inherit big_endian from display metadata."""
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG
def test_inherits_little_endian_from_display(self) -> None:
"""LVGL should inherit little_endian from display metadata."""
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE
def test_defaults_to_big_endian_when_no_metadata(self) -> None:
"""LVGL should default to big_endian when display has no metadata."""
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_BIG
class TestByteOrderExplicitMismatchError:
"""Test that LVGL rejects explicit byte_order mismatch with display."""
def test_raises_on_mismatch(self) -> None:
"""Explicit LVGL byte_order different from display should raise."""
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_LITTLE)
configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)]
with pytest.raises(
Invalid, match="LVGL byte order must match the display byte order"
):
final_validation(configs)
def test_no_error_when_matching(self) -> None:
"""Explicit LVGL byte_order matching display should pass."""
add_metadata(ID("my_disp"), 320, 240, byte_order=BYTE_ORDER_BIG)
configs = [_make_lvgl_config(["my_disp"], byte_order=BYTE_ORDER_BIG)]
final_validation(configs)
class TestByteOrderMultipleDisplays:
"""Test byte_order validation with multiple displays."""
def test_consistent_displays_inherit(self) -> None:
"""All displays with same byte_order should set LVGL byte_order."""
add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_LITTLE)
add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE)
configs = [_make_lvgl_config(["disp_a", "disp_b"])]
final_validation(configs)
assert configs[0][CONF_BYTE_ORDER] == BYTE_ORDER_LITTLE
def test_inconsistent_displays_raises(self) -> None:
"""Displays with different byte_order should raise an error."""
add_metadata(ID("disp_a"), 320, 240, byte_order=BYTE_ORDER_BIG)
add_metadata(ID("disp_b"), 128, 64, byte_order=BYTE_ORDER_LITTLE)
configs = [_make_lvgl_config(["disp_a", "disp_b"])]
with pytest.raises(Invalid, match="same byte_order"):
final_validation(configs)
class TestHasWriterCheck:
"""Test that LVGL rejects displays with has_writer set."""
def test_display_with_writer_raises(self) -> None:
"""Display with lambda/pages/auto_clear should be rejected."""
add_metadata(ID("my_disp"), 320, 240, has_writer=True)
configs = [_make_lvgl_config(["my_disp"])]
with pytest.raises(Invalid, match="not compatible with LVGL"):
final_validation(configs)
def test_display_without_writer_passes(self) -> None:
"""Display without writer should pass."""
add_metadata(ID("my_disp"), 320, 240, has_writer=False)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
class TestRotationCheck:
"""Test that LVGL rejects displays with non-zero rotation."""
def test_display_with_rotation_raises(self) -> None:
"""Display with rotation should be rejected."""
add_metadata(ID("my_disp"), 320, 240, rotation=90)
configs = [_make_lvgl_config(["my_disp"])]
with pytest.raises(Invalid, match="rotation.*not compatible with LVGL"):
final_validation(configs)
def test_display_without_rotation_passes(self) -> None:
"""Display with rotation=0 should pass."""
add_metadata(ID("my_disp"), 320, 240, rotation=0)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
class TestDrawRoundingMerge:
"""Test that display draw_rounding is merged into LVGL config."""
def test_display_draw_rounding_overrides_lower(self) -> None:
"""Display draw_rounding higher than LVGL default should win."""
add_metadata(ID("my_disp"), 320, 240, draw_rounding=8)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0]["draw_rounding"] == 8
def test_display_draw_rounding_does_not_lower(self) -> None:
"""Display draw_rounding lower than LVGL config should not reduce it."""
add_metadata(ID("my_disp"), 320, 240, draw_rounding=1)
configs = [_make_lvgl_config(["my_disp"])]
configs[0]["draw_rounding"] = 4
final_validation(configs)
assert configs[0]["draw_rounding"] == 4
def test_zero_draw_rounding_no_change(self) -> None:
"""Display with draw_rounding=0 should not affect LVGL config."""
add_metadata(ID("my_disp"), 320, 240, draw_rounding=0)
configs = [_make_lvgl_config(["my_disp"])]
final_validation(configs)
assert configs[0]["draw_rounding"] == 2

View File

@@ -3,22 +3,15 @@
from collections.abc import Callable
from pathlib import Path
from esphome.components.display import (
DisplayMetaData,
get_all_display_metadata,
get_display_metadata,
)
from esphome.components.const import BYTE_ORDER_BIG
from esphome.components.display import get_all_display_metadata, get_display_metadata
from esphome.components.esp32 import (
KEY_BOARD,
KEY_VARIANT,
VARIANT_ESP32,
VARIANT_ESP32S3,
)
from esphome.components.mipi_spi.display import (
CONFIG_SCHEMA,
FINAL_VALIDATE_SCHEMA,
get_instance,
)
from esphome.components.mipi_spi.display import CONFIG_SCHEMA, FINAL_VALIDATE_SCHEMA
from esphome.const import PlatformFramework
from tests.component_tests.types import SetCoreConfigCallable
@@ -38,38 +31,32 @@ def test_metadata_native_quad_default_test_card(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
config = validated_config({"model": "JC3636W518"})
get_instance(config)
meta = get_display_metadata(str(config["id"]))
config = CONFIG_SCHEMA({"model": "JC3636W518", "id": "jc3232w518"})
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 360
assert meta.height == 360
# final validation auto-enables show_test_card when no drawing methods are configured
assert meta.has_writer is True
assert meta.has_hardware_rotation is True
assert meta.byte_order == BYTE_ORDER_BIG
def test_metadata_single_mode_with_dc_pin(
set_core_config: SetCoreConfigCallable,
) -> None:
"""A single-mode display with no explicit drawing gets a test card from final validation."""
"""A single-mode display with no explicit drawing gets metadata from schema validation."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "ST7735",
"dc_pin": 18,
}
config = CONFIG_SCHEMA(
{"model": "ST7735", "dc_pin": 18, "id": "single_mode_with_dc_pin"}
)
get_instance(config)
meta = get_display_metadata(str(config["id"]))
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 128
assert meta.height == 160
assert meta.has_writer is True
assert meta.has_hardware_rotation is True
assert meta.byte_order == BYTE_ORDER_BIG
def test_metadata_custom_dimensions(
@@ -80,47 +67,22 @@ def test_metadata_custom_dimensions(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
config = CONFIG_SCHEMA(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 480, "height": 320},
"init_sequence": [[0xA0, 0x01]],
"id": "custom_dimensions",
}
)
get_instance(config)
meta = get_display_metadata(str(config["id"]))
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.width == 480
assert meta.height == 320
# final validation auto-enables show_test_card
assert meta.has_writer is True
assert meta.has_hardware_rotation is True
def test_metadata_with_test_card_has_writer(
set_core_config: SetCoreConfigCallable,
) -> None:
"""When show_test_card is enabled, has_writer should be True."""
set_core_config(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config = validated_config(
{
"model": "custom",
"dc_pin": 18,
"dimensions": {"width": 240, "height": 240},
"init_sequence": [[0xA0, 0x01]],
"show_test_card": True,
}
)
get_instance(config)
meta = get_display_metadata(str(config["id"]))
assert meta is not None
assert meta.has_writer is True
def test_metadata_no_swap_xy_not_full_hardware_rotation(
set_core_config: SetCoreConfigCallable,
) -> None:
@@ -130,9 +92,8 @@ def test_metadata_no_swap_xy_not_full_hardware_rotation(
platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3},
)
# JC3248W535 has swap_xy=cv.UNDEFINED -> transforms={mirror_x, mirror_y} only
config = validated_config({"model": "JC3248W535"})
get_instance(config)
meta = get_display_metadata(str(config["id"]))
config = CONFIG_SCHEMA({"model": "JC3248W535", "id": "jc3248w535"})
meta = get_display_metadata(config["id"])
assert meta is not None
assert meta.has_hardware_rotation is False
@@ -145,7 +106,7 @@ def test_metadata_multiple_displays_independent(
PlatformFramework.ESP32_IDF,
platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32},
)
config_a = validated_config(
CONFIG_SCHEMA(
{
"id": "disp_a",
"model": "custom",
@@ -154,7 +115,7 @@ def test_metadata_multiple_displays_independent(
"init_sequence": [[0xA0, 0x01]],
}
)
config_b = validated_config(
CONFIG_SCHEMA(
{
"id": "disp_b",
"model": "custom",
@@ -163,13 +124,16 @@ def test_metadata_multiple_displays_independent(
"init_sequence": [[0xA0, 0x01]],
}
)
get_instance(config_a)
get_instance(config_b)
all_meta = get_all_display_metadata()
# final validation auto-enables show_test_card for both
assert all_meta["disp_a"] == DisplayMetaData(320, 240, True, True)
assert all_meta["disp_b"] == DisplayMetaData(128, 64, True, True)
assert all_meta["disp_a"].width == 320
assert all_meta["disp_a"].height == 240
assert all_meta["disp_a"].has_hardware_rotation is True
assert all_meta["disp_a"].byte_order == BYTE_ORDER_BIG
assert all_meta["disp_b"].width == 128
assert all_meta["disp_b"].height == 64
assert all_meta["disp_b"].has_hardware_rotation is True
assert all_meta["disp_b"].byte_order == BYTE_ORDER_BIG
def test_metadata_via_code_generation_native(
@@ -179,12 +143,13 @@ def test_metadata_via_code_generation_native(
"""Full code generation for native.yaml should produce correct metadata."""
generate_main(component_fixture_path("native.yaml"))
all_meta = get_all_display_metadata()
# native.yaml: model JC3636W518 -> 360x360, no writer, full hardware rotation
# native.yaml: model JC3636W518 -> 360x360, full hardware rotation
assert len(all_meta) == 1
meta = next(iter(all_meta.values()))
assert meta == DisplayMetaData(
width=360, height=360, has_writer=True, has_hardware_rotation=True
)
assert meta.width == 360
assert meta.height == 360
assert meta.has_hardware_rotation is True
assert meta.byte_order == BYTE_ORDER_BIG
def test_metadata_via_code_generation_lvgl(
@@ -194,9 +159,10 @@ def test_metadata_via_code_generation_lvgl(
"""Full code generation for lvgl.yaml should produce correct metadata."""
generate_main(component_fixture_path("lvgl.yaml"))
all_meta = get_all_display_metadata()
# lvgl.yaml: model ST7735 -> 128x160, no writer (lvgl draws directly), full hw rotation
# lvgl.yaml: model ST7735 -> 128x160, full hw rotation
assert len(all_meta) == 1
meta = next(iter(all_meta.values()))
assert meta == DisplayMetaData(
width=128, height=160, has_writer=False, has_hardware_rotation=True
)
assert meta.width == 128
assert meta.height == 160
assert meta.has_hardware_rotation is True
assert meta.byte_order == BYTE_ORDER_BIG

View File

@@ -0,0 +1,37 @@
display:
- platform: qspi_dbi
model: RM690B0
data_rate: 80MHz
spi_mode: mode0
dimensions:
width: 450
height: 600
offset_width: 16
color_order: rgb
invert_colors: false
brightness: 255
cs_pin: 11
reset_pin: 13
enable_pin: 9
- platform: qspi_dbi
model: CUSTOM
id: main_lcd
draw_from_origin: true
dimensions:
height: 240
width: 536
transform:
mirror_x: true
swap_xy: true
color_order: rgb
brightness: 255
cs_pin: 6
reset_pin: 17
enable_pin: 38
init_sequence:
- [0x3A, 0x66]
- [0x11]
- delay 120ms
- [0x29]
- delay 20ms

View File

@@ -0,0 +1,7 @@
# DNM proof: this is a VARIANT test (test-display.*) with no base test.*.yaml.
# The memory-impact build runs --base-only, so const has nothing buildable and
# must be excluded; it only contributes an esp32-s3-idf platform hint.
packages:
qspi: !include ../../test_build_components/common/qspi/esp32-s3-idf.yaml
<<: !include common.yaml

View File

@@ -0,0 +1,4 @@
packages:
i2c: !include ../../test_build_components/common/i2c/host.yaml
<<: !include common.yaml

View File

@@ -0,0 +1,3 @@
logger:
hardware_uart: UART0
baud_rate: 0

View File

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

View File

@@ -10,6 +10,28 @@ display:
dimensions:
width: 450
height: 600
window_options:
position:
x: 100
y: 100
- platform: sdl
id: second_display
dimensions:
width: 450
height: 600
window_options:
position:
centered_on_display: 1
- platform: sdl
id: third_display
dimensions:
width: 450
height: 600
window_options:
position:
centered_on_display: 0
binary_sensor:
- platform: sdl

View File

@@ -1426,7 +1426,15 @@ def test_detect_memory_impact_config_core_python_only_changes(tmp_path: Path) ->
@pytest.mark.usefixtures("mock_target_branch_dev")
def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
"""Test memory impact detection when components have no common platform."""
"""Test memory impact detection when components have no common platform.
The merged build runs with --base-only on a single platform, so components
without a base test on the selected platform cannot be built and must be
dropped. We build the largest subset that shares the selected platform
rather than handing the runner components it has nothing to compile for
(which previously produced "0 passed, 0 failed" and a failed memory
extraction).
"""
# Create test directory structure
tests_dir = tmp_path / "tests" / "components"
@@ -1453,12 +1461,70 @@ def test_detect_memory_impact_config_no_common_platform(tmp_path: Path) -> None:
result = determine_jobs.detect_memory_impact_config()
# Should pick the most frequently supported platform
# No common platform: pick the most preferred platform among those supported
# (esp8266-ard outranks esp32-idf in the preference list) and build only the
# components that have a base test on it. wifi (esp32-idf only) is dropped.
assert result["should_run"] == "true"
assert set(result["components"]) == {"wifi", "logger"}
# When no common platform, picks most commonly supported
# esp8266-ard is preferred over esp32-idf in the preference list
assert result["platform"] in ["esp32-idf", "esp8266-ard"]
assert result["platform"] == "esp8266-ard"
assert result["components"] == ["logger"]
assert result["use_merged_config"] == "true"
def test_detect_memory_impact_config_variant_only_platform_excluded(
tmp_path: Path,
) -> None:
"""Regression test for the const + shelly_dimmer memory-impact failure.
Reproduces https://github.com/esphome/esphome/actions/runs/26746938473
where a platform hint selected esp32-idf even though neither changed
component had a base test.esp32-idf.yaml. The merged --base-only build then
found nothing to compile ("0 passed, 0 failed") and memory extraction
failed. Also covers a component whose only esp32-idf test is a *variant*
(test-*.esp32-idf.yaml): --base-only never compiles variants, so it must
not count toward platform availability.
"""
tests_dir = tmp_path / "tests" / "components"
# const: base test only on esp32-s3-idf
const_dir = tests_dir / "const"
const_dir.mkdir(parents=True)
(const_dir / "test.esp32-s3-idf.yaml").write_text("test: const")
# shelly_dimmer: base test only on esp8266-ard
shelly_dir = tests_dir / "shelly_dimmer"
shelly_dir.mkdir(parents=True)
(shelly_dir / "test.esp8266-ard.yaml").write_text("test: shelly_dimmer")
# mdns: only a VARIANT test on esp32-idf (no base test.esp32-idf.yaml).
# --base-only would never build it, so it must be excluded entirely.
mdns_dir = tests_dir / "mdns"
mdns_dir.mkdir(parents=True)
(mdns_dir / "test-min.esp32-idf.yaml").write_text("test: mdns")
with (
patch.object(determine_jobs, "root_path", str(tmp_path)),
patch.object(helpers, "root_path", str(tmp_path)),
patch.object(determine_jobs, "changed_files") as mock_changed_files,
):
# The "_esp32" filename yields an esp32-idf platform hint, reproducing
# the original bug where the hint picked a platform no component could
# build as a base test.
mock_changed_files.return_value = [
"esphome/components/const/const.cpp",
"esphome/components/shelly_dimmer/shelly_dimmer_esp32.cpp",
"esphome/components/mdns/mdns.cpp",
]
result = determine_jobs.detect_memory_impact_config()
# The esp32-idf hint is unbuildable (no base test), so we fall back to the
# platform supported by the most components, broken by preference order:
# esp8266-ard (shelly_dimmer) outranks esp32-s3-idf (const). Only the
# component with a base test on the selected platform is returned; the
# variant-only mdns is excluded entirely.
assert result["should_run"] == "true"
assert result["platform"] == "esp8266-ard"
assert result["components"] == ["shelly_dimmer"]
assert result["use_merged_config"] == "true"
@@ -1545,12 +1611,16 @@ def test_detect_memory_impact_config_includes_base_bus_components(
@pytest.mark.usefixtures("mock_target_branch_dev")
def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None:
"""Test memory impact detection for components with only variant test files.
def test_detect_memory_impact_config_variant_only_components_skipped(
tmp_path: Path,
) -> None:
"""Components with only variant tests are skipped for memory impact.
This verifies that memory impact analysis works correctly for components like
improv_serial, ethernet, mdns, etc. which only have variant test files
(test-*.yaml) instead of base test files (test.*.yaml).
Components like improv_serial and ethernet only have variant test files
(test-*.yaml), no base test.<platform>.yaml. The memory-impact build runs
test_build_components.py with --base-only, which never compiles variants, so
these components have nothing buildable and must not be selected. Selecting
them previously produced "0 passed, 0 failed" and a failed memory extraction.
"""
# Create test directory structure
tests_dir = tmp_path / "tests" / "components"
@@ -1581,12 +1651,8 @@ def test_detect_memory_impact_config_with_variant_tests(tmp_path: Path) -> None:
result = determine_jobs.detect_memory_impact_config()
# Should detect both components even though they only have variant tests
assert result["should_run"] == "true"
assert set(result["components"]) == {"improv_serial", "ethernet"}
# Both components support esp32-idf
assert result["platform"] == "esp32-idf"
assert result["use_merged_config"] == "true"
# Neither component has a base test, so nothing is buildable under --base-only
assert result["should_run"] == "false"
# Tests for clang-tidy split mode logic

View File

@@ -0,0 +1,20 @@
esphome:
name: componenttestesp32h2idf
friendly_name: $component_name
esp32:
board: esp32-h2-devkitm-1
framework:
type: esp-idf
# Use custom partition table with larger app partition (3MB)
# Default IDF partitions only allow 1.75MB which is too small for grouped tests
partitions: ../partitions_testing.csv
logger:
level: VERY_VERBOSE
packages:
component_under_test: !include
file: $component_test_file
vars:
component_test_file: $component_test_file

View File

@@ -0,0 +1,7 @@
# Common I2C configuration for host platform tests
i2c:
- id: i2c_bus
device: /dev/i2c-0
frequency: 100kHz
scan: true

View File

@@ -12,13 +12,6 @@ from esphome.build_gen import platformio
from esphome.core import CORE
@pytest.fixture
def mock_update_storage_json() -> Generator[MagicMock]:
"""Mock update_storage_json for all tests."""
with patch("esphome.build_gen.platformio.update_storage_json") as mock:
yield mock
@pytest.fixture
def mock_write_file_if_changed() -> Generator[MagicMock]:
"""Mock write_file_if_changed for tests."""
@@ -26,9 +19,7 @@ def mock_write_file_if_changed() -> Generator[MagicMock]:
yield mock
def test_write_ini_creates_new_file(
tmp_path: Path, mock_update_storage_json: MagicMock
) -> None:
def test_write_ini_creates_new_file(tmp_path: Path) -> None:
"""Test write_ini creates a new platformio.ini file."""
CORE.build_path = str(tmp_path)
@@ -50,9 +41,7 @@ framework = arduino
assert platformio.INI_AUTO_GENERATE_END in file_content
def test_write_ini_updates_existing_file(
tmp_path: Path, mock_update_storage_json: MagicMock
) -> None:
def test_write_ini_updates_existing_file(tmp_path: Path) -> None:
"""Test write_ini updates existing platformio.ini file."""
CORE.build_path = str(tmp_path)
@@ -97,9 +86,7 @@ framework = arduino
assert "platform = old" not in file_content
def test_write_ini_preserves_custom_sections(
tmp_path: Path, mock_update_storage_json: MagicMock
) -> None:
def test_write_ini_preserves_custom_sections(tmp_path: Path) -> None:
"""Test write_ini preserves custom sections outside auto-generate markers."""
CORE.build_path = str(tmp_path)
@@ -148,7 +135,6 @@ monitor_speed = 115200
def test_write_ini_no_change_when_content_same(
tmp_path: Path,
mock_update_storage_json: MagicMock,
mock_write_file_if_changed: MagicMock,
) -> None:
"""Test write_ini doesn't rewrite file when content is unchanged."""
@@ -174,15 +160,3 @@ def test_write_ini_no_change_when_content_same(
call_args = mock_write_file_if_changed.call_args[0]
assert call_args[0] == ini_file
assert content in call_args[1]
def test_write_ini_calls_update_storage_json(
tmp_path: Path, mock_update_storage_json: MagicMock
) -> None:
"""Test write_ini calls update_storage_json."""
CORE.build_path = str(tmp_path)
content = "[env:test]\nplatform = esp32"
platformio.write_ini(content)
mock_update_storage_json.assert_called_once()

View File

@@ -148,3 +148,12 @@ def test_get_idedata_regenerates_on_corrupted_cache(setup_core: Path) -> None:
mock_transform.assert_called_once()
assert result == {"cxx_path": "regen"}
def test_get_core_framework_version_from_core_data():
"""The version is read from CORE.data when validation populated it."""
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
import esphome.config_validation as cv
CORE.data = {KEY_ESP32: {KEY_IDF_VERSION: cv.Version(5, 5, 4)}}
assert toolchain._get_core_framework_version() == "5.5.4"

View File

@@ -8,7 +8,7 @@ from unittest.mock import MagicMock, Mock, patch
import pytest
from esphome import storage_json
from esphome import config_validation as cv, storage_json
from esphome.const import CONF_DISABLED, CONF_MDNS, Toolchain
from esphome.core import CORE
@@ -206,6 +206,7 @@ def test_storage_json_as_dict() -> None:
framework="arduino",
core_platform="esp32",
area="Living Room",
framework_version="5.3.1",
)
result = storage.as_dict()
@@ -235,6 +236,7 @@ def test_storage_json_as_dict() -> None:
assert result["framework"] == "arduino"
assert result["core_platform"] == "esp32"
assert result["area"] == "Living Room"
assert result["framework_version"] == "5.3.1"
def test_storage_json_to_json() -> None:
@@ -313,8 +315,12 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
mock_core.toolchain = Toolchain.ESP_IDF
mock_core.area = "Living Room"
with patch("esphome.components.esp32.get_esp32_variant") as mock_variant:
with (
patch("esphome.components.esp32.get_esp32_variant") as mock_variant,
patch("esphome.components.esp32.idf_version") as mock_idf_version,
):
mock_variant.return_value = "ESP32-C3"
mock_idf_version.return_value = cv.Version(5, 3, 1)
result = storage_json.StorageJSON.from_esphome_core(mock_core, old=None)
@@ -333,6 +339,7 @@ def test_storage_json_from_esphome_core(setup_core: Path) -> None:
assert result.core_platform == "esp32"
assert result.toolchain == "esp-idf"
assert result.area == "Living Room"
assert result.framework_version == "5.3.1"
def test_storage_json_from_esphome_core_mdns_enabled(setup_core: Path) -> None:
@@ -545,6 +552,51 @@ def test_storage_json_apply_to_core_ignores_unknown_toolchain(
assert CORE.toolchain is None
def test_storage_json_framework_version_round_trip(setup_core: Path) -> None:
"""Sidecar framework_version restores CORE.data[esp32][idf_version]."""
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
storage = _make_storage_with_toolchain("esp-idf")
storage.framework_version = "5.3.1"
path = setup_core / "storage.json"
path.write_text(storage.to_json())
assert json.loads(path.read_text())["framework_version"] == "5.3.1"
loaded = storage_json.StorageJSON.load(path)
assert loaded is not None
assert loaded.framework_version == "5.3.1"
loaded.apply_to_core()
assert CORE.data[KEY_ESP32][KEY_IDF_VERSION] == cv.Version(5, 3, 1)
def test_storage_json_apply_to_core_without_framework_version(
setup_core: Path,
) -> None:
"""Older sidecars lacking framework_version don't populate idf_version."""
from esphome.components.esp32.const import KEY_ESP32, KEY_IDF_VERSION
loaded = _make_storage_with_toolchain("esp-idf")
assert loaded.framework_version is None
loaded.apply_to_core()
assert KEY_IDF_VERSION not in CORE.data[KEY_ESP32]
def test_storage_json_apply_to_core_raises_on_invalid_framework_version(
setup_core: Path,
) -> None:
"""A malformed version string fails with an actionable error at parse time."""
from esphome.core import EsphomeError
loaded = _make_storage_with_toolchain("esp-idf")
loaded.framework_version = "not-a-version"
with pytest.raises(EsphomeError, match="clean the build"):
loaded.apply_to_core()
def test_esphome_storage_json_as_dict() -> None:
"""Test EsphomeStorageJSON.as_dict returns correct dictionary."""
storage = storage_json.EsphomeStorageJSON(

View File

@@ -112,6 +112,7 @@ def create_storage() -> Callable[..., StorageJSON]:
framework=kwargs.get("framework", "arduino"),
core_platform=kwargs.get("core_platform", "esp32"),
toolchain=kwargs.get("toolchain", "platformio"),
framework_version=kwargs.get("framework_version"),
)
return _create
@@ -157,6 +158,32 @@ def test_storage_should_clean_when_toolchain_changes(
assert storage_should_clean(old, new) is True
def test_storage_should_clean_when_framework_changes(
create_storage: Callable[..., StorageJSON],
) -> None:
"""Test that clean is triggered when the framework changes.
Switching between arduino and esp-idf produces incompatible build trees
even on the same toolchain, so the build must be wiped.
"""
old = create_storage(loaded_integrations=["api", "wifi"], framework="arduino")
new = create_storage(loaded_integrations=["api", "wifi"], framework="esp-idf")
assert storage_should_clean(old, new) is True
def test_storage_should_clean_when_framework_version_changes(
create_storage: Callable[..., StorageJSON],
) -> None:
"""Test that clean is triggered when the framework version changes.
A different framework/ESP-IDF version compiles against a different SDK, so
the stale build tree must be wiped.
"""
old = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.3.1")
new = create_storage(loaded_integrations=["api", "wifi"], framework_version="5.4.0")
assert storage_should_clean(old, new) is True
def test_storage_should_clean_when_component_removed(
create_storage: Callable[..., StorageJSON],
) -> None:
@@ -341,8 +368,8 @@ def test_update_storage_json_logging_when_old_is_none(
with caplog.at_level("INFO"):
update_storage_json()
# Verify clean_build was called
mock_clean_build.assert_called_once()
# Verify clean_build was called with a full wipe (runs before src is written)
mock_clean_build.assert_called_once_with(clear_pio_cache=False, full=True)
# Verify the correct log message was used (not the component removal message)
assert "Core config or version changed, cleaning build files..." in caplog.text
@@ -392,60 +419,50 @@ def test_update_storage_json_logging_components_removed(
new_storage.save.assert_called_once_with("/test/path")
def _mock_cmake_cache_paths(mock_core: MagicMock, tmp_path: Path) -> None:
"""Wire relative_pioenvs_path/relative_build_path to tmp_path subtrees."""
mock_core.name = "test_device"
mock_core.relative_pioenvs_path.side_effect = (tmp_path / ".pioenvs").joinpath
mock_core.relative_build_path.side_effect = tmp_path.joinpath
@patch("esphome.writer.CORE")
def test_clean_cmake_cache(
def test_clean_cmake_cache_platformio(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_cmake_cache removes CMakeCache.txt file."""
# Create directory structure
pioenvs_dir = tmp_path / ".pioenvs"
pioenvs_dir.mkdir()
device_dir = pioenvs_dir / "test_device"
device_dir.mkdir()
cmake_cache_file = device_dir / "CMakeCache.txt"
"""Test clean_cmake_cache removes the PlatformIO CMakeCache.txt."""
_mock_cmake_cache_paths(mock_core, tmp_path)
cmake_cache_file = tmp_path / ".pioenvs" / "test_device" / "CMakeCache.txt"
cmake_cache_file.parent.mkdir(parents=True)
cmake_cache_file.write_text("# CMake cache file")
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.name = "test_device"
# Verify file exists before
assert cmake_cache_file.exists()
# Call the function
with caplog.at_level("INFO"):
clean_cmake_cache()
# Verify file was removed
assert not cmake_cache_file.exists()
# Verify logging
assert "Deleting" in caplog.text
assert "CMakeCache.txt" in caplog.text
@patch("esphome.writer.CORE")
def test_clean_cmake_cache_no_pioenvs_dir(
def test_clean_cmake_cache_esp_idf(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test clean_cmake_cache when pioenvs directory doesn't exist."""
# Setup non-existent directory path
pioenvs_dir = tmp_path / ".pioenvs"
"""Test clean_cmake_cache removes the native ESP-IDF build/CMakeCache.txt."""
_mock_cmake_cache_paths(mock_core, tmp_path)
cmake_cache_file = tmp_path / "build" / "CMakeCache.txt"
cmake_cache_file.parent.mkdir(parents=True)
cmake_cache_file.write_text("# CMake cache file")
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
with caplog.at_level("INFO"):
clean_cmake_cache()
# Verify directory doesn't exist
assert not pioenvs_dir.exists()
# Call the function - should not crash
clean_cmake_cache()
# Verify directory still doesn't exist
assert not pioenvs_dir.exists()
assert not cmake_cache_file.exists()
assert str(cmake_cache_file) in caplog.text
@patch("esphome.writer.CORE")
@@ -453,27 +470,11 @@ def test_clean_cmake_cache_no_cmake_file(
mock_core: MagicMock,
tmp_path: Path,
) -> None:
"""Test clean_cmake_cache when CMakeCache.txt doesn't exist."""
# Create directory structure without CMakeCache.txt
pioenvs_dir = tmp_path / ".pioenvs"
pioenvs_dir.mkdir()
device_dir = pioenvs_dir / "test_device"
device_dir.mkdir()
cmake_cache_file = device_dir / "CMakeCache.txt"
"""Test clean_cmake_cache when no CMakeCache.txt exists -- should not crash."""
_mock_cmake_cache_paths(mock_core, tmp_path)
# Setup mocks
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.name = "test_device"
# Verify file doesn't exist
assert not cmake_cache_file.exists()
# Call the function - should not crash
clean_cmake_cache()
# Verify file still doesn't exist
assert not cmake_cache_file.exists()
@patch("esphome.writer.CORE")
def test_clean_build(
@@ -507,6 +508,11 @@ def test_clean_build(
managed_components_dir.mkdir()
(managed_components_dir / "espressif__arduino-esp32").mkdir()
# Converted-PIO-library cache (native ESP-IDF), under the data dir.
pio_components_dir = tmp_path / "pio_components"
pio_components_dir.mkdir()
(pio_components_dir / "abc12345").mkdir()
# Create PlatformIO cache directory
platformio_cache_dir = tmp_path / ".platformio" / ".cache"
platformio_cache_dir.mkdir(parents=True)
@@ -529,6 +535,7 @@ def test_clean_build(
assert idedata_cache.exists()
assert idf_build_dir.exists()
assert managed_components_dir.exists()
assert pio_components_dir.exists()
assert platformio_cache_dir.exists()
# Mock PlatformIO's ProjectConfig cache_dir
@@ -554,6 +561,7 @@ def test_clean_build(
assert not idedata_cache.exists()
assert not idf_build_dir.exists()
assert not managed_components_dir.exists()
assert not pio_components_dir.exists()
assert not platformio_cache_dir.exists()
# Verify logging
@@ -567,6 +575,41 @@ def test_clean_build(
assert "PlatformIO cache" in caplog.text
@patch("esphome.writer.CORE")
def test_clean_build_full_wipes_build_dir(
mock_core: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""full=True wipes the whole build dir (incl. src/) but keeps siblings."""
build_dir = tmp_path / "build" / "test"
(build_dir / "src").mkdir(parents=True)
(build_dir / "src" / "main.cpp").write_text("// generated")
(build_dir / "platformio.ini").write_text("[platformio]")
(build_dir / ".pioenvs").mkdir()
idedata_cache = tmp_path / "idedata" / "test.json"
idedata_cache.parent.mkdir()
idedata_cache.write_text("{}")
# A sibling of the build dir (under the data dir) must survive.
survivor = tmp_path / "keep_me.txt"
survivor.write_text("keep")
# build_path may be a str (e.g. set from config); clean_build must coerce.
mock_core.build_path = str(build_dir)
mock_core.name = "test"
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
with caplog.at_level("INFO"):
clean_build(clear_pio_cache=False, full=True)
assert not build_dir.exists()
assert not idedata_cache.exists()
assert survivor.exists()
assert str(build_dir) in caplog.text
@patch("esphome.writer.CORE")
def test_clean_build_partial_exists(
mock_core: MagicMock,
@@ -586,6 +629,7 @@ def test_clean_build_partial_exists(
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
# Verify only pioenvs exists
assert pioenvs_dir.exists()
@@ -623,6 +667,7 @@ def test_clean_build_nothing_exists(
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
# Verify nothing exists
assert not pioenvs_dir.exists()
@@ -659,6 +704,7 @@ def test_clean_build_platformio_not_available(
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = piolibdeps_dir
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
# Verify all exist before
assert pioenvs_dir.exists()
@@ -697,6 +743,7 @@ def test_clean_build_empty_cache_dir(
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
# Verify pioenvs exists before
assert pioenvs_dir.exists()
@@ -1425,6 +1472,7 @@ def test_clean_build_handles_readonly_files(
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
# Verify file is read-only
assert not os.access(readonly_file, os.W_OK)
@@ -1489,6 +1537,7 @@ def test_clean_build_reraises_for_other_errors(
mock_core.relative_pioenvs_path.return_value = pioenvs_dir
mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps"
mock_core.relative_build_path.side_effect = lambda name: tmp_path / name
mock_core.relative_internal_path.side_effect = tmp_path.joinpath
try:
# Mock os.access in writer module to return True (writable)