mirror of
https://github.com/esphome/esphome.git
synced 2026-06-24 18:40:22 +00:00
Compare commits
17 Commits
core-block
...
dnm-memory
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b1618331f | ||
|
|
3ad851993f | ||
|
|
0d7d091e71 | ||
|
|
0fcfd1e3d6 | ||
|
|
74a1ff9fc7 | ||
|
|
78d8a93fff | ||
|
|
92819d8658 | ||
|
|
7b8cbe2de1 | ||
|
|
3b0f669f47 | ||
|
|
87735d71a0 | ||
|
|
eba70dc193 | ||
|
|
712ef2ec0e | ||
|
|
2009f6cc5f | ||
|
|
89ddd34cb9 | ||
|
|
e4980713d1 | ||
|
|
997ab11687 | ||
|
|
792e1ff304 |
@@ -1 +1 @@
|
||||
27aaab4e0ebfc10491720345aa746fc2dffa6a3985f73ec111b12dd99078d46f
|
||||
a30d2e50f2cac76e9c504eb7e5b250070dc92df23469c44a7eb8e52e26fd375d
|
||||
|
||||
2
.github/workflows/auto-label-pr.yml
vendored
2
.github/workflows/auto-label-pr.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/ci-api-proto.yml
vendored
2
.github/workflows/ci-api-proto.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/ci-clang-tidy-hash.yml
vendored
2
.github/workflows/ci-clang-tidy-hash.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/ci-docker.yml
vendored
2
.github/workflows/ci-docker.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/ci-github-scripts.yml
vendored
2
.github/workflows/ci-github-scripts.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
40
.github/workflows/ci.yml
vendored
40
.github/workflows/ci.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -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}}"
|
||||
|
||||
2
.github/workflows/pr-title-check.yml
vendored
2
.github/workflows/pr-title-check.yml
vendored
@@ -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:
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/sync-device-classes.yml
vendored
4
.github/workflows/sync-device-classes.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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_();
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
)
|
||||
|
||||
297
esphome/components/i2c/i2c_bus_host.cpp
Normal file
297
esphome/components/i2c/i2c_bus_host.cpp
Normal 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
|
||||
41
esphome/components/i2c/i2c_bus_host.h
Normal file
41
esphome/components/i2c/i2c_bus_host.h
Normal 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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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_{};
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
0
tests/component_tests/esp32_ble_server/__init__.py
Normal file
0
tests/component_tests/esp32_ble_server/__init__.py
Normal 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)
|
||||
177
tests/component_tests/lvgl/test_validation.py
Normal file
177
tests/component_tests/lvgl/test_validation.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
37
tests/components/const/common.yaml
Normal file
37
tests/components/const/common.yaml
Normal 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
|
||||
7
tests/components/const/test-display.esp32-s3-idf.yaml
Normal file
7
tests/components/const/test-display.esp32-s3-idf.yaml
Normal 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
|
||||
4
tests/components/i2c/test.host.yaml
Normal file
4
tests/components/i2c/test.host.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
i2c: !include ../../test_build_components/common/i2c/host.yaml
|
||||
|
||||
<<: !include common.yaml
|
||||
3
tests/components/logger/common-uart0_no_logging.yaml
Normal file
3
tests/components/logger/common-uart0_no_logging.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
logger:
|
||||
hardware_uart: UART0
|
||||
baud_rate: 0
|
||||
@@ -0,0 +1 @@
|
||||
<<: !include common-uart0_no_logging.yaml
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
7
tests/test_build_components/common/i2c/host.yaml
Normal file
7
tests/test_build_components/common/i2c/host.yaml
Normal file
@@ -0,0 +1,7 @@
|
||||
# Common I2C configuration for host platform tests
|
||||
|
||||
i2c:
|
||||
- id: i2c_bus
|
||||
device: /dev/i2c-0
|
||||
frequency: 100kHz
|
||||
scan: true
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user