diff --git a/.ai/instructions.md b/.ai/instructions.md index 8d81c6cf0f..681829bae6 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -172,8 +172,7 @@ This document provides essential context for AI models interacting with this pro * **C++ Class Pattern:** ```cpp - namespace esphome { - namespace my_component { + namespace esphome::my_component { class MyComponent : public Component { public: @@ -189,8 +188,7 @@ This document provides essential context for AI models interacting with this pro int param_{0}; }; - } // namespace my_component - } // namespace esphome + } // namespace esphome::my_component ``` * **Common Component Examples:** diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 28437e6302..41dd02458e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -7,6 +7,7 @@ - [ ] Bugfix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Developer breaking change (an API change that could break external components) - [ ] Code quality improvements to existing code or addition of tests - [ ] Other diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index f314e79ad9..c4ac3d1a9e 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -17,7 +17,7 @@ runs: steps: - name: Set up Python ${{ inputs.python-version }} id: python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index dd1bc29d83..39164fc2ea 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -22,11 +22,11 @@ jobs: if: github.event.action != 'labeled' || github.event.sender.type != 'Bot' steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Generate a token id: generate-token - uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2 + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} @@ -68,6 +68,7 @@ jobs: 'bugfix', 'new-feature', 'breaking-change', + 'developer-breaking-change', 'code-quality' ]; @@ -367,6 +368,7 @@ jobs: { pattern: /- \[x\] Bugfix \(non-breaking change which fixes an issue\)/i, label: 'bugfix' }, { pattern: /- \[x\] New feature \(non-breaking change which adds functionality\)/i, label: 'new-feature' }, { pattern: /- \[x\] Breaking change \(fix or feature that would cause existing functionality to not work as expected\)/i, label: 'breaking-change' }, + { pattern: /- \[x\] Developer breaking change \(an API change that could break external components\)/i, label: 'developer-breaking-change' }, { pattern: /- \[x\] Code quality improvements to existing code or addition of tests/i, label: 'code-quality' } ]; diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index 400373679f..a0c6568345 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -21,9 +21,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" diff --git a/.github/workflows/ci-clang-tidy-hash.yml b/.github/workflows/ci-clang-tidy-hash.yml index 78d1c2b87f..94068c19d6 100644 --- a/.github/workflows/ci-clang-tidy-hash.yml +++ b/.github/workflows/ci-clang-tidy-hash.yml @@ -21,10 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" diff --git a/.github/workflows/ci-docker.yml b/.github/workflows/ci-docker.yml index 7111c61dda..bf7fa0c262 100644 --- a/.github/workflows/ci-docker.yml +++ b/.github/workflows/ci-docker.yml @@ -43,9 +43,9 @@ jobs: - "docker" # - "lint" steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" - name: Set up Docker Buildx diff --git a/.github/workflows/ci-memory-impact-comment.yml b/.github/workflows/ci-memory-impact-comment.yml index eea1d2c148..7e81e1184d 100644 --- a/.github/workflows/ci-memory-impact-comment.yml +++ b/.github/workflows/ci-memory-impact-comment.yml @@ -49,7 +49,7 @@ jobs: - name: Check out code from base repository if: steps.pr.outputs.skip != 'true' - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16837b3186..9ef6b4341c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,13 +36,13 @@ jobs: cache-key: ${{ steps.cache-key.outputs.key }} steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Generate cache-key id: cache-key run: echo key="${{ hashFiles('requirements.txt', 'requirements_test.txt', '.pre-commit-config.yaml') }}" >> $GITHUB_OUTPUT - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment @@ -70,7 +70,7 @@ jobs: if: needs.determine-jobs.outputs.python-linters == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -91,7 +91,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -132,7 +132,7 @@ jobs: - common steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python id: restore-python uses: ./.github/actions/restore-python @@ -183,7 +183,7 @@ jobs: component-test-batches: ${{ steps.determine.outputs.component-test-batches }} steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Fetch enough history to find the merge base fetch-depth: 2 @@ -237,10 +237,10 @@ jobs: if: needs.determine-jobs.outputs.integration-tests == 'true' steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python 3.13 id: python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.13" - name: Restore Python virtual environment @@ -273,7 +273,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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python @@ -321,7 +321,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -400,7 +400,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -489,7 +489,7 @@ jobs: steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: # Need history for HEAD~1 to work for checking changed files fetch-depth: 2 @@ -577,7 +577,7 @@ jobs: version: 1.0 - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -662,7 +662,7 @@ jobs: if: github.event_name == 'pull_request' && !startsWith(github.base_ref, 'beta') && !startsWith(github.base_ref, 'release') steps: - name: Check out code from GitHub - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -688,7 +688,7 @@ jobs: skip: ${{ steps.check-script.outputs.skip }} steps: - name: Check out target branch - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: ref: ${{ github.base_ref }} @@ -840,7 +840,7 @@ jobs: flash_usage: ${{ steps.extract.outputs.flash_usage }} steps: - name: Check out PR branch - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: @@ -908,7 +908,7 @@ jobs: GH_TOKEN: ${{ github.token }} steps: - name: Check out code - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Restore Python uses: ./.github/actions/restore-python with: diff --git a/.github/workflows/codeowner-review-request.yml b/.github/workflows/codeowner-review-request.yml index 563d55f42b..6f4351b298 100644 --- a/.github/workflows/codeowner-review-request.yml +++ b/.github/workflows/codeowner-review-request.yml @@ -21,7 +21,7 @@ permissions: jobs: request-codeowner-reviews: name: Run - if: ${{ !github.event.pull_request.draft }} + if: ${{ github.repository == 'esphome/esphome' && !github.event.pull_request.draft }} runs-on: ubuntu-latest steps: - name: Request reviews from component codeowners diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index ab938b3436..d9b6bcdcca 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,11 +54,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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -86,6 +86,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75d88abf29..d52595bbb3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: branch_build: ${{ steps.tag.outputs.branch_build }} deploy_env: ${{ steps.tag.outputs.deploy_env }} steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Get tag id: tag # yamllint disable rule:line-length @@ -60,9 +60,9 @@ jobs: contents: read id-token: write steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.x" - name: Build @@ -92,9 +92,9 @@ jobs: os: "ubuntu-24.04-arm" steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: "3.11" @@ -168,7 +168,7 @@ jobs: - ghcr - dockerhub steps: - - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download digests uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index 9479645ccc..ea81a1e013 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -13,16 +13,16 @@ jobs: if: github.repository == 'esphome/esphome' steps: - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Checkout Home Assistant - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 with: repository: home-assistant/core path: lib/home-assistant - name: Setup Python - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: 3.13 @@ -41,7 +41,7 @@ jobs: python script/run-in-env.py pre-commit run --all-files - name: Commit changes - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 + uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphomebot diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dab660b03f..412a678d02 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.4 + rev: v0.14.7 hooks: # Run the linter. - id: ruff diff --git a/CODEOWNERS b/CODEOWNERS index 7e785db451..7861871323 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -21,6 +21,7 @@ esphome/components/adc128s102/* @DeerMaximum esphome/components/addressable_light/* @justfalter esphome/components/ade7880/* @kpfleming esphome/components/ade7953/* @angelnu +esphome/components/ade7953_base/* @angelnu esphome/components/ade7953_i2c/* @angelnu esphome/components/ade7953_spi/* @angelnu esphome/components/ads1118/* @solomondg1 @@ -72,6 +73,7 @@ esphome/components/bl0942/* @dbuezas @dwmw2 esphome/components/ble_client/* @buxtronix @clydebarrow esphome/components/ble_nus/* @tomaszduda23 esphome/components/bluetooth_proxy/* @bdraco @jesserockz +esphome/components/bm8563/* @abmantis esphome/components/bme280_base/* @esphome/core esphome/components/bme280_spi/* @apbodrov esphome/components/bme680_bsec/* @trvrnrth @@ -202,10 +204,12 @@ esphome/components/havells_solar/* @sourabhjaiswal esphome/components/hbridge/fan/* @WeekendWarrior esphome/components/hbridge/light/* @DotNetDann esphome/components/hbridge/switch/* @dwmw2 +esphome/components/hc8/* @omartijn esphome/components/hdc2010/* @optimusprimespace @ssieb esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal +esphome/components/hlk_fm22x/* @OnFreund esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 esphome/components/homeassistant/* @esphome/core @OttoWinter @@ -395,6 +399,7 @@ esphome/components/rpi_dpi_rgb/* @clydebarrow esphome/components/rtl87xx/* @kuba2k2 esphome/components/rtttl/* @glmnet esphome/components/runtime_stats/* @bdraco +esphome/components/rx8130/* @beormund esphome/components/safe_mode/* @jsuanet @kbx81 @paulmonigatti esphome/components/scd4x/* @martgras @sjtrny esphome/components/script/* @esphome/core @@ -458,6 +463,7 @@ esphome/components/st7735/* @SenexCrenshaw esphome/components/st7789v/* @kbx81 esphome/components/st7920/* @marsjan155 esphome/components/statsd/* @Links2004 +esphome/components/stts22h/* @B48D81EFCC esphome/components/substitutions/* @esphome/core esphome/components/sun/* @OttoWinter esphome/components/sun_gtil2/* @Mat931 @@ -479,6 +485,7 @@ esphome/components/template/datetime/* @rfdarter esphome/components/template/event/* @nohat esphome/components/template/fan/* @ssieb esphome/components/text/* @mauritskorse +esphome/components/thermopro_ble/* @sittner esphome/components/thermostat/* @kbx81 esphome/components/time/* @esphome/core esphome/components/tinyusb/* @kbx81 diff --git a/Doxyfile b/Doxyfile index 034fa3fa37..a19120b9da 100644 --- a/Doxyfile +++ b/Doxyfile @@ -48,7 +48,7 @@ PROJECT_NAME = ESPHome # could be handy for archiving the generated documentation or if some version # control system is used. -PROJECT_NUMBER = 2025.11.0-dev +PROJECT_NUMBER = 2025.12.0-dev # Using the PROJECT_BRIEF tag one can provide an optional one line description # for a project that appears at the top of each page and should give viewer a diff --git a/esphome/__main__.py b/esphome/__main__.py index b0c081a34f..55fbbc6c8a 100644 --- a/esphome/__main__.py +++ b/esphome/__main__.py @@ -944,6 +944,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: """ from esphome import platformio_api from esphome.analyze_memory.cli import MemoryAnalyzerCLI + from esphome.analyze_memory.ram_strings import RamStringsAnalyzer # Always compile to ensure fresh data (fast if no changes - just relinks) exit_code = write_cpp(config) @@ -966,7 +967,7 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: external_components = detect_external_components(config) _LOGGER.debug("Detected external components: %s", external_components) - # Perform memory analysis + # Perform component memory analysis _LOGGER.info("Analyzing memory usage...") analyzer = MemoryAnalyzerCLI( str(firmware_elf), @@ -976,11 +977,28 @@ def command_analyze_memory(args: ArgsProtocol, config: ConfigType) -> int: ) analyzer.analyze() - # Generate and display report + # Generate and display component report report = analyzer.generate_report() print() print(report) + # Perform RAM strings analysis + _LOGGER.info("Analyzing RAM strings...") + try: + ram_analyzer = RamStringsAnalyzer( + str(firmware_elf), + objdump_path=idedata.objdump_path, + platform=CORE.target_platform, + ) + ram_analyzer.analyze() + + # Generate and display RAM strings report + ram_report = ram_analyzer.generate_report() + print() + print(ram_report) + except Exception as e: # pylint: disable=broad-except + _LOGGER.warning("RAM strings analysis failed: %s", e) + return 0 @@ -1319,7 +1337,7 @@ def parse_args(argv): "clean-all", help="Clean all build and platform files." ) parser_clean_all.add_argument( - "configuration", help="Your YAML configuration directory.", nargs="*" + "configuration", help="Your YAML file or configuration directory.", nargs="*" ) parser_dashboard = subparsers.add_parser( diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index 71e86e3788..9632a68913 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -15,6 +15,7 @@ from .const import ( SECTION_TO_ATTR, SYMBOL_PATTERNS, ) +from .demangle import batch_demangle from .helpers import ( get_component_class_patterns, get_esphome_components, @@ -27,15 +28,6 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -# GCC global constructor/destructor prefix annotations -_GCC_PREFIX_ANNOTATIONS = { - "_GLOBAL__sub_I_": "global constructor for", - "_GLOBAL__sub_D_": "global destructor for", -} - -# GCC optimization suffix pattern (e.g., $isra$0, $part$1, $constprop$2) -_GCC_OPTIMIZATION_SUFFIX_PATTERN = re.compile(r"(\$(?:isra|part|constprop)\$\d+)") - # C++ runtime patterns for categorization _CPP_RUNTIME_PATTERNS = frozenset(["vtable", "typeinfo", "thunk"]) @@ -312,168 +304,9 @@ class MemoryAnalyzer: if not symbols: return - # Try to find the appropriate c++filt for the platform - cppfilt_cmd = "c++filt" - _LOGGER.info("Demangling %d symbols", len(symbols)) - _LOGGER.debug("objdump_path = %s", self.objdump_path) - - # Check if we have a toolchain-specific c++filt - if self.objdump_path and self.objdump_path != "objdump": - # Replace objdump with c++filt in the path - potential_cppfilt = self.objdump_path.replace("objdump", "c++filt") - _LOGGER.info("Checking for toolchain c++filt at: %s", potential_cppfilt) - if Path(potential_cppfilt).exists(): - cppfilt_cmd = potential_cppfilt - _LOGGER.info("✓ Using toolchain c++filt: %s", cppfilt_cmd) - else: - _LOGGER.info( - "✗ Toolchain c++filt not found at %s, using system c++filt", - potential_cppfilt, - ) - else: - _LOGGER.info("✗ Using system c++filt (objdump_path=%s)", self.objdump_path) - - # Strip GCC optimization suffixes and prefixes before demangling - # Suffixes like $isra$0, $part$0, $constprop$0 confuse c++filt - # Prefixes like _GLOBAL__sub_I_ need to be removed and tracked - symbols_stripped: list[str] = [] - symbols_prefixes: list[str] = [] # Track removed prefixes - for symbol in symbols: - # Remove GCC optimization markers - stripped = _GCC_OPTIMIZATION_SUFFIX_PATTERN.sub("", symbol) - - # Handle GCC global constructor/initializer prefixes - # _GLOBAL__sub_I_ -> extract for demangling - prefix = "" - for gcc_prefix in _GCC_PREFIX_ANNOTATIONS: - if stripped.startswith(gcc_prefix): - prefix = gcc_prefix - stripped = stripped[len(prefix) :] - break - - symbols_stripped.append(stripped) - symbols_prefixes.append(prefix) - - try: - # Send all symbols to c++filt at once - result = subprocess.run( - [cppfilt_cmd], - input="\n".join(symbols_stripped), - capture_output=True, - text=True, - check=False, - ) - except (subprocess.SubprocessError, OSError, UnicodeDecodeError) as e: - # On error, cache originals - _LOGGER.warning("Failed to batch demangle symbols: %s", e) - for symbol in symbols: - self._demangle_cache[symbol] = symbol - return - - if result.returncode != 0: - _LOGGER.warning( - "c++filt exited with code %d: %s", - result.returncode, - result.stderr[:200] if result.stderr else "(no error output)", - ) - # Cache originals on failure - for symbol in symbols: - self._demangle_cache[symbol] = symbol - return - - # Process demangled output - self._process_demangled_output( - symbols, symbols_stripped, symbols_prefixes, result.stdout, cppfilt_cmd - ) - - def _process_demangled_output( - self, - symbols: list[str], - symbols_stripped: list[str], - symbols_prefixes: list[str], - demangled_output: str, - cppfilt_cmd: str, - ) -> None: - """Process demangled symbol output and populate cache. - - Args: - symbols: Original symbol names - symbols_stripped: Stripped symbol names sent to c++filt - symbols_prefixes: Removed prefixes to restore - demangled_output: Output from c++filt - cppfilt_cmd: Path to c++filt command (for logging) - """ - demangled_lines = demangled_output.strip().split("\n") - failed_count = 0 - - for original, stripped, prefix, demangled in zip( - symbols, symbols_stripped, symbols_prefixes, demangled_lines - ): - # Add back any prefix that was removed - demangled = self._restore_symbol_prefix(prefix, stripped, demangled) - - # If we stripped a suffix, add it back to the demangled name for clarity - if original != stripped and not prefix: - demangled = self._restore_symbol_suffix(original, demangled) - - self._demangle_cache[original] = demangled - - # Log symbols that failed to demangle (stayed the same as stripped version) - if stripped == demangled and stripped.startswith("_Z"): - failed_count += 1 - if failed_count <= 5: # Only log first 5 failures - _LOGGER.warning("Failed to demangle: %s", original) - - if failed_count == 0: - _LOGGER.info("Successfully demangled all %d symbols", len(symbols)) - return - - _LOGGER.warning( - "Failed to demangle %d/%d symbols using %s", - failed_count, - len(symbols), - cppfilt_cmd, - ) - - @staticmethod - def _restore_symbol_prefix(prefix: str, stripped: str, demangled: str) -> str: - """Restore prefix that was removed before demangling. - - Args: - prefix: Prefix that was removed (e.g., "_GLOBAL__sub_I_") - stripped: Stripped symbol name - demangled: Demangled symbol name - - Returns: - Demangled name with prefix restored/annotated - """ - if not prefix: - return demangled - - # Successfully demangled - add descriptive prefix - if demangled != stripped and ( - annotation := _GCC_PREFIX_ANNOTATIONS.get(prefix) - ): - return f"[{annotation}: {demangled}]" - - # Failed to demangle - restore original prefix - return prefix + demangled - - @staticmethod - def _restore_symbol_suffix(original: str, demangled: str) -> str: - """Restore GCC optimization suffix that was removed before demangling. - - Args: - original: Original symbol name with suffix - demangled: Demangled symbol name without suffix - - Returns: - Demangled name with suffix annotation - """ - if suffix_match := _GCC_OPTIMIZATION_SUFFIX_PATTERN.search(original): - return f"{demangled} [{suffix_match.group(1)}]" - return demangled + self._demangle_cache = batch_demangle(symbols, objdump_path=self.objdump_path) + _LOGGER.info("Successfully demangled %d symbols", len(self._demangle_cache)) def _demangle_symbol(self, symbol: str) -> str: """Get demangled C++ symbol name from cache.""" diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index 718f42330d..44ade221f8 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -15,6 +15,11 @@ from . import ( class MemoryAnalyzerCLI(MemoryAnalyzer): """Memory analyzer with CLI-specific report generation.""" + # Symbol size threshold for detailed analysis + SYMBOL_SIZE_THRESHOLD: int = ( + 100 # Show symbols larger than this in detailed analysis + ) + # Column width constants COL_COMPONENT: int = 29 COL_FLASH_TEXT: int = 14 @@ -191,14 +196,21 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): f"{len(symbols):>{self.COL_CORE_COUNT}} | {percentage:>{self.COL_CORE_PERCENT - 1}.1f}%" ) - # Top 15 largest core symbols + # All core symbols above threshold lines.append("") - lines.append(f"Top 15 Largest {_COMPONENT_CORE} Symbols:") sorted_core_symbols = sorted( self._esphome_core_symbols, key=lambda x: x[2], reverse=True ) + large_core_symbols = [ + (symbol, demangled, size) + for symbol, demangled, size in sorted_core_symbols + if size > self.SYMBOL_SIZE_THRESHOLD + ] - for i, (symbol, demangled, size) in enumerate(sorted_core_symbols[:15]): + lines.append( + f"{_COMPONENT_CORE} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_core_symbols)} symbols):" + ) + for i, (symbol, demangled, size) in enumerate(large_core_symbols): lines.append(f"{i + 1}. {demangled} ({size:,} B)") lines.append("=" * self.TABLE_WIDTH) @@ -268,13 +280,15 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): lines.append(f"Total size: {comp_mem.flash_total:,} B") lines.append("") - # Show all symbols > 100 bytes for better visibility + # Show all symbols above threshold for better visibility large_symbols = [ - (sym, dem, size) for sym, dem, size in sorted_symbols if size > 100 + (sym, dem, size) + for sym, dem, size in sorted_symbols + if size > self.SYMBOL_SIZE_THRESHOLD ] lines.append( - f"{comp_name} Symbols > 100 B ({len(large_symbols)} symbols):" + f"{comp_name} Symbols > {self.SYMBOL_SIZE_THRESHOLD} B ({len(large_symbols)} symbols):" ) for i, (symbol, demangled, size) in enumerate(large_symbols): lines.append(f"{i + 1}. {demangled} ({size:,} B)") diff --git a/esphome/analyze_memory/demangle.py b/esphome/analyze_memory/demangle.py new file mode 100644 index 0000000000..8999108b51 --- /dev/null +++ b/esphome/analyze_memory/demangle.py @@ -0,0 +1,182 @@ +"""Symbol demangling utilities for memory analysis. + +This module provides functions for demangling C++ symbol names using c++filt. +""" + +from __future__ import annotations + +import logging +import re +import subprocess + +from .toolchain import find_tool + +_LOGGER = logging.getLogger(__name__) + +# GCC global constructor/destructor prefix annotations +GCC_PREFIX_ANNOTATIONS = { + "_GLOBAL__sub_I_": "global constructor for", + "_GLOBAL__sub_D_": "global destructor for", +} + +# GCC optimization suffix pattern (e.g., $isra$0, $part$1, $constprop$2) +GCC_OPTIMIZATION_SUFFIX_PATTERN = re.compile(r"(\$(?:isra|part|constprop)\$\d+)") + + +def _strip_gcc_annotations(symbol: str) -> tuple[str, str]: + """Strip GCC optimization suffixes and prefixes from a symbol. + + Args: + symbol: The mangled symbol name + + Returns: + Tuple of (stripped_symbol, removed_prefix) + """ + # Remove GCC optimization markers + stripped = GCC_OPTIMIZATION_SUFFIX_PATTERN.sub("", symbol) + + # Handle GCC global constructor/initializer prefixes + prefix = "" + for gcc_prefix in GCC_PREFIX_ANNOTATIONS: + if stripped.startswith(gcc_prefix): + prefix = gcc_prefix + stripped = stripped[len(prefix) :] + break + + return stripped, prefix + + +def _restore_symbol_prefix(prefix: str, stripped: str, demangled: str) -> str: + """Restore prefix that was removed before demangling. + + Args: + prefix: Prefix that was removed (e.g., "_GLOBAL__sub_I_") + stripped: Stripped symbol name + demangled: Demangled symbol name + + Returns: + Demangled name with prefix restored/annotated + """ + if not prefix: + return demangled + + # Successfully demangled - add descriptive prefix + if demangled != stripped and (annotation := GCC_PREFIX_ANNOTATIONS.get(prefix)): + return f"[{annotation}: {demangled}]" + + # Failed to demangle - restore original prefix + return prefix + demangled + + +def _restore_symbol_suffix(original: str, demangled: str) -> str: + """Restore GCC optimization suffix that was removed before demangling. + + Args: + original: Original symbol name with suffix + demangled: Demangled symbol name without suffix + + Returns: + Demangled name with suffix annotation + """ + if suffix_match := GCC_OPTIMIZATION_SUFFIX_PATTERN.search(original): + return f"{demangled} [{suffix_match.group(1)}]" + return demangled + + +def batch_demangle( + symbols: list[str], + cppfilt_path: str | None = None, + objdump_path: str | None = None, +) -> dict[str, str]: + """Batch demangle C++ symbol names. + + Args: + symbols: List of symbol names to demangle + cppfilt_path: Path to c++filt binary (auto-detected if not provided) + objdump_path: Path to objdump binary to derive c++filt path from + + Returns: + Dictionary mapping original symbol names to demangled names + """ + cache: dict[str, str] = {} + + if not symbols: + return cache + + # Find c++filt tool + cppfilt_cmd = cppfilt_path or find_tool("c++filt", objdump_path) + if not cppfilt_cmd: + _LOGGER.warning("Could not find c++filt, symbols will not be demangled") + return {s: s for s in symbols} + + _LOGGER.debug("Demangling %d symbols using %s", len(symbols), cppfilt_cmd) + + # Strip GCC optimization suffixes and prefixes before demangling + symbols_stripped: list[str] = [] + symbols_prefixes: list[str] = [] + for symbol in symbols: + stripped, prefix = _strip_gcc_annotations(symbol) + symbols_stripped.append(stripped) + symbols_prefixes.append(prefix) + + try: + result = subprocess.run( + [cppfilt_cmd], + input="\n".join(symbols_stripped), + capture_output=True, + text=True, + check=False, + ) + except (subprocess.SubprocessError, OSError, UnicodeDecodeError) as e: + _LOGGER.warning("Failed to batch demangle symbols: %s", e) + return {s: s for s in symbols} + + if result.returncode != 0: + _LOGGER.warning( + "c++filt exited with code %d: %s", + result.returncode, + result.stderr[:200] if result.stderr else "(no error output)", + ) + return {s: s for s in symbols} + + # Process demangled output + demangled_lines = result.stdout.strip().split("\n") + + # Check for output length mismatch + if len(demangled_lines) != len(symbols): + _LOGGER.warning( + "c++filt output mismatch: expected %d lines, got %d", + len(symbols), + len(demangled_lines), + ) + return {s: s for s in symbols} + + failed_count = 0 + + for original, stripped, prefix, demangled in zip( + symbols, symbols_stripped, symbols_prefixes, demangled_lines + ): + # Add back any prefix that was removed + demangled = _restore_symbol_prefix(prefix, stripped, demangled) + + # If we stripped a suffix, add it back to the demangled name for clarity + if original != stripped and not prefix: + demangled = _restore_symbol_suffix(original, demangled) + + cache[original] = demangled + + # Count symbols that failed to demangle + if stripped == demangled and stripped.startswith("_Z"): + failed_count += 1 + if failed_count <= 5: + _LOGGER.debug("Failed to demangle: %s", original) + + if failed_count > 0: + _LOGGER.debug( + "Failed to demangle %d/%d symbols using %s", + failed_count, + len(symbols), + cppfilt_cmd, + ) + + return cache diff --git a/esphome/analyze_memory/ram_strings.py b/esphome/analyze_memory/ram_strings.py new file mode 100644 index 0000000000..fbcbeeca61 --- /dev/null +++ b/esphome/analyze_memory/ram_strings.py @@ -0,0 +1,493 @@ +"""Analyzer for RAM-stored strings in ESP8266/ESP32 firmware ELF files. + +This module identifies strings that are stored in RAM sections (.data, .bss, .rodata) +rather than in flash sections (.irom0.text, .irom.text), which is important for +memory-constrained platforms like ESP8266. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass +import logging +from pathlib import Path +import re +import subprocess + +from .demangle import batch_demangle +from .toolchain import find_tool + +_LOGGER = logging.getLogger(__name__) + +# ESP8266: .rodata is in RAM (DRAM), not flash +# ESP32: .rodata is in flash, mapped to data bus +ESP8266_RAM_SECTIONS = frozenset([".data", ".rodata", ".bss"]) +ESP8266_FLASH_SECTIONS = frozenset([".irom0.text", ".irom.text", ".text"]) + +# ESP32: .rodata is memory-mapped from flash +ESP32_RAM_SECTIONS = frozenset([".data", ".bss", ".dram0.data", ".dram0.bss"]) +ESP32_FLASH_SECTIONS = frozenset([".text", ".rodata", ".flash.text", ".flash.rodata"]) + +# nm symbol types for data symbols (D=global data, d=local data, R=rodata, B=bss) +DATA_SYMBOL_TYPES = frozenset(["D", "d", "R", "r", "B", "b"]) + + +@dataclass +class SectionInfo: + """Information about an ELF section.""" + + name: str + address: int + size: int + + +@dataclass +class RamString: + """A string found in RAM.""" + + section: str + address: int + content: str + + @property + def size(self) -> int: + """Size in bytes including null terminator.""" + return len(self.content) + 1 + + +@dataclass +class RamSymbol: + """A symbol found in RAM.""" + + name: str + sym_type: str + address: int + size: int + section: str + demangled: str = "" # Demangled name, set after batch demangling + + +class RamStringsAnalyzer: + """Analyzes ELF files to find strings stored in RAM.""" + + def __init__( + self, + elf_path: str, + objdump_path: str | None = None, + min_length: int = 8, + platform: str = "esp32", + ) -> None: + """Initialize the RAM strings analyzer. + + Args: + elf_path: Path to the ELF file to analyze + objdump_path: Path to objdump binary (used to find other tools) + min_length: Minimum string length to report (default: 8) + platform: Platform name ("esp8266", "esp32", etc.) for section mapping + """ + self.elf_path = Path(elf_path) + if not self.elf_path.exists(): + raise FileNotFoundError(f"ELF file not found: {elf_path}") + + self.objdump_path = objdump_path + self.min_length = min_length + self.platform = platform + + # Set RAM/flash sections based on platform + if self.platform == "esp8266": + self.ram_sections = ESP8266_RAM_SECTIONS + self.flash_sections = ESP8266_FLASH_SECTIONS + else: + # ESP32 and other platforms + self.ram_sections = ESP32_RAM_SECTIONS + self.flash_sections = ESP32_FLASH_SECTIONS + + self.sections: dict[str, SectionInfo] = {} + self.ram_strings: list[RamString] = [] + self.ram_symbols: list[RamSymbol] = [] + + def _run_command(self, cmd: list[str]) -> str: + """Run a command and return its output.""" + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + return result.stdout + except subprocess.CalledProcessError as e: + _LOGGER.debug("Command failed: %s - %s", " ".join(cmd), e.stderr) + raise + except FileNotFoundError: + _LOGGER.warning("Command not found: %s", cmd[0]) + raise + + def analyze(self) -> None: + """Perform the full RAM analysis.""" + self._parse_sections() + self._extract_strings() + self._analyze_symbols() + self._demangle_symbols() + + def _parse_sections(self) -> None: + """Parse section headers from ELF file.""" + objdump = find_tool("objdump", self.objdump_path) + if not objdump: + _LOGGER.error("Could not find objdump command") + return + + try: + output = self._run_command([objdump, "-h", str(self.elf_path)]) + except (subprocess.CalledProcessError, FileNotFoundError): + return + + # Parse section headers + # Format: Idx Name Size VMA LMA File off Algn + section_pattern = re.compile( + r"^\s*\d+\s+(\S+)\s+([0-9a-fA-F]+)\s+([0-9a-fA-F]+)" + ) + + for line in output.split("\n"): + if match := section_pattern.match(line): + name = match.group(1) + size = int(match.group(2), 16) + vma = int(match.group(3), 16) + self.sections[name] = SectionInfo(name, vma, size) + + def _extract_strings(self) -> None: + """Extract strings from RAM sections.""" + objdump = find_tool("objdump", self.objdump_path) + if not objdump: + return + + for section_name in self.ram_sections: + if section_name not in self.sections: + continue + + try: + output = self._run_command( + [objdump, "-s", "-j", section_name, str(self.elf_path)] + ) + except subprocess.CalledProcessError: + # Section may exist but have no content (e.g., .bss) + continue + except FileNotFoundError: + continue + + strings = self._parse_hex_dump(output, section_name) + self.ram_strings.extend(strings) + + def _parse_hex_dump(self, output: str, section_name: str) -> list[RamString]: + """Parse hex dump output to extract strings. + + Args: + output: Output from objdump -s + section_name: Name of the section being parsed + + Returns: + List of RamString objects + """ + strings: list[RamString] = [] + current_string = bytearray() + string_start_addr = 0 + + for line in output.split("\n"): + # Lines look like: " 3ffef8a0 00000000 00000000 00000000 00000000 ................" + match = re.match(r"^\s+([0-9a-fA-F]+)\s+((?:[0-9a-fA-F]{2,8}\s*)+)", line) + if not match: + continue + + addr = int(match.group(1), 16) + hex_data = match.group(2).strip() + + # Convert hex to bytes + hex_bytes = hex_data.split() + byte_offset = 0 + for hex_chunk in hex_bytes: + # Handle both byte-by-byte and word formats + for i in range(0, len(hex_chunk), 2): + byte_val = int(hex_chunk[i : i + 2], 16) + if 0x20 <= byte_val <= 0x7E: # Printable ASCII + if not current_string: + string_start_addr = addr + byte_offset + current_string.append(byte_val) + else: + if byte_val == 0 and len(current_string) >= self.min_length: + # Found null terminator + strings.append( + RamString( + section=section_name, + address=string_start_addr, + content=current_string.decode( + "ascii", errors="ignore" + ), + ) + ) + current_string = bytearray() + byte_offset += 1 + + return strings + + def _analyze_symbols(self) -> None: + """Analyze symbols in RAM sections.""" + nm = find_tool("nm", self.objdump_path) + if not nm: + return + + try: + output = self._run_command([nm, "-S", "--size-sort", str(self.elf_path)]) + except (subprocess.CalledProcessError, FileNotFoundError): + return + + for line in output.split("\n"): + parts = line.split() + if len(parts) < 4: + continue + + try: + addr = int(parts[0], 16) + size = int(parts[1], 16) if parts[1] != "?" else 0 + except ValueError: + continue + + sym_type = parts[2] + name = " ".join(parts[3:]) + + # Filter for data symbols + if sym_type not in DATA_SYMBOL_TYPES: + continue + + # Check if symbol is in a RAM section + for section_name in self.ram_sections: + if section_name not in self.sections: + continue + + section = self.sections[section_name] + if section.address <= addr < section.address + section.size: + self.ram_symbols.append( + RamSymbol( + name=name, + sym_type=sym_type, + address=addr, + size=size, + section=section_name, + ) + ) + break + + def _demangle_symbols(self) -> None: + """Batch demangle all RAM symbol names.""" + if not self.ram_symbols: + return + + # Collect all symbol names and demangle them + symbol_names = [s.name for s in self.ram_symbols] + demangle_cache = batch_demangle(symbol_names, objdump_path=self.objdump_path) + + # Assign demangled names to symbols + for symbol in self.ram_symbols: + symbol.demangled = demangle_cache.get(symbol.name, symbol.name) + + def _get_sections_size(self, section_names: frozenset[str]) -> int: + """Get total size of specified sections.""" + return sum( + section.size + for name, section in self.sections.items() + if name in section_names + ) + + def get_total_ram_usage(self) -> int: + """Get total RAM usage from RAM sections.""" + return self._get_sections_size(self.ram_sections) + + def get_total_flash_usage(self) -> int: + """Get total flash usage from flash sections.""" + return self._get_sections_size(self.flash_sections) + + def get_total_string_bytes(self) -> int: + """Get total bytes used by strings in RAM.""" + return sum(s.size for s in self.ram_strings) + + def get_repeated_strings(self) -> list[tuple[str, int]]: + """Find strings that appear multiple times. + + Returns: + List of (string, count) tuples sorted by potential savings + """ + string_counts: dict[str, int] = defaultdict(int) + for ram_string in self.ram_strings: + string_counts[ram_string.content] += 1 + + return sorted( + [(s, c) for s, c in string_counts.items() if c > 1], + key=lambda x: x[1] * (len(x[0]) + 1), + reverse=True, + ) + + def get_long_strings(self, min_len: int = 20) -> list[RamString]: + """Get strings longer than the specified length. + + Args: + min_len: Minimum string length + + Returns: + List of RamString objects sorted by length + """ + return sorted( + [s for s in self.ram_strings if len(s.content) >= min_len], + key=lambda x: len(x.content), + reverse=True, + ) + + def get_largest_symbols(self, min_size: int = 100) -> list[RamSymbol]: + """Get RAM symbols larger than the specified size. + + Args: + min_size: Minimum symbol size in bytes + + Returns: + List of RamSymbol objects sorted by size + """ + return sorted( + [s for s in self.ram_symbols if s.size >= min_size], + key=lambda x: x.size, + reverse=True, + ) + + def generate_report(self, show_all_sections: bool = False) -> str: + """Generate a formatted RAM strings analysis report. + + Args: + show_all_sections: If True, show all sections, not just RAM + + Returns: + Formatted report string + """ + lines: list[str] = [] + table_width = 80 + + lines.append("=" * table_width) + lines.append( + f"RAM Strings Analysis ({self.platform.upper()})".center(table_width) + ) + lines.append("=" * table_width) + lines.append("") + + # Section Analysis + lines.append("SECTION ANALYSIS") + lines.append("-" * table_width) + lines.append(f"{'Section':<20} {'Address':<12} {'Size':<12} {'Location'}") + lines.append("-" * table_width) + + total_ram_usage = 0 + total_flash_usage = 0 + + for name, section in sorted(self.sections.items(), key=lambda x: x[1].address): + if name in self.ram_sections: + location = "RAM" + total_ram_usage += section.size + elif name in self.flash_sections: + location = "FLASH" + total_flash_usage += section.size + else: + location = "OTHER" + + if show_all_sections or name in self.ram_sections: + lines.append( + f"{name:<20} 0x{section.address:08x} {section.size:>8} B {location}" + ) + + lines.append("-" * table_width) + lines.append(f"Total RAM sections size: {total_ram_usage:,} bytes") + lines.append(f"Total Flash sections size: {total_flash_usage:,} bytes") + + # Strings in RAM + lines.append("") + lines.append("=" * table_width) + lines.append("STRINGS IN RAM SECTIONS") + lines.append("=" * table_width) + lines.append( + "Note: .bss sections contain uninitialized data (no strings to extract)" + ) + + # Group strings by section + strings_by_section: dict[str, list[RamString]] = defaultdict(list) + for ram_string in self.ram_strings: + strings_by_section[ram_string.section].append(ram_string) + + for section_name in sorted(strings_by_section.keys()): + section_strings = strings_by_section[section_name] + lines.append(f"\nSection: {section_name}") + lines.append("-" * 40) + for ram_string in sorted(section_strings, key=lambda x: x.address): + clean_string = ram_string.content[:100] + ( + "..." if len(ram_string.content) > 100 else "" + ) + lines.append( + f' 0x{ram_string.address:08x}: "{clean_string}" (len={len(ram_string.content)})' + ) + + # Large RAM symbols + lines.append("") + lines.append("=" * table_width) + lines.append("LARGE DATA SYMBOLS IN RAM (>= 50 bytes)") + lines.append("=" * table_width) + + largest_symbols = self.get_largest_symbols(50) + lines.append(f"\n{'Symbol':<50} {'Type':<6} {'Size':<10} {'Section'}") + lines.append("-" * table_width) + + for symbol in largest_symbols: + # Use demangled name if available, otherwise raw name + display_name = symbol.demangled or symbol.name + name_display = display_name[:49] if len(display_name) > 49 else display_name + lines.append( + f"{name_display:<50} {symbol.sym_type:<6} {symbol.size:>8} B {symbol.section}" + ) + + # Summary + lines.append("") + lines.append("=" * table_width) + lines.append("SUMMARY") + lines.append("=" * table_width) + lines.append(f"Total strings found in RAM: {len(self.ram_strings)}") + total_string_bytes = self.get_total_string_bytes() + lines.append(f"Total bytes used by strings: {total_string_bytes:,}") + + # Optimization targets + lines.append("") + lines.append("=" * table_width) + lines.append("POTENTIAL OPTIMIZATION TARGETS") + lines.append("=" * table_width) + + # Repeated strings + repeated = self.get_repeated_strings()[:10] + if repeated: + lines.append("\nRepeated strings (could be deduplicated):") + for string, count in repeated: + savings = (count - 1) * (len(string) + 1) + clean_string = string[:50] + ("..." if len(string) > 50 else "") + lines.append( + f' "{clean_string}" - appears {count} times (potential savings: {savings} bytes)' + ) + + # Long strings - platform-specific advice + long_strings = self.get_long_strings(20)[:10] + if long_strings: + if self.platform == "esp8266": + lines.append( + "\nLong strings that could be moved to PROGMEM (>= 20 chars):" + ) + else: + # ESP32: strings in DRAM are typically there for a reason + # (interrupt handlers, pre-flash-init code, etc.) + lines.append("\nLong strings in DRAM (>= 20 chars):") + lines.append( + "Note: ESP32 DRAM strings may be required for interrupt/early-boot contexts" + ) + for ram_string in long_strings: + clean_string = ram_string.content[:60] + ( + "..." if len(ram_string.content) > 60 else "" + ) + lines.append( + f' {ram_string.section} @ 0x{ram_string.address:08x}: "{clean_string}" ({len(ram_string.content)} bytes)' + ) + + lines.append("") + return "\n".join(lines) diff --git a/esphome/analyze_memory/toolchain.py b/esphome/analyze_memory/toolchain.py new file mode 100644 index 0000000000..e766252412 --- /dev/null +++ b/esphome/analyze_memory/toolchain.py @@ -0,0 +1,57 @@ +"""Toolchain utilities for memory analysis.""" + +from __future__ import annotations + +import logging +from pathlib import Path +import subprocess + +_LOGGER = logging.getLogger(__name__) + +# Platform-specific toolchain prefixes +TOOLCHAIN_PREFIXES = [ + "xtensa-lx106-elf-", # ESP8266 + "xtensa-esp32-elf-", # ESP32 + "xtensa-esp-elf-", # ESP32 (newer IDF) + "", # System default (no prefix) +] + + +def find_tool( + tool_name: str, + objdump_path: str | None = None, +) -> str | None: + """Find a toolchain tool by name. + + First tries to derive the tool path from objdump_path (if provided), + then falls back to searching for platform-specific tools. + + Args: + tool_name: Name of the tool (e.g., "objdump", "nm", "c++filt") + objdump_path: Path to objdump binary to derive other tool paths from + + Returns: + Path to the tool or None if not found + """ + # Try to derive from objdump path first (most reliable) + if objdump_path and objdump_path != "objdump": + objdump_file = Path(objdump_path) + # Replace just the filename portion, preserving any prefix (e.g., xtensa-esp32-elf-) + new_name = objdump_file.name.replace("objdump", tool_name) + potential_path = str(objdump_file.with_name(new_name)) + if Path(potential_path).exists(): + _LOGGER.debug("Found %s at: %s", tool_name, potential_path) + return potential_path + + # Try platform-specific tools + for prefix in TOOLCHAIN_PREFIXES: + cmd = f"{prefix}{tool_name}" + try: + subprocess.run([cmd, "--version"], capture_output=True, check=True) + _LOGGER.debug("Found %s: %s", tool_name, cmd) + return cmd + except (subprocess.CalledProcessError, FileNotFoundError): + continue + + _LOGGER.warning("Could not find %s tool", tool_name) + return None diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp index 2c5603ee3d..d16a024d86 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.cpp +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -87,7 +87,7 @@ void AbsoluteHumidityComponent::loop() { break; default: this->publish_state(NAN); - this->status_set_error("Invalid saturation vapor pressure equation selection!"); + this->status_set_error(LOG_STR("Invalid saturation vapor pressure equation selection!")); return; } ESP_LOGD(TAG, "Saturation vapor pressure %f kPa", es); diff --git a/esphome/components/ade7953_base/__init__.py b/esphome/components/ade7953_base/__init__.py index 42b6c8ba24..4fc35352f9 100644 --- a/esphome/components/ade7953_base/__init__.py +++ b/esphome/components/ade7953_base/__init__.py @@ -24,6 +24,8 @@ from esphome.const import ( UNIT_WATT, ) +CODEOWNERS = ["@angelnu"] + CONF_CURRENT_A = "current_a" CONF_CURRENT_B = "current_b" CONF_ACTIVE_POWER_A = "active_power_a" diff --git a/esphome/components/ade7953_base/ade7953_base.cpp b/esphome/components/ade7953_base/ade7953_base.cpp index 5f5fdd27ee..821e4a3105 100644 --- a/esphome/components/ade7953_base/ade7953_base.cpp +++ b/esphome/components/ade7953_base/ade7953_base.cpp @@ -25,7 +25,8 @@ void ADE7953::setup() { this->ade_write_8(PGA_V_8, pga_v_); this->ade_write_8(PGA_IA_8, pga_ia_); this->ade_write_8(PGA_IB_8, pga_ib_); - this->ade_write_32(AVGAIN_32, vgain_); + this->ade_write_32(AVGAIN_32, avgain_); + this->ade_write_32(BVGAIN_32, bvgain_); this->ade_write_32(AIGAIN_32, aigain_); this->ade_write_32(BIGAIN_32, bigain_); this->ade_write_32(AWGAIN_32, awgain_); @@ -34,7 +35,8 @@ void ADE7953::setup() { this->ade_read_8(PGA_V_8, &pga_v_); this->ade_read_8(PGA_IA_8, &pga_ia_); this->ade_read_8(PGA_IB_8, &pga_ib_); - this->ade_read_32(AVGAIN_32, &vgain_); + this->ade_read_32(AVGAIN_32, &avgain_); + this->ade_read_32(BVGAIN_32, &bvgain_); this->ade_read_32(AIGAIN_32, &aigain_); this->ade_read_32(BIGAIN_32, &bigain_); this->ade_read_32(AWGAIN_32, &awgain_); @@ -63,13 +65,14 @@ void ADE7953::dump_config() { " PGA_V_8: 0x%X\n" " PGA_IA_8: 0x%X\n" " PGA_IB_8: 0x%X\n" - " VGAIN_32: 0x%08jX\n" + " AVGAIN_32: 0x%08jX\n" + " BVGAIN_32: 0x%08jX\n" " AIGAIN_32: 0x%08jX\n" " BIGAIN_32: 0x%08jX\n" " AWGAIN_32: 0x%08jX\n" " BWGAIN_32: 0x%08jX", - this->use_acc_energy_regs_, pga_v_, pga_ia_, pga_ib_, (uintmax_t) vgain_, (uintmax_t) aigain_, - (uintmax_t) bigain_, (uintmax_t) awgain_, (uintmax_t) bwgain_); + this->use_acc_energy_regs_, pga_v_, pga_ia_, pga_ib_, (uintmax_t) avgain_, (uintmax_t) bvgain_, + (uintmax_t) aigain_, (uintmax_t) bigain_, (uintmax_t) awgain_, (uintmax_t) bwgain_); } #define ADE_PUBLISH_(name, val, factor) \ diff --git a/esphome/components/ade7953_base/ade7953_base.h b/esphome/components/ade7953_base/ade7953_base.h index d711a5c6be..bcafddca4e 100644 --- a/esphome/components/ade7953_base/ade7953_base.h +++ b/esphome/components/ade7953_base/ade7953_base.h @@ -46,7 +46,12 @@ class ADE7953 : public PollingComponent, public sensor::Sensor { void set_pga_ib(uint8_t pga_ib) { pga_ib_ = pga_ib; } // Set input gains - void set_vgain(uint32_t vgain) { vgain_ = vgain; } + void set_vgain(uint32_t vgain) { + // Datasheet says: "to avoid discrepancies in other registers, + // if AVGAIN is set then BVGAIN should be set to the same value." + avgain_ = vgain; + bvgain_ = vgain; + } void set_aigain(uint32_t aigain) { aigain_ = aigain; } void set_bigain(uint32_t bigain) { bigain_ = bigain; } void set_awgain(uint32_t awgain) { awgain_ = awgain; } @@ -100,7 +105,8 @@ class ADE7953 : public PollingComponent, public sensor::Sensor { uint8_t pga_v_; uint8_t pga_ia_; uint8_t pga_ib_; - uint32_t vgain_; + uint32_t avgain_; + uint32_t bvgain_; uint32_t aigain_; uint32_t bigain_; uint32_t awgain_; diff --git a/esphome/components/aht10/aht10.cpp b/esphome/components/aht10/aht10.cpp index 53c712a7a7..03d9d9cd9e 100644 --- a/esphome/components/aht10/aht10.cpp +++ b/esphome/components/aht10/aht10.cpp @@ -83,7 +83,7 @@ void AHT10Component::setup() { void AHT10Component::restart_read_() { if (this->read_count_ == AHT10_ATTEMPTS) { this->read_count_ = 0; - this->status_set_error("Reading timed out"); + this->status_set_error(LOG_STR("Reading timed out")); return; } this->read_count_++; diff --git a/esphome/components/alpha3/alpha3.cpp b/esphome/components/alpha3/alpha3.cpp index 344f2d5a03..f22a8e2444 100644 --- a/esphome/components/alpha3/alpha3.cpp +++ b/esphome/components/alpha3/alpha3.cpp @@ -56,13 +56,13 @@ bool Alpha3::is_current_response_type_(const uint8_t *response_type) { void Alpha3::handle_geni_response_(const uint8_t *response, uint16_t length) { if (this->response_offset_ >= this->response_length_) { - ESP_LOGD(TAG, "[%s] GENI response begin", this->parent_->address_str().c_str()); + ESP_LOGD(TAG, "[%s] GENI response begin", this->parent_->address_str()); if (length < GENI_RESPONSE_HEADER_LENGTH) { - ESP_LOGW(TAG, "[%s] response to short", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] response too short", this->parent_->address_str()); return; } if (response[0] != 36 || response[2] != 248 || response[3] != 231 || response[4] != 10) { - ESP_LOGW(TAG, "[%s] response bytes %d %d %d %d %d don't match GENI HEADER", this->parent_->address_str().c_str(), + ESP_LOGW(TAG, "[%s] response bytes %d %d %d %d %d don't match GENI HEADER", this->parent_->address_str(), response[0], response[1], response[2], response[3], response[4]); return; } @@ -77,11 +77,11 @@ void Alpha3::handle_geni_response_(const uint8_t *response, uint16_t length) { }; if (this->is_current_response_type_(GENI_RESPONSE_TYPE_FLOW_HEAD)) { - ESP_LOGD(TAG, "[%s] FLOW HEAD Response", this->parent_->address_str().c_str()); + ESP_LOGD(TAG, "[%s] FLOW HEAD Response", this->parent_->address_str()); extract_publish_sensor_value(GENI_RESPONSE_FLOW_OFFSET, this->flow_sensor_, 3600.0F); extract_publish_sensor_value(GENI_RESPONSE_HEAD_OFFSET, this->head_sensor_, .0001F); } else if (this->is_current_response_type_(GENI_RESPONSE_TYPE_POWER)) { - ESP_LOGD(TAG, "[%s] POWER Response", this->parent_->address_str().c_str()); + ESP_LOGD(TAG, "[%s] POWER Response", this->parent_->address_str()); extract_publish_sensor_value(GENI_RESPONSE_POWER_OFFSET, this->power_sensor_, 1.0F); extract_publish_sensor_value(GENI_RESPONSE_CURRENT_OFFSET, this->current_sensor_, 1.0F); extract_publish_sensor_value(GENI_RESPONSE_MOTOR_SPEED_OFFSET, this->speed_sensor_, 1.0F); @@ -100,7 +100,7 @@ void Alpha3::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc if (param->open.status == ESP_GATT_OK) { this->response_offset_ = 0; this->response_length_ = 0; - ESP_LOGI(TAG, "[%s] connection open", this->parent_->address_str().c_str()); + ESP_LOGI(TAG, "[%s] connection open", this->parent_->address_str()); } break; } @@ -132,7 +132,7 @@ void Alpha3::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc case ESP_GATTC_SEARCH_CMPL_EVT: { auto *chr = this->parent_->get_characteristic(ALPHA3_GENI_SERVICE_UUID, ALPHA3_GENI_CHARACTERISTIC_UUID); if (chr == nullptr) { - ESP_LOGE(TAG, "[%s] No GENI service found at device, not an Alpha3..?", this->parent_->address_str().c_str()); + ESP_LOGE(TAG, "[%s] No GENI service found at device, not an Alpha3..?", this->parent_->address_str()); break; } auto status = esp_ble_gattc_register_for_notify(this->parent_->get_gattc_if(), this->parent_->get_remote_bda(), @@ -164,12 +164,12 @@ void Alpha3::send_request_(uint8_t *request, size_t len) { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->geni_handle_, len, request, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } void Alpha3::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%s] Cannot poll, not connected", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Cannot poll, not connected", this->parent_->address_str()); return; } diff --git a/esphome/components/am43/sensor/am43_sensor.cpp b/esphome/components/am43/sensor/am43_sensor.cpp index 4cc99001ae..b2bc3254e2 100644 --- a/esphome/components/am43/sensor/am43_sensor.cpp +++ b/esphome/components/am43/sensor/am43_sensor.cpp @@ -44,11 +44,9 @@ void Am43::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_i auto *chr = this->parent_->get_characteristic(AM43_SERVICE_UUID, AM43_CHARACTERISTIC_UUID); if (chr == nullptr) { if (this->parent_->get_characteristic(AM43_TUYA_SERVICE_UUID, AM43_TUYA_CHARACTERISTIC_UUID) != nullptr) { - ESP_LOGE(TAG, "[%s] Detected a Tuya AM43 which is not supported, sorry.", - this->parent_->address_str().c_str()); + ESP_LOGE(TAG, "[%s] Detected a Tuya AM43 which is not supported, sorry.", this->parent_->address_str()); } else { - ESP_LOGE(TAG, "[%s] No control service found at device, not an AM43..?", - this->parent_->address_str().c_str()); + ESP_LOGE(TAG, "[%s] No control service found at device, not an AM43..?", this->parent_->address_str()); } break; } @@ -82,8 +80,7 @@ void Am43::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_i this->char_handle_, packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), - status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } } this->current_sensor_ = 0; @@ -97,7 +94,7 @@ void Am43::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_i void Am43::update() { if (this->node_state != espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%s] Cannot poll, not connected", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Cannot poll, not connected", this->parent_->address_str()); return; } if (this->current_sensor_ == 0) { @@ -107,7 +104,7 @@ void Am43::update() { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, packet->length, packet->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } } this->current_sensor_++; diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp index f83f2aff08..0b3bd0e472 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.cpp @@ -12,10 +12,11 @@ void AnalogThresholdBinarySensor::setup() { // TRUE state is defined to be when sensor is >= threshold // so when undefined sensor value initialize to FALSE if (std::isnan(sensor_value)) { + this->raw_state_ = false; this->publish_initial_state(false); } else { - this->publish_initial_state(sensor_value >= - (this->lower_threshold_.value() + this->upper_threshold_.value()) / 2.0f); + this->raw_state_ = sensor_value >= (this->lower_threshold_.value() + this->upper_threshold_.value()) / 2.0f; + this->publish_initial_state(this->raw_state_); } } @@ -25,8 +26,10 @@ void AnalogThresholdBinarySensor::set_sensor(sensor::Sensor *analog_sensor) { this->sensor_->add_on_state_callback([this](float sensor_value) { // if there is an invalid sensor reading, ignore the change and keep the current state if (!std::isnan(sensor_value)) { - this->publish_state(sensor_value >= - (this->state ? this->lower_threshold_.value() : this->upper_threshold_.value())); + // Use raw_state_ for hysteresis logic, not this->state which is post-filter + this->raw_state_ = + sensor_value >= (this->raw_state_ ? this->lower_threshold_.value() : this->upper_threshold_.value()); + this->publish_state(this->raw_state_); } }); } diff --git a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h index 55d6b15c36..9ea95d8570 100644 --- a/esphome/components/analog_threshold/analog_threshold_binary_sensor.h +++ b/esphome/components/analog_threshold/analog_threshold_binary_sensor.h @@ -20,6 +20,7 @@ class AnalogThresholdBinarySensor : public Component, public binary_sensor::Bina sensor::Sensor *sensor_{nullptr}; TemplatableValue upper_threshold_{}; TemplatableValue lower_threshold_{}; + bool raw_state_{false}; // Pre-filter state for hysteresis logic }; } // namespace analog_threshold diff --git a/esphome/components/anova/anova.cpp b/esphome/components/anova/anova.cpp index d0e8f6827f..2693224a97 100644 --- a/esphome/components/anova/anova.cpp +++ b/esphome/components/anova/anova.cpp @@ -42,7 +42,7 @@ void Anova::control(const ClimateCall &call) { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } } if (call.get_target_temperature().has_value()) { @@ -51,7 +51,7 @@ void Anova::control(const ClimateCall &call) { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } } } @@ -124,8 +124,7 @@ void Anova::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_ esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), - status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } } } @@ -150,7 +149,7 @@ void Anova::update() { esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, pkt->length, pkt->data, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } this->current_request_++; } diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index a9286c531f..2910643dfb 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -85,6 +85,7 @@ CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" CONF_LISTEN_BACKLOG = "listen_backlog" CONF_MAX_SEND_QUEUE = "max_send_queue" +CONF_STATE_SUBSCRIPTION_ONLY = "state_subscription_only" def validate_encryption_key(value): @@ -260,9 +261,9 @@ async def to_code(config): cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) cg.add_define("API_MAX_SEND_QUEUE", config[CONF_MAX_SEND_QUEUE]) - # Set USE_API_SERVICES if any services are enabled + # Set USE_API_USER_DEFINED_ACTIONS if any services are enabled if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: - cg.add_define("USE_API_SERVICES") + cg.add_define("USE_API_USER_DEFINED_ACTIONS") # Set USE_API_CUSTOM_SERVICES if external components need dynamic service registration if config[CONF_CUSTOM_SERVICES]: @@ -537,9 +538,24 @@ async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, arg return var -@automation.register_condition("api.connected", APIConnectedCondition, {}) +API_CONNECTED_CONDITION_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.use_id(APIServer), + cv.Optional(CONF_STATE_SUBSCRIPTION_ONLY, default=False): cv.templatable( + cv.boolean + ), + } +) + + +@automation.register_condition( + "api.connected", APIConnectedCondition, API_CONNECTED_CONDITION_SCHEMA +) async def api_connected_to_code(config, condition_id, template_arg, args): - return cg.new_Pvariable(condition_id, template_arg) + var = cg.new_Pvariable(condition_id, template_arg) + templ = await cg.templatable(config[CONF_STATE_SUBSCRIPTION_ONLY], args, cg.bool_) + cg.add(var.set_state_subscription_only(templ)) + return var def FILTER_SOURCE_FILES() -> list[str]: diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index e115e4630d..5450c2536c 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -518,7 +518,7 @@ message ListEntitiesLightResponse { bool legacy_supports_color_temperature = 8 [deprecated=true]; float min_mireds = 9; float max_mireds = 10; - repeated string effects = 11; + repeated string effects = 11 [(container_pointer_no_template) = "FixedVector"]; bool disabled_by_default = 13; string icon = 14 [(field_ifdef) = "USE_ENTITY_ICON"]; EntityCategory entity_category = 15; @@ -589,6 +589,7 @@ enum SensorStateClass { STATE_CLASS_MEASUREMENT = 1; STATE_CLASS_TOTAL_INCREASING = 2; STATE_CLASS_TOTAL = 3; + STATE_CLASS_MEASUREMENT_ANGLE = 4; } // Deprecated in API version 1.5 @@ -855,21 +856,21 @@ enum ServiceArgType { SERVICE_ARG_TYPE_STRING_ARRAY = 7; } message ListEntitiesServicesArgument { - option (ifdef) = "USE_API_SERVICES"; + option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; string name = 1; ServiceArgType type = 2; } message ListEntitiesServicesResponse { option (id) = 41; option (source) = SOURCE_SERVER; - option (ifdef) = "USE_API_SERVICES"; + option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; string name = 1; fixed32 key = 2; repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true]; } message ExecuteServiceArgument { - option (ifdef) = "USE_API_SERVICES"; + option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; bool bool_ = 1; int32 legacy_int = 2; float float_ = 3; @@ -885,7 +886,7 @@ message ExecuteServiceRequest { option (id) = 42; option (source) = SOURCE_CLIENT; option (no_delay) = true; - option (ifdef) = "USE_API_SERVICES"; + option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; fixed32 key = 1; repeated ExecuteServiceArgument args = 2 [(fixed_vector) = true]; diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 7eb61f08b6..9ad45dc6b7 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -90,8 +90,8 @@ static const int CAMERA_STOP_STREAM = 5000; APIConnection::APIConnection(std::unique_ptr sock, APIServer *parent) : parent_(parent), initial_state_iterator_(this), list_entities_iterator_(this) { #if defined(USE_API_PLAINTEXT) && defined(USE_API_NOISE) - auto noise_ctx = parent->get_noise_ctx(); - if (noise_ctx->has_psk()) { + auto &noise_ctx = parent->get_noise_ctx(); + if (noise_ctx.has_psk()) { this->helper_ = std::unique_ptr{new APINoiseFrameHelper(std::move(sock), noise_ctx, &this->client_info_)}; } else { @@ -169,8 +169,7 @@ void APIConnection::loop() { } else { this->last_traffic_ = now; // read a packet - this->read_message(buffer.data_len, buffer.type, - buffer.data_len > 0 ? &buffer.container[buffer.data_offset] : nullptr); + this->read_message(buffer.data_len, buffer.type, buffer.data); if (this->flags_.remove) return; } @@ -195,6 +194,9 @@ void APIConnection::loop() { } // Now that everything is sent, enable immediate sending for future state changes this->flags_.should_try_send_immediately = true; + // Release excess memory from buffers that grew during initial sync + this->deferred_batch_.release_buffer(); + this->helper_->release_buffers(); } } @@ -476,19 +478,24 @@ uint16_t APIConnection::try_send_light_info(EntityBase *entity, APIConnection *c auto *light = static_cast(entity); ListEntitiesLightResponse msg; auto traits = light->get_traits(); + auto supported_modes = traits.get_supported_color_modes(); // Pass pointer to ColorModeMask so the iterator can encode actual ColorMode enum values - msg.supported_color_modes = &traits.get_supported_color_modes(); + msg.supported_color_modes = &supported_modes; if (traits.supports_color_capability(light::ColorCapability::COLOR_TEMPERATURE) || traits.supports_color_capability(light::ColorCapability::COLD_WARM_WHITE)) { msg.min_mireds = traits.get_min_mireds(); msg.max_mireds = traits.get_max_mireds(); } + FixedVector effects_list; if (light->supports_effects()) { - msg.effects.emplace_back("None"); - for (auto *effect : light->get_effects()) { - msg.effects.emplace_back(effect->get_name()); + auto &light_effects = light->get_effects(); + effects_list.init(light_effects.size() + 1); + effects_list.push_back("None"); + for (auto *effect : light_effects) { + effects_list.push_back(effect->get_name()); } } + msg.effects = &effects_list; return fill_and_encode_entity_info(light, msg, ListEntitiesLightResponse::MESSAGE_TYPE, conn, remaining_size, is_single); } @@ -1294,11 +1301,11 @@ void APIConnection::alarm_control_panel_command(const AlarmControlPanelCommandRe #endif #ifdef USE_EVENT -void APIConnection::send_event(event::Event *event, const std::string &event_type) { - this->schedule_message_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE, - EventResponse::ESTIMATED_SIZE); +void APIConnection::send_event(event::Event *event, const char *event_type) { + this->send_message_smart_(event, MessageCreator(event_type), EventResponse::MESSAGE_TYPE, + EventResponse::ESTIMATED_SIZE); } -uint16_t APIConnection::try_send_event_response(event::Event *event, const std::string &event_type, APIConnection *conn, +uint16_t APIConnection::try_send_event_response(event::Event *event, const char *event_type, APIConnection *conn, uint32_t remaining_size, bool is_single) { EventResponse resp; resp.set_event_type(StringRef(event_type)); @@ -1450,8 +1457,11 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { #ifdef USE_AREAS resp.set_suggested_area(StringRef(App.get_area())); #endif - // mac_address must store temporary string - will be valid during send_message call - std::string mac_address = get_mac_address_pretty(); + // Stack buffer for MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) + char mac_address[18]; + uint8_t mac[6]; + get_mac_address_raw(mac); + format_mac_addr_upper(mac, mac_address); resp.set_mac_address(StringRef(mac_address)); resp.set_esphome_version(ESPHOME_VERSION_REF); @@ -1492,8 +1502,9 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { #endif #ifdef USE_BLUETOOTH_PROXY resp.bluetooth_proxy_feature_flags = bluetooth_proxy::global_bluetooth_proxy->get_feature_flags(); - // bt_mac must store temporary string - will be valid during send_message call - std::string bluetooth_mac = bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(); + // Stack buffer for Bluetooth MAC address (XX:XX:XX:XX:XX:XX\0 = 18 bytes) + char bluetooth_mac[18]; + bluetooth_proxy::global_bluetooth_proxy->get_bluetooth_mac_address_pretty(bluetooth_mac); resp.set_bluetooth_mac_address(StringRef(bluetooth_mac)); #endif #ifdef USE_VOICE_ASSISTANT @@ -1540,7 +1551,7 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes } } #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void APIConnection::execute_service(const ExecuteServiceRequest &msg) { bool found = false; for (auto *service : this->parent_->get_user_services()) { @@ -1650,9 +1661,7 @@ void APIConnection::DeferredBatch::add_item(EntityBase *entity, MessageCreator c // O(n) but optimized for RAM and not performance. for (auto &item : items) { if (item.entity == entity && item.message_type == message_type) { - // Clean up old creator before replacing - item.creator.cleanup(message_type); - // Move assign the new creator + // Replace with new creator item.creator = std::move(creator); return; } @@ -1822,7 +1831,7 @@ void APIConnection::process_batch_() { // Handle remaining items more efficiently if (items_processed < this->deferred_batch_.size()) { - // Remove processed items from the beginning with proper cleanup + // Remove processed items from the beginning this->deferred_batch_.remove_front(items_processed); // Reschedule for remaining items this->schedule_batch_(); @@ -1835,10 +1844,10 @@ void APIConnection::process_batch_() { uint16_t APIConnection::MessageCreator::operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single, uint8_t message_type) const { #ifdef USE_EVENT - // Special case: EventResponse uses string pointer + // Special case: EventResponse uses const char * pointer if (message_type == EventResponse::MESSAGE_TYPE) { auto *e = static_cast(entity); - return APIConnection::try_send_event_response(e, *data_.string_ptr, conn, remaining_size, is_single); + return APIConnection::try_send_event_response(e, data_.const_char_ptr, conn, remaining_size, is_single); } #endif diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 284fa11a95..05af0ccde7 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -177,7 +177,7 @@ class APIConnection final : public APIServerConnection { #endif #ifdef USE_EVENT - void send_event(event::Event *event, const std::string &event_type); + void send_event(event::Event *event, const char *event_type); #endif #ifdef USE_UPDATE @@ -221,7 +221,7 @@ class APIConnection final : public APIServerConnection { #ifdef USE_API_HOMEASSISTANT_STATES void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) override; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void execute_service(const ExecuteServiceRequest &msg) override; #endif #ifdef USE_API_NOISE @@ -450,7 +450,7 @@ class APIConnection final : public APIServerConnection { bool is_single); #endif #ifdef USE_EVENT - static uint16_t try_send_event_response(event::Event *event, const std::string &event_type, APIConnection *conn, + static uint16_t try_send_event_response(event::Event *event, const char *event_type, APIConnection *conn, uint32_t remaining_size, bool is_single); static uint16_t try_send_event_info(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single); #endif @@ -508,10 +508,8 @@ class APIConnection final : public APIServerConnection { // Constructor for function pointer MessageCreator(MessageCreatorPtr ptr) { data_.function_ptr = ptr; } - // Constructor for string state capture - explicit MessageCreator(const std::string &str_value) { data_.string_ptr = new std::string(str_value); } - - // No destructor - cleanup must be called explicitly with message_type + // Constructor for const char * (Event types - no allocation needed) + explicit MessageCreator(const char *str_value) { data_.const_char_ptr = str_value; } // Delete copy operations - MessageCreator should only be moved MessageCreator(const MessageCreator &other) = delete; @@ -523,8 +521,6 @@ class APIConnection final : public APIServerConnection { // Move assignment MessageCreator &operator=(MessageCreator &&other) noexcept { if (this != &other) { - // IMPORTANT: Caller must ensure cleanup() was called if this contains a string! - // In our usage, this happens in add_item() deduplication and vector::erase() data_ = other.data_; other.data_.function_ptr = nullptr; } @@ -535,20 +531,10 @@ class APIConnection final : public APIServerConnection { uint16_t operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single, uint8_t message_type) const; - // Manual cleanup method - must be called before destruction for string types - void cleanup(uint8_t message_type) { -#ifdef USE_EVENT - if (message_type == EventResponse::MESSAGE_TYPE && data_.string_ptr != nullptr) { - delete data_.string_ptr; - data_.string_ptr = nullptr; - } -#endif - } - private: union Data { MessageCreatorPtr function_ptr; - std::string *string_ptr; + const char *const_char_ptr; } data_; // 4 bytes on 32-bit, 8 bytes on 64-bit - same as before }; @@ -568,46 +554,35 @@ class APIConnection final : public APIServerConnection { std::vector items; uint32_t batch_start_time{0}; - private: - // Helper to cleanup items from the beginning - void cleanup_items_(size_t count) { - for (size_t i = 0; i < count; i++) { - items[i].creator.cleanup(items[i].message_type); - } - } - - public: - DeferredBatch() { - // Pre-allocate capacity for typical batch sizes to avoid reallocation - items.reserve(8); - } - - ~DeferredBatch() { - // Ensure cleanup of any remaining items - clear(); - } + // No pre-allocation - log connections never use batching, and for + // connections that do, buffers are released after initial sync anyway // Add item to the batch void add_item(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size); // Add item to the front of the batch (for high priority messages like ping) void add_item_front(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size); - // Clear all items with proper cleanup + // Clear all items void clear() { - cleanup_items_(items.size()); items.clear(); batch_start_time = 0; } - // Remove processed items from the front with proper cleanup - void remove_front(size_t count) { - cleanup_items_(count); - items.erase(items.begin(), items.begin() + count); - } + // Remove processed items from the front + void remove_front(size_t count) { items.erase(items.begin(), items.begin() + count); } bool empty() const { return items.empty(); } size_t size() const { return items.size(); } const BatchItem &operator[](size_t index) const { return items[index]; } + // Release excess capacity - only releases if items already empty + void release_buffer() { + // Safe to call: batch is processed before release_buffer is called, + // and if any items remain (partial processing), we must not clear them. + // Use swap trick since shrink_to_fit() is non-binding and may be ignored. + if (items.empty()) { + std::vector().swap(items); + } + } }; // DeferredBatch here (16 bytes, 4-byte aligned) @@ -682,21 +657,30 @@ class APIConnection final : public APIServerConnection { } #endif + // Helper to check if a message type should bypass batching + // Returns true if: + // 1. It's an UpdateStateResponse (always send immediately to handle cases where + // the main loop is blocked, e.g., during OTA updates) + // 2. It's an EventResponse (events are edge-triggered - every occurrence matters) + // 3. OR: User has opted into immediate sending (should_try_send_immediately = true + // AND batch_delay = 0) + inline bool should_send_immediately_(uint8_t message_type) const { + return ( +#ifdef USE_UPDATE + message_type == UpdateStateResponse::MESSAGE_TYPE || +#endif +#ifdef USE_EVENT + message_type == EventResponse::MESSAGE_TYPE || +#endif + (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)); + } + // Helper method to send a message either immediately or via batching + // Tries immediate send if should_send_immediately_() returns true and buffer has space + // Falls back to batching if immediate send fails or isn't applicable bool send_message_smart_(EntityBase *entity, MessageCreatorPtr creator, uint8_t message_type, uint8_t estimated_size) { - // Try to send immediately if: - // 1. It's an UpdateStateResponse (always send immediately to handle cases where - // the main loop is blocked, e.g., during OTA updates) - // 2. OR: We should try to send immediately (should_try_send_immediately = true) - // AND Batch delay is 0 (user has opted in to immediate sending) - // 3. AND: Buffer has space available - if (( -#ifdef USE_UPDATE - message_type == UpdateStateResponse::MESSAGE_TYPE || -#endif - (this->flags_.should_try_send_immediately && this->get_batch_delay_ms_() == 0)) && - this->helper_->can_write_without_blocking()) { + if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) { // Now actually encode and send if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true) && this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) { @@ -714,6 +698,27 @@ class APIConnection final : public APIServerConnection { return this->schedule_message_(entity, creator, message_type, estimated_size); } + // Overload for MessageCreator (used by events which need to capture event_type) + bool send_message_smart_(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) { + // Try to send immediately if message type should bypass batching and buffer has space + if (this->should_send_immediately_(message_type) && this->helper_->can_write_without_blocking()) { + // Now actually encode and send + if (creator(entity, this, MAX_BATCH_PACKET_SIZE, true, message_type) && + this->send_buffer(ProtoWriteBuffer{&this->parent_->get_shared_buffer_ref()}, message_type)) { +#ifdef HAS_PROTO_MESSAGE_DUMP + // Log the message in verbose mode + this->log_proto_message_(entity, creator, message_type); +#endif + return true; + } + + // If immediate send failed, fall through to batching + } + + // Fall back to scheduled batching + return this->schedule_message_(entity, std::move(creator), message_type, estimated_size); + } + // Helper function to schedule a deferred message with known message type bool schedule_message_(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) { this->deferred_batch_.add_item(entity, std::move(creator), message_type, estimated_size); diff --git a/esphome/components/api/api_frame_helper.h b/esphome/components/api/api_frame_helper.h index 9aaada3cf7..b582bcea9a 100644 --- a/esphome/components/api/api_frame_helper.h +++ b/esphome/components/api/api_frame_helper.h @@ -35,10 +35,9 @@ struct ClientInfo; class ProtoWriteBuffer; struct ReadPacketBuffer { - std::vector container; - uint16_t type; - uint16_t data_offset; + const uint8_t *data; // Points directly into frame helper's rx_buf_ (valid until next read_packet call) uint16_t data_len; + uint16_t type; }; // Packed packet info structure to minimize memory usage @@ -84,9 +83,7 @@ class APIFrameHelper { public: APIFrameHelper() = default; explicit APIFrameHelper(std::unique_ptr socket, const ClientInfo *client_info) - : socket_owned_(std::move(socket)), client_info_(client_info) { - socket_ = socket_owned_.get(); - } + : socket_(std::move(socket)), client_info_(client_info) {} virtual ~APIFrameHelper() = default; virtual APIError init() = 0; virtual APIError loop(); @@ -121,6 +118,22 @@ class APIFrameHelper { uint8_t frame_footer_size() const { return frame_footer_size_; } // Check if socket has data ready to read bool is_socket_ready() const { return socket_ != nullptr && socket_->ready(); } + // Release excess memory from internal buffers after initial sync + void release_buffers() { + // rx_buf_: Safe to clear only if no partial read in progress. + // rx_buf_len_ tracks bytes read so far; if non-zero, we're mid-frame + // and clearing would lose partially received data. + if (this->rx_buf_len_ == 0) { + // Use swap trick since shrink_to_fit() is non-binding and may be ignored + std::vector().swap(this->rx_buf_); + } + // reusable_iovs_: Safe to release unconditionally. + // Only used within write_protobuf_packets() calls - cleared at start, + // populated with pointers, used for writev(), then function returns. + // The iovecs contain stale pointers after the call (data was either sent + // or copied to tx_buf_), and are cleared on next write_protobuf_packets(). + std::vector().swap(this->reusable_iovs_); + } protected: // Buffer containing data to be sent @@ -149,9 +162,8 @@ class APIFrameHelper { APIError write_raw_(const struct iovec *iov, int iovcnt, socket::Socket *socket, std::vector &tx_buf, const std::string &info, StateEnum &state, StateEnum failed_state); - // Pointers first (4 bytes each) - socket::Socket *socket_{nullptr}; - std::unique_ptr socket_owned_; + // Socket ownership (4 bytes on 32-bit, 8 bytes on 64-bit) + std::unique_ptr socket_; // Common state enum for all frame helpers // Note: Not all states are used by all implementations diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index 633b07a7fa..ae69f0b673 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -239,12 +239,13 @@ APIError APINoiseFrameHelper::state_action_() { } if (state_ == State::SERVER_HELLO) { // send server hello + constexpr size_t mac_len = 13; // 12 hex chars + null terminator const std::string &name = App.get_name(); - const std::string &mac = get_mac_address(); + char mac[mac_len]; + get_mac_address_into_buffer(mac); // Calculate positions and sizes size_t name_len = name.size() + 1; // including null terminator - size_t mac_len = mac.size() + 1; // including null terminator size_t name_offset = 1; size_t mac_offset = name_offset + name_len; size_t total_size = 1 + name_len + mac_len; @@ -257,7 +258,7 @@ APIError APINoiseFrameHelper::state_action_() { // node name, terminated by null byte std::memcpy(msg.get() + name_offset, name.c_str(), name_len); // node mac, terminated by null byte - std::memcpy(msg.get() + mac_offset, mac.c_str(), mac_len); + std::memcpy(msg.get() + mac_offset, mac, mac_len); aerr = write_frame_(msg.get(), total_size); if (aerr != APIError::OK) @@ -406,8 +407,7 @@ APIError APINoiseFrameHelper::read_packet(ReadPacketBuffer *buffer) { return APIError::BAD_DATA_PACKET; } - buffer->container = std::move(this->rx_buf_); - buffer->data_offset = 4; + buffer->data = msg_data + 4; // Skip 4-byte header (type + length) buffer->data_len = data_len; buffer->type = type; return APIError::OK; @@ -527,7 +527,7 @@ APIError APINoiseFrameHelper::init_handshake_() { if (aerr != APIError::OK) return aerr; - const auto &psk = ctx_->get_psk(); + const auto &psk = this->ctx_.get_psk(); err = noise_handshakestate_set_pre_shared_key(handshake_, psk.data(), psk.size()); aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_set_pre_shared_key"), APIError::HANDSHAKESTATE_SETUP_FAILED); diff --git a/esphome/components/api/api_frame_helper_noise.h b/esphome/components/api/api_frame_helper_noise.h index e3243e4fa5..7eb01058db 100644 --- a/esphome/components/api/api_frame_helper_noise.h +++ b/esphome/components/api/api_frame_helper_noise.h @@ -9,9 +9,8 @@ namespace esphome::api { class APINoiseFrameHelper final : public APIFrameHelper { public: - APINoiseFrameHelper(std::unique_ptr socket, std::shared_ptr ctx, - const ClientInfo *client_info) - : APIFrameHelper(std::move(socket), client_info), ctx_(std::move(ctx)) { + APINoiseFrameHelper(std::unique_ptr socket, APINoiseContext &ctx, const ClientInfo *client_info) + : APIFrameHelper(std::move(socket), client_info), ctx_(ctx) { // Noise header structure: // Pos 0: indicator (0x01) // Pos 1-2: encrypted payload size (16-bit big-endian) @@ -41,8 +40,8 @@ class APINoiseFrameHelper final : public APIFrameHelper { NoiseCipherState *send_cipher_{nullptr}; NoiseCipherState *recv_cipher_{nullptr}; - // Shared pointer (8 bytes on 32-bit = 4 bytes control block pointer + 4 bytes object pointer) - std::shared_ptr ctx_; + // Reference to noise context (4 bytes on 32-bit) + APINoiseContext &ctx_; // Vector (12 bytes on 32-bit) std::vector prologue_; diff --git a/esphome/components/api/api_frame_helper_plaintext.cpp b/esphome/components/api/api_frame_helper_plaintext.cpp index dcbd35aa32..b5d90b2429 100644 --- a/esphome/components/api/api_frame_helper_plaintext.cpp +++ b/esphome/components/api/api_frame_helper_plaintext.cpp @@ -210,8 +210,7 @@ APIError APIPlaintextFrameHelper::read_packet(ReadPacketBuffer *buffer) { return aerr; } - buffer->container = std::move(this->rx_buf_); - buffer->data_offset = 0; + buffer->data = this->rx_buf_.data(); buffer->data_len = this->rx_header_parsed_len_; buffer->type = this->rx_header_parsed_type_; return APIError::OK; diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index 0a073fb662..c131815456 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -476,8 +476,8 @@ void ListEntitiesLightResponse::encode(ProtoWriteBuffer buffer) const { } buffer.encode_float(9, this->min_mireds); buffer.encode_float(10, this->max_mireds); - for (auto &it : this->effects) { - buffer.encode_string(11, it, true); + for (const char *it : *this->effects) { + buffer.encode_string(11, it, strlen(it), true); } buffer.encode_bool(13, this->disabled_by_default); #ifdef USE_ENTITY_ICON @@ -499,9 +499,9 @@ void ListEntitiesLightResponse::calculate_size(ProtoSize &size) const { } size.add_float(1, this->min_mireds); size.add_float(1, this->max_mireds); - if (!this->effects.empty()) { - for (const auto &it : this->effects) { - size.add_length_force(1, it.size()); + if (!this->effects->empty()) { + for (const char *it : *this->effects) { + size.add_length_force(1, strlen(it)); } } size.add_bool(1, this->disabled_by_default); @@ -995,7 +995,7 @@ bool GetTimeResponse::decode_32bit(uint32_t field_id, Proto32Bit value) { } return true; } -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void ListEntitiesServicesArgument::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->name_ref_); buffer.encode_uint32(2, static_cast(this->type)); diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 358049026e..74d3834bf5 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -51,6 +51,7 @@ enum SensorStateClass : uint32_t { STATE_CLASS_MEASUREMENT = 1, STATE_CLASS_TOTAL_INCREASING = 2, STATE_CLASS_TOTAL = 3, + STATE_CLASS_MEASUREMENT_ANGLE = 4, }; #endif enum LogLevel : uint32_t { @@ -63,7 +64,7 @@ enum LogLevel : uint32_t { LOG_LEVEL_VERBOSE = 6, LOG_LEVEL_VERY_VERBOSE = 7, }; -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS enum ServiceArgType : uint32_t { SERVICE_ARG_TYPE_BOOL = 0, SERVICE_ARG_TYPE_INT = 1, @@ -793,7 +794,7 @@ class ListEntitiesLightResponse final : public InfoResponseProtoMessage { const light::ColorModeMask *supported_color_modes{}; float min_mireds{0.0f}; float max_mireds{0.0f}; - std::vector effects{}; + const FixedVector *effects{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1239,7 +1240,7 @@ class GetTimeResponse final : public ProtoDecodableMessage { bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; }; -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS class ListEntitiesServicesArgument final : public ProtoMessage { public: StringRef name_ref_{}; diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index d9662483bf..bea7fc53c4 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -66,7 +66,7 @@ static void dump_field(std::string &out, const char *field_name, float value, in static void dump_field(std::string &out, const char *field_name, uint64_t value, int indent = 2) { char buffer[64]; append_field_prefix(out, field_name, indent); - snprintf(buffer, 64, "%llu", value); + snprintf(buffer, 64, "%" PRIu64, value); append_with_newline(out, buffer); } @@ -179,6 +179,8 @@ template<> const char *proto_enum_to_string(enums::Sens return "STATE_CLASS_TOTAL_INCREASING"; case enums::STATE_CLASS_TOTAL: return "STATE_CLASS_TOTAL"; + case enums::STATE_CLASS_MEASUREMENT_ANGLE: + return "STATE_CLASS_MEASUREMENT_ANGLE"; default: return "UNKNOWN"; } @@ -206,7 +208,7 @@ template<> const char *proto_enum_to_string(enums::LogLevel val return "UNKNOWN"; } } -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS template<> const char *proto_enum_to_string(enums::ServiceArgType value) { switch (value) { case enums::SERVICE_ARG_TYPE_BOOL: @@ -924,7 +926,7 @@ void ListEntitiesLightResponse::dump_to(std::string &out) const { } dump_field(out, "min_mireds", this->min_mireds); dump_field(out, "max_mireds", this->max_mireds); - for (const auto &it : this->effects) { + for (const auto &it : *this->effects) { dump_field(out, "effects", it, 4); } dump_field(out, "disabled_by_default", this->disabled_by_default); @@ -1177,7 +1179,7 @@ void GetTimeResponse::dump_to(std::string &out) const { out.append(format_hex_pretty(this->timezone, this->timezone_len)); out.append("\n"); } -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void ListEntitiesServicesArgument::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ListEntitiesServicesArgument"); dump_field(out, "name", this->name_ref_); diff --git a/esphome/components/api/api_pb2_service.cpp b/esphome/components/api/api_pb2_service.cpp index 9d227af0a3..45f6ecd30e 100644 --- a/esphome/components/api/api_pb2_service.cpp +++ b/esphome/components/api/api_pb2_service.cpp @@ -13,7 +13,7 @@ void APIServerConnectionBase::log_send_message_(const char *name, const std::str } #endif -void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { +void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { switch (msg_type) { case HelloRequest::MESSAGE_TYPE: { HelloRequest msg; @@ -193,7 +193,7 @@ void APIServerConnectionBase::read_message(uint32_t msg_size, uint32_t msg_type, break; } #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS case ExecuteServiceRequest::MESSAGE_TYPE: { ExecuteServiceRequest msg; msg.decode(msg_data, msg_size); @@ -670,7 +670,7 @@ void APIServerConnection::on_subscribe_home_assistant_states_request(const Subsc this->subscribe_home_assistant_states(msg); } #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void APIServerConnection::on_execute_service_request(const ExecuteServiceRequest &msg) { this->execute_service(msg); } #endif #ifdef USE_API_NOISE @@ -827,7 +827,7 @@ void APIServerConnection::on_z_wave_proxy_frame(const ZWaveProxyFrame &msg) { th void APIServerConnection::on_z_wave_proxy_request(const ZWaveProxyRequest &msg) { this->zwave_proxy_request(msg); } #endif -void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) { +void APIServerConnection::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) { // Check authentication/connection requirements for messages switch (msg_type) { case HelloRequest::MESSAGE_TYPE: // No setup required diff --git a/esphome/components/api/api_pb2_service.h b/esphome/components/api/api_pb2_service.h index 549b00ee6a..6d94046a23 100644 --- a/esphome/components/api/api_pb2_service.h +++ b/esphome/components/api/api_pb2_service.h @@ -79,7 +79,7 @@ class APIServerConnectionBase : public ProtoService { virtual void on_get_time_response(const GetTimeResponse &value){}; -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS virtual void on_execute_service_request(const ExecuteServiceRequest &value){}; #endif @@ -218,7 +218,7 @@ class APIServerConnectionBase : public ProtoService { virtual void on_z_wave_proxy_request(const ZWaveProxyRequest &value){}; #endif protected: - void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; + void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; class APIServerConnection : public APIServerConnectionBase { @@ -239,7 +239,7 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES virtual void subscribe_home_assistant_states(const SubscribeHomeAssistantStatesRequest &msg) = 0; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS virtual void execute_service(const ExecuteServiceRequest &msg) = 0; #endif #ifdef USE_API_NOISE @@ -368,7 +368,7 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_API_HOMEASSISTANT_STATES void on_subscribe_home_assistant_states_request(const SubscribeHomeAssistantStatesRequest &msg) override; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void on_execute_service_request(const ExecuteServiceRequest &msg) override; #endif #ifdef USE_API_NOISE @@ -480,7 +480,7 @@ class APIServerConnection : public APIServerConnectionBase { #ifdef USE_ZWAVE_PROXY void on_z_wave_proxy_request(const ZWaveProxyRequest &msg) override; #endif - void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override; + void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override; }; } // namespace esphome::api diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 18601d74ff..4168761c74 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -101,30 +101,13 @@ void APIServer::setup() { #ifdef USE_LOGGER if (logger::global_logger != nullptr) { - logger::global_logger->add_on_log_callback( - [this](int level, const char *tag, const char *message, size_t message_len) { - if (this->shutting_down_) { - // Don't try to send logs during shutdown - // as it could result in a recursion and - // we would be filling a buffer we are trying to clear - return; - } - for (auto &c : this->clients_) { - if (!c->flags_.remove && c->get_log_subscription_level() >= level) - c->try_send_log_message(level, tag, message, message_len); - } - }); + logger::global_logger->add_log_listener(this); } #endif #ifdef USE_CAMERA if (camera::Camera::instance() != nullptr && !camera::Camera::instance()->is_internal()) { - camera::Camera::instance()->add_image_callback([this](const std::shared_ptr &image) { - for (auto &c : this->clients_) { - if (!c->flags_.remove) - c->set_camera_state(image); - } - }); + camera::Camera::instance()->add_listener(this); } #endif } @@ -227,8 +210,8 @@ void APIServer::dump_config() { " Max connections: %u", network::get_use_address(), this->port_, this->listen_backlog_, this->max_connections_); #ifdef USE_API_NOISE - ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_->has_psk())); - if (!this->noise_ctx_->has_psk()) { + ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_.has_psk())); + if (!this->noise_ctx_.has_psk()) { ESP_LOGCONFIG(TAG, " Supports encryption: YES"); } #else @@ -493,7 +476,7 @@ bool APIServer::save_noise_psk(psk_t psk, bool make_active) { ESP_LOGW(TAG, "Key set in YAML"); return false; #else - auto &old_psk = this->noise_ctx_->get_psk(); + auto &old_psk = this->noise_ctx_.get_psk(); if (std::equal(old_psk.begin(), old_psk.end(), psk.begin())) { ESP_LOGW(TAG, "New PSK matches old"); return true; @@ -528,7 +511,42 @@ void APIServer::request_time() { } #endif -bool APIServer::is_connected() const { return !this->clients_.empty(); } +bool APIServer::is_connected(bool state_subscription_only) const { + if (!state_subscription_only) { + return !this->clients_.empty(); + } + + for (const auto &client : this->clients_) { + if (client->flags_.state_subscription) { + return true; + } + } + return false; +} + +#ifdef USE_LOGGER +void APIServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { + if (this->shutting_down_) { + // Don't try to send logs during shutdown + // as it could result in a recursion and + // we would be filling a buffer we are trying to clear + return; + } + for (auto &c : this->clients_) { + if (!c->flags_.remove && c->get_log_subscription_level() >= level) + c->try_send_log_message(level, tag, message, message_len); + } +} +#endif + +#ifdef USE_CAMERA +void APIServer::on_camera_image(const std::shared_ptr &image) { + for (auto &c : this->clients_) { + if (!c->flags_.remove) + c->set_camera_state(image); + } +} +#endif void APIServer::on_shutdown() { this->shutting_down_ = true; diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 2d58063d6c..3089bb1d35 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -12,9 +12,15 @@ #include "esphome/core/log.h" #include "list_entities.h" #include "subscribe_state.h" -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS #include "user_services.h" #endif +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif +#ifdef USE_CAMERA +#include "esphome/components/camera/camera.h" +#endif #include #include @@ -27,7 +33,17 @@ struct SavedNoisePsk { } PACKED; // NOLINT #endif -class APIServer : public Component, public Controller { +class APIServer : public Component, + public Controller +#ifdef USE_LOGGER + , + public logger::LogListener +#endif +#ifdef USE_CAMERA + , + public camera::CameraListener +#endif +{ public: APIServer(); void setup() override; @@ -37,6 +53,12 @@ class APIServer : public Component, public Controller { void dump_config() override; void on_shutdown() override; bool teardown() override; +#ifdef USE_LOGGER + void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override; +#endif +#ifdef USE_CAMERA + void on_camera_image(const std::shared_ptr &image) override; +#endif #ifdef USE_API_PASSWORD bool check_password(const uint8_t *password_data, size_t password_len) const; void set_password(const std::string &password); @@ -54,8 +76,8 @@ class APIServer : public Component, public Controller { #ifdef USE_API_NOISE bool save_noise_psk(psk_t psk, bool make_active = true); bool clear_noise_psk(bool make_active = true); - void set_noise_psk(psk_t psk) { noise_ctx_->set_psk(psk); } - std::shared_ptr get_noise_ctx() { return noise_ctx_; } + void set_noise_psk(psk_t psk) { this->noise_ctx_.set_psk(psk); } + APINoiseContext &get_noise_ctx() { return this->noise_ctx_; } #endif // USE_API_NOISE void handle_disconnect(APIConnection *conn); @@ -124,7 +146,7 @@ class APIServer : public Component, public Controller { #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON #endif // USE_API_HOMEASSISTANT_ACTION_RESPONSES #endif // USE_API_HOMEASSISTANT_SERVICES -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS void initialize_user_services(std::initializer_list services) { this->user_services_.assign(services); } @@ -150,7 +172,7 @@ class APIServer : public Component, public Controller { void on_zwave_proxy_request(const esphome::api::ProtoMessage &msg); #endif - bool is_connected() const; + bool is_connected(bool state_subscription_only = false) const; #ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { @@ -166,7 +188,7 @@ class APIServer : public Component, public Controller { std::function f); const std::vector &get_state_subs() const; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS const std::vector &get_user_services() const { return this->user_services_; } #endif @@ -206,7 +228,7 @@ class APIServer : public Component, public Controller { #ifdef USE_API_HOMEASSISTANT_STATES std::vector state_subs_; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS std::vector user_services_; #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -228,7 +250,7 @@ class APIServer : public Component, public Controller { // 7 bytes used, 1 byte padding #ifdef USE_API_NOISE - std::shared_ptr noise_ctx_ = std::make_shared(); + APINoiseContext noise_ctx_; ESPPreferenceObject noise_pref_; #endif // USE_API_NOISE }; @@ -236,8 +258,11 @@ class APIServer : public Component, public Controller { extern APIServer *global_api_server; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) template class APIConnectedCondition : public Condition { + TEMPLATABLE_VALUE(bool, state_subscription_only) public: - bool check(const Ts &...x) override { return global_api_server->is_connected(); } + bool check(const Ts &...x) override { + return global_api_server->is_connected(this->state_subscription_only_.value(x...)); + } }; } // namespace esphome::api diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 43ea644f0c..1006d07533 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -3,12 +3,12 @@ #include #include "api_server.h" #ifdef USE_API -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS #include "user_services.h" #endif namespace esphome::api { -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS template class CustomAPIDeviceService : public UserServiceDynamic { public: CustomAPIDeviceService(const std::string &name, const std::array &arg_names, T *obj, @@ -21,7 +21,7 @@ template class CustomAPIDeviceService : public UserS T *obj_; void (T::*callback_)(Ts...); }; -#endif // USE_API_SERVICES +#endif // USE_API_USER_DEFINED_ACTIONS class CustomAPIDevice { public: @@ -49,7 +49,7 @@ class CustomAPIDevice { * @param name The name of the service to register. * @param arg_names The name of the arguments for the service, must match the arguments of the function. */ -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS template void register_service(void (T::*callback)(Ts...), const std::string &name, const std::array &arg_names) { @@ -90,7 +90,7 @@ class CustomAPIDevice { * @param callback The member function to call when the service is triggered. * @param name The name of the arguments for the service, must match the arguments of the function. */ -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS template void register_service(void (T::*callback)(), const std::string &name) { #ifdef USE_API_CUSTOM_SERVICES auto *service = new CustomAPIDeviceService(name, {}, (T *) this, callback); // NOLINT diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index da4800a45e..e18fc17801 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -82,7 +82,7 @@ bool ListEntitiesIterator::on_end() { return this->client_->send_list_info_done( ListEntitiesIterator::ListEntitiesIterator(APIConnection *client) : client_(client) {} -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS bool ListEntitiesIterator::on_service(UserServiceDescriptor *service) { auto resp = service->encode_list_service_response(); return this->client_->send_message(resp, ListEntitiesServicesResponse::MESSAGE_TYPE); diff --git a/esphome/components/api/list_entities.h b/esphome/components/api/list_entities.h index 769d7b9b6e..4c90dbbad8 100644 --- a/esphome/components/api/list_entities.h +++ b/esphome/components/api/list_entities.h @@ -43,7 +43,7 @@ class ListEntitiesIterator : public ComponentIterator { #ifdef USE_TEXT_SENSOR bool on_text_sensor(text_sensor::TextSensor *entity) override; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS bool on_service(UserServiceDescriptor *service) override; #endif #ifdef USE_CAMERA diff --git a/esphome/components/api/proto.h b/esphome/components/api/proto.h index e7585924a5..83b6922be1 100644 --- a/esphome/components/api/proto.h +++ b/esphome/components/api/proto.h @@ -846,7 +846,7 @@ class ProtoService { */ virtual ProtoWriteBuffer create_buffer(uint32_t reserve_size) = 0; virtual bool send_buffer(ProtoWriteBuffer buffer, uint8_t message_type) = 0; - virtual void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) = 0; + virtual void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) = 0; // Optimized method that pre-allocates buffer based on message size bool send_message_(const ProtoMessage &msg, uint8_t message_type) { diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index 2a887fc52d..d9c13c520b 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -7,7 +7,7 @@ #include "esphome/core/automation.h" #include "api_pb2.h" -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS namespace esphome::api { class UserServiceDescriptor { @@ -51,13 +51,14 @@ template class UserServiceBase : public UserServiceDescriptor { return false; if (req.args.size() != sizeof...(Ts)) return false; - this->execute_(req.args, typename gens::type()); + this->execute_(req.args, std::make_index_sequence{}); return true; } protected: virtual void execute(Ts... x) = 0; - template void execute_(const ArgsContainer &args, seq type) { + template + void execute_(const ArgsContainer &args, std::index_sequence type) { this->execute((get_execute_arg_value(args[S]))...); } @@ -95,13 +96,14 @@ template class UserServiceDynamic : public UserServiceDescriptor return false; if (req.args.size() != sizeof...(Ts)) return false; - this->execute_(req.args, typename gens::type()); + this->execute_(req.args, std::make_index_sequence{}); return true; } protected: virtual void execute(Ts... x) = 0; - template void execute_(const ArgsContainer &args, seq type) { + template + void execute_(const ArgsContainer &args, std::index_sequence type) { this->execute((get_execute_arg_value(args[S]))...); } @@ -122,4 +124,4 @@ template class UserServiceTrigger : public UserServiceBasewrite_register(SOFT_RESET_REG, &SOFT_RESET_PAYLOAD, 1); // Software Reset to check communication if (result_code != i2c::ERROR_OK) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } } diff --git a/esphome/components/binary_sensor/automation.cpp b/esphome/components/binary_sensor/automation.cpp index 64a0d3db8d..66d8d6e90f 100644 --- a/esphome/components/binary_sensor/automation.cpp +++ b/esphome/components/binary_sensor/automation.cpp @@ -1,12 +1,11 @@ #include "automation.h" #include "esphome/core/log.h" -namespace esphome { -namespace binary_sensor { +namespace esphome::binary_sensor { static const char *const TAG = "binary_sensor.automation"; -void binary_sensor::MultiClickTrigger::on_state_(bool state) { +void MultiClickTrigger::on_state_(bool state) { // Handle duplicate events if (state == this->last_state_) { return; @@ -67,7 +66,7 @@ void binary_sensor::MultiClickTrigger::on_state_(bool state) { *this->at_index_ = *this->at_index_ + 1; } -void binary_sensor::MultiClickTrigger::schedule_cooldown_() { +void MultiClickTrigger::schedule_cooldown_() { ESP_LOGV(TAG, "Multi Click: Invalid length of press, starting cooldown of %" PRIu32 " ms", this->invalid_cooldown_); this->is_in_cooldown_ = true; this->set_timeout("cooldown", this->invalid_cooldown_, [this]() { @@ -79,7 +78,7 @@ void binary_sensor::MultiClickTrigger::schedule_cooldown_() { this->cancel_timeout("is_valid"); this->cancel_timeout("is_not_valid"); } -void binary_sensor::MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { +void MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { if (min_length == 0) { this->is_valid_ = true; return; @@ -90,19 +89,19 @@ void binary_sensor::MultiClickTrigger::schedule_is_valid_(uint32_t min_length) { this->is_valid_ = true; }); } -void binary_sensor::MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { +void MultiClickTrigger::schedule_is_not_valid_(uint32_t max_length) { this->set_timeout("is_not_valid", max_length, [this]() { ESP_LOGV(TAG, "Multi Click: You waited too long to %s.", this->parent_->state ? "RELEASE" : "PRESS"); this->is_valid_ = false; this->schedule_cooldown_(); }); } -void binary_sensor::MultiClickTrigger::cancel() { +void MultiClickTrigger::cancel() { ESP_LOGV(TAG, "Multi Click: Sequence explicitly cancelled."); this->is_valid_ = false; this->schedule_cooldown_(); } -void binary_sensor::MultiClickTrigger::trigger_() { +void MultiClickTrigger::trigger_() { ESP_LOGV(TAG, "Multi Click: Hooray, multi click is valid. Triggering!"); this->at_index_.reset(); this->cancel_timeout("trigger"); @@ -118,5 +117,4 @@ bool match_interval(uint32_t min_length, uint32_t max_length, uint32_t length) { return length >= min_length && length <= max_length; } } -} // namespace binary_sensor -} // namespace esphome +} // namespace esphome::binary_sensor diff --git a/esphome/components/binary_sensor/automation.h b/esphome/components/binary_sensor/automation.h index f6971a2fc4..f8b130e08a 100644 --- a/esphome/components/binary_sensor/automation.h +++ b/esphome/components/binary_sensor/automation.h @@ -9,8 +9,7 @@ #include "esphome/core/helpers.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace binary_sensor { +namespace esphome::binary_sensor { struct MultiClickTriggerEvent { bool state; @@ -172,5 +171,4 @@ template class BinarySensorInvalidateAction : public Action filters) { } bool BinarySensor::is_status_binary_sensor() const { return false; } -} // namespace binary_sensor - -} // namespace esphome +} // namespace esphome::binary_sensor diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index c1661d710f..0dca3e1520 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -6,9 +6,7 @@ #include -namespace esphome { - -namespace binary_sensor { +namespace esphome::binary_sensor { class BinarySensor; void log_binary_sensor(const char *tag, const char *prefix, const char *type, BinarySensor *obj); @@ -70,5 +68,4 @@ class BinarySensorInitiallyOff : public BinarySensor { bool has_state() const override { return true; } }; -} // namespace binary_sensor -} // namespace esphome +} // namespace esphome::binary_sensor diff --git a/esphome/components/binary_sensor/filter.cpp b/esphome/components/binary_sensor/filter.cpp index 8f31cf6fc2..9c7238f6d7 100644 --- a/esphome/components/binary_sensor/filter.cpp +++ b/esphome/components/binary_sensor/filter.cpp @@ -2,9 +2,7 @@ #include "binary_sensor.h" -namespace esphome { - -namespace binary_sensor { +namespace esphome::binary_sensor { static const char *const TAG = "sensor.filter"; @@ -132,6 +130,4 @@ optional SettleFilter::new_value(bool value) { float SettleFilter::get_setup_priority() const { return setup_priority::HARDWARE; } -} // namespace binary_sensor - -} // namespace esphome +} // namespace esphome::binary_sensor diff --git a/esphome/components/binary_sensor/filter.h b/esphome/components/binary_sensor/filter.h index 2d473c3b64..59bc43eeba 100644 --- a/esphome/components/binary_sensor/filter.h +++ b/esphome/components/binary_sensor/filter.h @@ -4,9 +4,7 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" -namespace esphome { - -namespace binary_sensor { +namespace esphome::binary_sensor { class BinarySensor; @@ -139,6 +137,4 @@ class SettleFilter : public Filter, public Component { bool steady_{true}; }; -} // namespace binary_sensor - -} // namespace esphome +} // namespace esphome::binary_sensor diff --git a/esphome/components/ble_client/automation.cpp b/esphome/components/ble_client/automation.cpp index 9a0233eb70..cd2802f617 100644 --- a/esphome/components/ble_client/automation.cpp +++ b/esphome/components/ble_client/automation.cpp @@ -2,12 +2,10 @@ #include "automation.h" -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { const char *const Automation::TAG = "ble_client.automation"; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/automation.h b/esphome/components/ble_client/automation.h index 9c5646b3d1..ccda894509 100644 --- a/esphome/components/ble_client/automation.h +++ b/esphome/components/ble_client/automation.h @@ -9,8 +9,7 @@ #include "esphome/components/ble_client/ble_client.h" #include "esphome/core/log.h" -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { // placeholder class for static TAG . class Automation { @@ -122,16 +121,19 @@ template class BLEClientWriteAction : public Action, publ void play_complex(const Ts &...x) override { this->num_running_++; this->var_ = std::make_tuple(x...); - std::vector value; + + bool result; if (this->len_ >= 0) { - // Static mode: copy from flash to vector - value.assign(this->value_.data, this->value_.data + this->len_); + // Static mode: write directly from flash pointer + result = this->write(this->value_.data, this->len_); } else { - // Template mode: call function - value = this->value_.func(x...); + // Template mode: call function and write the vector + std::vector value = this->value_.func(x...); + result = this->write(value); } + // on write failure, continue the automation chain rather than stopping so that e.g. disconnect can work. - if (!write(value)) + if (!result) this->play_next_(x...); } @@ -144,15 +146,15 @@ template class BLEClientWriteAction : public Action, publ * errors. */ // initiate the write. Return true if all went well, will be followed by a WRITE_CHAR event. - bool write(const std::vector &value) { + bool write(const uint8_t *data, size_t len) { if (this->node_state != espbt::ClientState::ESTABLISHED) { esph_log_w(Automation::TAG, "Cannot write to BLE characteristic - not connected"); return false; } - esph_log_vv(Automation::TAG, "Will write %d bytes: %s", value.size(), format_hex_pretty(value).c_str()); - esp_err_t err = esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), - this->char_handle_, value.size(), const_cast(value.data()), - this->write_type_, ESP_GATT_AUTH_REQ_NONE); + esph_log_vv(Automation::TAG, "Will write %d bytes: %s", len, format_hex_pretty(data, len).c_str()); + esp_err_t err = + esp_ble_gattc_write_char(this->parent()->get_gattc_if(), this->parent()->get_conn_id(), this->char_handle_, len, + const_cast(data), this->write_type_, ESP_GATT_AUTH_REQ_NONE); if (err != ESP_OK) { esph_log_e(Automation::TAG, "Error writing to characteristic: %s!", esp_err_to_name(err)); return false; @@ -160,6 +162,8 @@ template class BLEClientWriteAction : public Action, publ return true; } + bool write(const std::vector &value) { return this->write(value.data(), value.size()); } + void gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t gattc_if, esp_ble_gattc_cb_param_t *param) override { switch (event) { @@ -193,7 +197,7 @@ template class BLEClientWriteAction : public Action, publ } this->node_state = espbt::ClientState::ESTABLISHED; esph_log_d(Automation::TAG, "Found characteristic %s on device %s", this->char_uuid_.to_string().c_str(), - ble_client_->address_str().c_str()); + ble_client_->address_str()); break; } default: @@ -386,7 +390,6 @@ template class BLEClientDisconnectAction : public Action, BLEClient *ble_client_; std::tuple var_{}; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/ble_client.cpp b/esphome/components/ble_client/ble_client.cpp index 5cf096c9d4..d41fb17961 100644 --- a/esphome/components/ble_client/ble_client.cpp +++ b/esphome/components/ble_client/ble_client.cpp @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_client"; @@ -39,7 +38,7 @@ void BLEClient::set_enabled(bool enabled) { return; this->enabled = enabled; if (!enabled) { - ESP_LOGI(TAG, "[%s] Disabling BLE client.", this->address_str().c_str()); + ESP_LOGI(TAG, "[%s] Disabling BLE client.", this->address_str()); this->disconnect(); } } @@ -82,7 +81,6 @@ bool BLEClient::all_nodes_established_() { return true; } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/ble_client.h b/esphome/components/ble_client/ble_client.h index e04f4a8042..ca523251ef 100644 --- a/esphome/components/ble_client/ble_client.h +++ b/esphome/components/ble_client/ble_client.h @@ -15,8 +15,7 @@ #include #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -75,7 +74,6 @@ class BLEClient : public BLEClientBase { std::vector nodes_; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/output/ble_binary_output.cpp b/esphome/components/ble_client/output/ble_binary_output.cpp index ce67193be7..1d874a65e4 100644 --- a/esphome/components/ble_client/output/ble_binary_output.cpp +++ b/esphome/components/ble_client/output/ble_binary_output.cpp @@ -3,8 +3,7 @@ #include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_binary_output"; @@ -14,7 +13,7 @@ void BLEBinaryOutput::dump_config() { " MAC address : %s\n" " Service UUID : %s\n" " Characteristic UUID: %s", - this->parent_->address_str().c_str(), this->service_uuid_.to_string().c_str(), + this->parent_->address_str(), this->service_uuid_.to_string().c_str(), this->char_uuid_.to_string().c_str()); LOG_BINARY_OUTPUT(this); } @@ -44,7 +43,7 @@ void BLEBinaryOutput::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i } this->node_state = espbt::ClientState::ESTABLISHED; ESP_LOGD(TAG, "Found characteristic %s on device %s", this->char_uuid_.to_string().c_str(), - this->parent()->address_str().c_str()); + this->parent()->address_str()); this->node_state = espbt::ClientState::ESTABLISHED; break; } @@ -75,6 +74,5 @@ void BLEBinaryOutput::write_state(bool state) { ESP_LOGW(TAG, "[%s] Write error, err=%d", this->char_uuid_.to_string().c_str(), err); } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/output/ble_binary_output.h b/esphome/components/ble_client/output/ble_binary_output.h index 5e8bd6da62..299de9b860 100644 --- a/esphome/components/ble_client/output/ble_binary_output.h +++ b/esphome/components/ble_client/output/ble_binary_output.h @@ -7,8 +7,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -36,7 +35,6 @@ class BLEBinaryOutput : public output::BinaryOutput, public BLEClientNode, publi esp_gatt_write_type_t write_type_{}; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/sensor/automation.h b/esphome/components/ble_client/sensor/automation.h index 56ab7ba4c9..84430cb7d9 100644 --- a/esphome/components/ble_client/sensor/automation.h +++ b/esphome/components/ble_client/sensor/automation.h @@ -5,8 +5,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { class BLESensorNotifyTrigger : public Trigger, public BLESensor { public: @@ -35,7 +34,6 @@ class BLESensorNotifyTrigger : public Trigger, public BLESensor { BLESensor *sensor_; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp index 663c52ac10..dc032a7a98 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.cpp @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_rssi_sensor"; @@ -19,7 +18,7 @@ void BLEClientRSSISensor::loop() { void BLEClientRSSISensor::dump_config() { LOG_SENSOR("", "BLE Client RSSI Sensor", this); - ESP_LOGCONFIG(TAG, " MAC address : %s", this->parent()->address_str().c_str()); + ESP_LOGCONFIG(TAG, " MAC address : %s", this->parent()->address_str()); LOG_UPDATE_INTERVAL(this); } @@ -69,15 +68,14 @@ void BLEClientRSSISensor::update() { this->get_rssi_(); } void BLEClientRSSISensor::get_rssi_() { - ESP_LOGV(TAG, "requesting rssi from %s", this->parent()->address_str().c_str()); + ESP_LOGV(TAG, "requesting rssi from %s", this->parent()->address_str()); auto status = esp_ble_gap_read_rssi(this->parent()->get_remote_bda()); if (status != ESP_OK) { - ESP_LOGW(TAG, "esp_ble_gap_read_rssi error, address=%s, status=%d", this->parent()->address_str().c_str(), status); + ESP_LOGW(TAG, "esp_ble_gap_read_rssi error, address=%s, status=%d", this->parent()->address_str(), status); this->status_set_warning(); this->publish_state(NAN); } } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/sensor/ble_rssi_sensor.h b/esphome/components/ble_client/sensor/ble_rssi_sensor.h index 76cd8345a6..570a5b423c 100644 --- a/esphome/components/ble_client/sensor/ble_rssi_sensor.h +++ b/esphome/components/ble_client/sensor/ble_rssi_sensor.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -29,6 +28,5 @@ class BLEClientRSSISensor : public sensor::Sensor, public PollingComponent, publ bool should_update_{false}; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/sensor/ble_sensor.cpp b/esphome/components/ble_client/sensor/ble_sensor.cpp index 61685c0566..38d90faff0 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.cpp +++ b/esphome/components/ble_client/sensor/ble_sensor.cpp @@ -6,8 +6,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_sensor"; @@ -25,7 +24,7 @@ void BLESensor::dump_config() { " Characteristic UUID: %s\n" " Descriptor UUID : %s\n" " Notifications : %s", - this->parent()->address_str().c_str(), this->service_uuid_.to_string().c_str(), + this->parent()->address_str(), this->service_uuid_.to_string().c_str(), this->char_uuid_.to_string().c_str(), this->descr_uuid_.to_string().c_str(), YESNO(this->notify_)); LOG_UPDATE_INTERVAL(this); } @@ -147,6 +146,5 @@ void BLESensor::update() { } } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/sensor/ble_sensor.h b/esphome/components/ble_client/sensor/ble_sensor.h index c6335d5836..fe5b5ecd53 100644 --- a/esphome/components/ble_client/sensor/ble_sensor.h +++ b/esphome/components/ble_client/sensor/ble_sensor.h @@ -10,8 +10,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -48,6 +47,5 @@ class BLESensor : public sensor::Sensor, public PollingComponent, public BLEClie espbt::ESPBTUUID descr_uuid_; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/switch/ble_switch.cpp b/esphome/components/ble_client/switch/ble_switch.cpp index 9d92b1b2b5..5baca2adcf 100644 --- a/esphome/components/ble_client/switch/ble_switch.cpp +++ b/esphome/components/ble_client/switch/ble_switch.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_switch"; @@ -31,6 +30,5 @@ void BLEClientSwitch::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void BLEClientSwitch::dump_config() { LOG_SWITCH("", "BLE Client Switch", this); } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/switch/ble_switch.h b/esphome/components/ble_client/switch/ble_switch.h index 9809f904e7..9be6d06b1c 100644 --- a/esphome/components/ble_client/switch/ble_switch.h +++ b/esphome/components/ble_client/switch/ble_switch.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -24,6 +23,5 @@ class BLEClientSwitch : public switch_::Switch, public Component, public BLEClie void write_state(bool state) override; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/text_sensor/automation.h b/esphome/components/ble_client/text_sensor/automation.h index c504c35a58..f7b077926b 100644 --- a/esphome/components/ble_client/text_sensor/automation.h +++ b/esphome/components/ble_client/text_sensor/automation.h @@ -5,8 +5,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { class BLETextSensorNotifyTrigger : public Trigger, public BLETextSensor { public: @@ -33,7 +32,6 @@ class BLETextSensorNotifyTrigger : public Trigger, public BLETextSe BLETextSensor *sensor_; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp index b7a6d154db..415981a1ba 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.cpp @@ -7,8 +7,7 @@ #ifdef USE_ESP32 -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { static const char *const TAG = "ble_text_sensor"; @@ -28,7 +27,7 @@ void BLETextSensor::dump_config() { " Characteristic UUID: %s\n" " Descriptor UUID : %s\n" " Notifications : %s", - this->parent()->address_str().c_str(), this->service_uuid_.to_string().c_str(), + this->parent()->address_str(), this->service_uuid_.to_string().c_str(), this->char_uuid_.to_string().c_str(), this->descr_uuid_.to_string().c_str(), YESNO(this->notify_)); LOG_UPDATE_INTERVAL(this); } @@ -138,6 +137,5 @@ void BLETextSensor::update() { } } -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_client/text_sensor/ble_text_sensor.h b/esphome/components/ble_client/text_sensor/ble_text_sensor.h index c75a4df952..3fbd64389c 100644 --- a/esphome/components/ble_client/text_sensor/ble_text_sensor.h +++ b/esphome/components/ble_client/text_sensor/ble_text_sensor.h @@ -8,8 +8,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace ble_client { +namespace esphome::ble_client { namespace espbt = esphome::esp32_ble_tracker; @@ -40,6 +39,5 @@ class BLETextSensor : public text_sensor::TextSensor, public PollingComponent, p espbt::ESPBTUUID descr_uuid_; }; -} // namespace ble_client -} // namespace esphome +} // namespace esphome::ble_client #endif diff --git a/esphome/components/ble_nus/ble_nus.cpp b/esphome/components/ble_nus/ble_nus.cpp index 9c4d0a3938..bd80592d89 100644 --- a/esphome/components/ble_nus/ble_nus.cpp +++ b/esphome/components/ble_nus/ble_nus.cpp @@ -87,17 +87,21 @@ void BLENUS::setup() { global_ble_nus = this; #ifdef USE_LOGGER if (logger::global_logger != nullptr && this->expose_log_) { - logger::global_logger->add_on_log_callback( - [this](int level, const char *tag, const char *message, size_t message_len) { - this->write_array(reinterpret_cast(message), message_len); - const char c = '\n'; - this->write_array(reinterpret_cast(&c), 1); - }); + logger::global_logger->add_log_listener(this); } - #endif } +#ifdef USE_LOGGER +void BLENUS::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { + (void) level; + (void) tag; + this->write_array(reinterpret_cast(message), message_len); + const char c = '\n'; + this->write_array(reinterpret_cast(&c), 1); +} +#endif + void BLENUS::dump_config() { ESP_LOGCONFIG(TAG, "ble nus:"); ESP_LOGCONFIG(TAG, " log: %s", YESNO(this->expose_log_)); diff --git a/esphome/components/ble_nus/ble_nus.h b/esphome/components/ble_nus/ble_nus.h index e8cba32b4c..ef20fc5e5b 100644 --- a/esphome/components/ble_nus/ble_nus.h +++ b/esphome/components/ble_nus/ble_nus.h @@ -2,12 +2,20 @@ #ifdef USE_ZEPHYR #include "esphome/core/defines.h" #include "esphome/core/component.h" +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif #include #include namespace esphome::ble_nus { -class BLENUS : public Component { +class BLENUS : public Component +#ifdef USE_LOGGER + , + public logger::LogListener +#endif +{ enum TxStatus { TX_DISABLED, TX_ENABLED, @@ -20,6 +28,9 @@ class BLENUS : public Component { void loop() override; size_t write_array(const uint8_t *data, size_t len); void set_expose_log(bool expose_log) { this->expose_log_ = expose_log; } +#ifdef USE_LOGGER + void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override; +#endif protected: static void send_enabled_callback(bt_nus_send_status status); diff --git a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp index fcc344dda9..1d6f7e23b3 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_connection.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_connection.cpp @@ -196,8 +196,8 @@ void BluetoothConnection::send_service_for_discovery_() { if (service_status != ESP_GATT_OK || service_count == 0) { ESP_LOGE(TAG, "[%d] [%s] esp_ble_gattc_get_service %s, status=%d, service_count=%d, offset=%d", - this->connection_index_, this->address_str().c_str(), - service_status != ESP_GATT_OK ? "error" : "missing", service_status, service_count, this->send_service_); + this->connection_index_, this->address_str(), service_status != ESP_GATT_OK ? "error" : "missing", + service_status, service_count, this->send_service_); this->send_service_ = DONE_SENDING_SERVICES; return; } @@ -312,13 +312,13 @@ void BluetoothConnection::send_service_for_discovery_() { if (resp.services.size() > 1) { resp.services.pop_back(); ESP_LOGD(TAG, "[%d] [%s] Service %d would exceed limit (current: %d + service: %d > %d), sending current batch", - this->connection_index_, this->address_str().c_str(), this->send_service_, current_size, service_size, + this->connection_index_, this->address_str(), this->send_service_, current_size, service_size, MAX_PACKET_SIZE); // Don't increment send_service_ - we'll retry this service in next batch } else { // This single service is too large, but we have to send it anyway ESP_LOGV(TAG, "[%d] [%s] Service %d is too large (%d bytes) but sending anyway", this->connection_index_, - this->address_str().c_str(), this->send_service_, service_size); + this->address_str(), this->send_service_, service_size); // Increment so we don't get stuck this->send_service_++; } @@ -337,21 +337,20 @@ void BluetoothConnection::send_service_for_discovery_() { } void BluetoothConnection::log_connection_error_(const char *operation, esp_gatt_status_t status) { - ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str().c_str(), operation, - status); + ESP_LOGE(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str(), operation, status); } void BluetoothConnection::log_connection_warning_(const char *operation, esp_err_t err) { - ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str().c_str(), operation, err); + ESP_LOGW(TAG, "[%d] [%s] %s failed, err=%d", this->connection_index_, this->address_str(), operation, err); } void BluetoothConnection::log_gatt_not_connected_(const char *action, const char *type) { - ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str().c_str(), - action, type); + ESP_LOGW(TAG, "[%d] [%s] Cannot %s GATT %s, not connected.", this->connection_index_, this->address_str(), action, + type); } void BluetoothConnection::log_gatt_operation_error_(const char *operation, uint16_t handle, esp_gatt_status_t status) { - ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str().c_str(), + ESP_LOGW(TAG, "[%d] [%s] Error %s for handle 0x%2X, status=%d", this->connection_index_, this->address_str(), operation, handle, status); } @@ -372,14 +371,14 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga case ESP_GATTC_DISCONNECT_EVT: { // Don't reset connection yet - wait for CLOSE_EVT to ensure controller has freed resources // This prevents race condition where we mark slot as free before controller cleanup is complete - ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_.c_str(), + ESP_LOGD(TAG, "[%d] [%s] Disconnect, reason=0x%02x", this->connection_index_, this->address_str_, param->disconnect.reason); // Send disconnection notification but don't free the slot yet this->proxy_->send_device_connection(this->address_, false, 0, param->disconnect.reason); break; } case ESP_GATTC_CLOSE_EVT: { - ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_.c_str(), + ESP_LOGD(TAG, "[%d] [%s] Close, reason=0x%02x, freeing slot", this->connection_index_, this->address_str_, param->close.reason); // Now the GATT connection is fully closed and controller resources are freed // Safe to mark the connection slot as available @@ -463,7 +462,7 @@ bool BluetoothConnection::gattc_event_handler(esp_gattc_cb_event_t event, esp_ga break; } case ESP_GATTC_NOTIFY_EVT: { - ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_.c_str(), + ESP_LOGV(TAG, "[%d] [%s] ESP_GATTC_NOTIFY_EVT: handle=0x%2X", this->connection_index_, this->address_str_, param->notify.handle); api::BluetoothGATTNotifyDataResponse resp; resp.address = this->address_; @@ -502,8 +501,7 @@ esp_err_t BluetoothConnection::read_characteristic(uint16_t handle) { return ESP_GATT_NOT_CONNECTED; } - ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_.c_str(), - handle); + ESP_LOGV(TAG, "[%d] [%s] Reading GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); esp_err_t err = esp_ble_gattc_read_char(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_read_char", err); @@ -515,8 +513,7 @@ esp_err_t BluetoothConnection::write_characteristic(uint16_t handle, const uint8 this->log_gatt_not_connected_("write", "characteristic"); return ESP_GATT_NOT_CONNECTED; } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_.c_str(), - handle); + ESP_LOGV(TAG, "[%d] [%s] Writing GATT characteristic handle %d", this->connection_index_, this->address_str_, handle); // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) @@ -532,8 +529,7 @@ esp_err_t BluetoothConnection::read_descriptor(uint16_t handle) { this->log_gatt_not_connected_("read", "descriptor"); return ESP_GATT_NOT_CONNECTED; } - ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), - handle); + ESP_LOGV(TAG, "[%d] [%s] Reading GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); esp_err_t err = esp_ble_gattc_read_char_descr(this->gattc_if_, this->conn_id_, handle, ESP_GATT_AUTH_REQ_NONE); return this->check_and_log_error_("esp_ble_gattc_read_char_descr", err); @@ -544,8 +540,7 @@ esp_err_t BluetoothConnection::write_descriptor(uint16_t handle, const uint8_t * this->log_gatt_not_connected_("write", "descriptor"); return ESP_GATT_NOT_CONNECTED; } - ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_.c_str(), - handle); + ESP_LOGV(TAG, "[%d] [%s] Writing GATT descriptor handle %d", this->connection_index_, this->address_str_, handle); // ESP-IDF's API requires a non-const uint8_t* but it doesn't modify the data // The BTC layer immediately copies the data to its own buffer (see btc_gattc.c) @@ -564,13 +559,13 @@ esp_err_t BluetoothConnection::notify_characteristic(uint16_t handle, bool enabl if (enable) { ESP_LOGV(TAG, "[%d] [%s] Registering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_.c_str(), handle); + this->address_str_, handle); esp_err_t err = esp_ble_gattc_register_for_notify(this->gattc_if_, this->remote_bda_, handle); return this->check_and_log_error_("esp_ble_gattc_register_for_notify", err); } ESP_LOGV(TAG, "[%d] [%s] Unregistering for GATT characteristic notifications handle %d", this->connection_index_, - this->address_str_.c_str(), handle); + this->address_str_, handle); esp_err_t err = esp_ble_gattc_unregister_for_notify(this->gattc_if_, this->remote_bda_, handle); return this->check_and_log_error_("esp_ble_gattc_unregister_for_notify", err); } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp index 34e0aa93a3..d45377b3f6 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.cpp @@ -27,11 +27,13 @@ void BluetoothProxy::setup() { // Capture the configured scan mode from YAML before any API changes this->configured_scan_active_ = this->parent_->get_scan_active(); - this->parent_->add_scanner_state_callback([this](esp32_ble_tracker::ScannerState state) { - if (this->api_connection_ != nullptr) { - this->send_bluetooth_scanner_state_(state); - } - }); + this->parent_->add_scanner_state_listener(this); +} + +void BluetoothProxy::on_scanner_state(esp32_ble_tracker::ScannerState state) { + if (this->api_connection_ != nullptr) { + this->send_bluetooth_scanner_state_(state); + } } void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerState state) { @@ -47,12 +49,11 @@ void BluetoothProxy::send_bluetooth_scanner_state_(esp32_ble_tracker::ScannerSta void BluetoothProxy::log_connection_request_ignored_(BluetoothConnection *connection, espbt::ClientState state) { ESP_LOGW(TAG, "[%d] [%s] Connection request ignored, state: %s", connection->get_connection_index(), - connection->address_str().c_str(), espbt::client_state_to_string(state)); + connection->address_str(), espbt::client_state_to_string(state)); } void BluetoothProxy::log_connection_info_(BluetoothConnection *connection, const char *message) { - ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str().c_str(), - message); + ESP_LOGI(TAG, "[%d] [%s] Connecting %s", connection->get_connection_index(), connection->address_str(), message); } void BluetoothProxy::log_not_connected_gatt_(const char *action, const char *type) { @@ -186,7 +187,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } if (!msg.has_address_type) { ESP_LOGE(TAG, "[%d] [%s] Missing address type in connect request", connection->get_connection_index(), - connection->address_str().c_str()); + connection->address_str()); this->send_device_connection(msg.address, false); return; } @@ -199,7 +200,7 @@ void BluetoothProxy::bluetooth_device_request(const api::BluetoothDeviceRequest } else if (connection->state() == espbt::ClientState::CONNECTING) { if (connection->disconnect_pending()) { ESP_LOGW(TAG, "[%d] [%s] Connection request while pending disconnect, cancelling pending disconnect", - connection->get_connection_index(), connection->address_str().c_str()); + connection->get_connection_index(), connection->address_str()); connection->cancel_pending_disconnect(); return; } @@ -339,7 +340,7 @@ void BluetoothProxy::bluetooth_gatt_send_services(const api::BluetoothGATTGetSer return; } if (!connection->service_count_) { - ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->connection_index_, connection->address_str().c_str()); + ESP_LOGW(TAG, "[%d] [%s] No GATT services found", connection->connection_index_, connection->address_str()); this->send_gatt_services_done(msg.address); return; } diff --git a/esphome/components/bluetooth_proxy/bluetooth_proxy.h b/esphome/components/bluetooth_proxy/bluetooth_proxy.h index a5f0fbe32f..ab9aee2d81 100644 --- a/esphome/components/bluetooth_proxy/bluetooth_proxy.h +++ b/esphome/components/bluetooth_proxy/bluetooth_proxy.h @@ -52,7 +52,9 @@ enum BluetoothProxySubscriptionFlag : uint32_t { SUBSCRIPTION_RAW_ADVERTISEMENTS = 1 << 0, }; -class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, public Component { +class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, + public esp32_ble_tracker::BLEScannerStateListener, + public Component { friend class BluetoothConnection; // Allow connection to update connections_free_response_ public: BluetoothProxy(); @@ -108,6 +110,9 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ void set_active(bool active) { this->active_ = active; } bool has_active() { return this->active_; } + /// BLEScannerStateListener interface + void on_scanner_state(esp32_ble_tracker::ScannerState state) override; + uint32_t get_legacy_version() const { if (this->active_) { return LEGACY_ACTIVE_CONNECTIONS_VERSION; @@ -130,11 +135,13 @@ class BluetoothProxy final : public esp32_ble_tracker::ESPBTDeviceListener, publ return flags; } - std::string get_bluetooth_mac_address_pretty() { + void get_bluetooth_mac_address_pretty(std::span output) { const uint8_t *mac = esp_bt_dev_get_address(); - char buf[18]; - format_mac_addr_upper(mac, buf); - return std::string(buf); + if (mac != nullptr) { + format_mac_addr_upper(mac, output.data()); + } else { + output[0] = '\0'; + } } protected: diff --git a/esphome/components/bm8563/__init__.py b/esphome/components/bm8563/__init__.py new file mode 100644 index 0000000000..20254a8b00 --- /dev/null +++ b/esphome/components/bm8563/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@abmantis"] diff --git a/esphome/components/bm8563/bm8563.cpp b/esphome/components/bm8563/bm8563.cpp new file mode 100644 index 0000000000..07831485c1 --- /dev/null +++ b/esphome/components/bm8563/bm8563.cpp @@ -0,0 +1,198 @@ +#include "bm8563.h" +#include "esphome/core/log.h" + +namespace esphome::bm8563 { + +static const char *const TAG = "bm8563"; + +static constexpr uint8_t CONTROL_STATUS_1_REG = 0x00; +static constexpr uint8_t CONTROL_STATUS_2_REG = 0x01; +static constexpr uint8_t TIME_FIRST_REG = 0x02; // Time uses reg 2, 3, 4 +static constexpr uint8_t DATE_FIRST_REG = 0x05; // Date uses reg 5, 6, 7, 8 +static constexpr uint8_t TIMER_CONTROL_REG = 0x0E; +static constexpr uint8_t TIMER_VALUE_REG = 0x0F; +static constexpr uint8_t CLOCK_1_HZ = 0x82; +static constexpr uint8_t CLOCK_1_60_HZ = 0x83; +// Maximum duration: 255 minutes (at 1/60 Hz) = 15300 seconds +static constexpr uint32_t MAX_TIMER_DURATION_S = 255 * 60; + +void BM8563::setup() { + if (!this->write_byte_16(CONTROL_STATUS_1_REG, 0)) { + this->mark_failed(); + return; + } +} + +void BM8563::update() { this->read_time(); } + +void BM8563::dump_config() { + ESP_LOGCONFIG(TAG, "BM8563:"); + LOG_I2C_DEVICE(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +void BM8563::start_timer(uint32_t duration_s) { + this->clear_irq_(); + this->set_timer_irq_(duration_s); +} + +void BM8563::write_time() { + auto now = time::RealTimeClock::utcnow(); + if (!now.is_valid()) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC."); + return; + } + + ESP_LOGD(TAG, "Writing time: %i-%i-%i %i, %i:%i:%i", now.year, now.month, now.day_of_month, now.day_of_week, now.hour, + now.minute, now.second); + + this->set_time_(now); + this->set_date_(now); +} + +void BM8563::read_time() { + ESPTime rtc_time; + this->get_time_(rtc_time); + this->get_date_(rtc_time); + rtc_time.day_of_year = 1; // unused by recalc_timestamp_utc, but needs to be valid + ESP_LOGD(TAG, "Read time: %i-%i-%i %i, %i:%i:%i", rtc_time.year, rtc_time.month, rtc_time.day_of_month, + rtc_time.day_of_week, rtc_time.hour, rtc_time.minute, rtc_time.second); + + rtc_time.recalc_timestamp_utc(false); + if (!rtc_time.is_valid()) { + ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); + return; + } + time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); +} + +uint8_t BM8563::bcd2_to_byte_(uint8_t value) { + const uint8_t tmp = ((value & 0xF0) >> 0x4) * 10; + return tmp + (value & 0x0F); +} + +uint8_t BM8563::byte_to_bcd2_(uint8_t value) { + const uint8_t bcdhigh = value / 10; + value -= bcdhigh * 10; + return (bcdhigh << 4) | value; +} + +void BM8563::get_time_(ESPTime &time) { + uint8_t buf[3] = {0}; + this->read_register(TIME_FIRST_REG, buf, 3); + + time.second = this->bcd2_to_byte_(buf[0] & 0x7f); + time.minute = this->bcd2_to_byte_(buf[1] & 0x7f); + time.hour = this->bcd2_to_byte_(buf[2] & 0x3f); +} + +void BM8563::set_time_(const ESPTime &time) { + uint8_t buf[3] = {this->byte_to_bcd2_(time.second), this->byte_to_bcd2_(time.minute), this->byte_to_bcd2_(time.hour)}; + this->write_register_(TIME_FIRST_REG, buf, 3); +} + +void BM8563::get_date_(ESPTime &time) { + uint8_t buf[4] = {0}; + this->read_register(DATE_FIRST_REG, buf, sizeof(buf)); + + time.day_of_month = this->bcd2_to_byte_(buf[0] & 0x3f); + time.day_of_week = this->bcd2_to_byte_(buf[1] & 0x07); + time.month = this->bcd2_to_byte_(buf[2] & 0x1f); + + uint8_t year_byte = this->bcd2_to_byte_(buf[3] & 0xff); + + if (buf[2] & 0x80) { + time.year = 1900 + year_byte; + } else { + time.year = 2000 + year_byte; + } +} + +void BM8563::set_date_(const ESPTime &time) { + uint8_t buf[4] = { + this->byte_to_bcd2_(time.day_of_month), + this->byte_to_bcd2_(time.day_of_week), + this->byte_to_bcd2_(time.month), + this->byte_to_bcd2_(time.year % 100), + }; + + if (time.year < 2000) { + buf[2] = buf[2] | 0x80; + } + + this->write_register_(DATE_FIRST_REG, buf, 4); +} + +void BM8563::write_byte_(uint8_t reg, uint8_t value) { + if (!this->write_byte(reg, value)) { + ESP_LOGE(TAG, "Failed to write byte 0x%02X with value 0x%02X", reg, value); + } +} + +void BM8563::write_register_(uint8_t reg, const uint8_t *data, size_t len) { + if (auto error = this->write_register(reg, data, len); error != i2c::ErrorCode::NO_ERROR) { + ESP_LOGE(TAG, "Failed to write register 0x%02X with %zu bytes", reg, len); + } +} + +optional BM8563::read_register_(uint8_t reg) { + uint8_t data; + if (auto error = this->read_register(reg, &data, 1); error != i2c::ErrorCode::NO_ERROR) { + ESP_LOGE(TAG, "Failed to read register 0x%02X", reg); + return {}; + } + return data; +} + +void BM8563::set_timer_irq_(uint32_t duration_s) { + ESP_LOGI(TAG, "Timer Duration: %u s", duration_s); + + if (duration_s > MAX_TIMER_DURATION_S) { + ESP_LOGW(TAG, "Timer duration %u s exceeds maximum %u s", duration_s, MAX_TIMER_DURATION_S); + return; + } + + if (duration_s > 255) { + uint8_t duration_minutes = duration_s / 60; + this->write_byte_(TIMER_VALUE_REG, duration_minutes); + this->write_byte_(TIMER_CONTROL_REG, CLOCK_1_60_HZ); + } else { + this->write_byte_(TIMER_VALUE_REG, duration_s); + this->write_byte_(TIMER_CONTROL_REG, CLOCK_1_HZ); + } + + auto maybe_ctrl_status_2 = this->read_register_(CONTROL_STATUS_2_REG); + if (!maybe_ctrl_status_2.has_value()) { + ESP_LOGE(TAG, "Failed to read CONTROL_STATUS_2_REG"); + return; + } + uint8_t ctrl_status_2_reg_value = maybe_ctrl_status_2.value(); + ctrl_status_2_reg_value |= (1 << 0); + ctrl_status_2_reg_value &= ~(1 << 7); + this->write_byte_(CONTROL_STATUS_2_REG, ctrl_status_2_reg_value); +} + +void BM8563::clear_irq_() { + auto maybe_data = this->read_register_(CONTROL_STATUS_2_REG); + if (!maybe_data.has_value()) { + ESP_LOGE(TAG, "Failed to read CONTROL_STATUS_2_REG"); + return; + } + uint8_t data = maybe_data.value(); + this->write_byte_(CONTROL_STATUS_2_REG, data & 0xf3); +} + +void BM8563::disable_irq_() { + this->clear_irq_(); + auto maybe_data = this->read_register_(CONTROL_STATUS_2_REG); + if (!maybe_data.has_value()) { + ESP_LOGE(TAG, "Failed to read CONTROL_STATUS_2_REG"); + return; + } + uint8_t data = maybe_data.value(); + this->write_byte_(CONTROL_STATUS_2_REG, data & 0xfc); +} + +} // namespace esphome::bm8563 diff --git a/esphome/components/bm8563/bm8563.h b/esphome/components/bm8563/bm8563.h new file mode 100644 index 0000000000..eda2d1b3c0 --- /dev/null +++ b/esphome/components/bm8563/bm8563.h @@ -0,0 +1,57 @@ +#pragma once + +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome::bm8563 { + +class BM8563 : public time::RealTimeClock, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void write_time(); + void read_time(); + void start_timer(uint32_t duration_s); + + private: + void get_time_(ESPTime &time); + void get_date_(ESPTime &time); + + void set_time_(const ESPTime &time); + void set_date_(const ESPTime &time); + + void set_timer_irq_(uint32_t duration_s); + void clear_irq_(); + void disable_irq_(); + + void write_byte_(uint8_t reg, uint8_t value); + void write_register_(uint8_t reg, const uint8_t *data, size_t len); + optional read_register_(uint8_t reg); + + uint8_t bcd2_to_byte_(uint8_t value); + uint8_t byte_to_bcd2_(uint8_t value); +}; + +template class WriteAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->write_time(); } +}; + +template class ReadAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->read_time(); } +}; + +template class TimerAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint32_t, duration) + + void play(const Ts &...x) override { + auto duration = this->duration_.value(x...); + this->parent_->start_timer(duration); + } +}; + +} // namespace esphome::bm8563 diff --git a/esphome/components/bm8563/time.py b/esphome/components/bm8563/time.py new file mode 100644 index 0000000000..2785315af2 --- /dev/null +++ b/esphome/components/bm8563/time.py @@ -0,0 +1,80 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, time +import esphome.config_validation as cv +from esphome.const import CONF_DURATION, CONF_ID + +DEPENDENCIES = ["i2c"] + +I2C_ADDR = 0x51 + +bm8563_ns = cg.esphome_ns.namespace("bm8563") +BM8563 = bm8563_ns.class_("BM8563", time.RealTimeClock, i2c.I2CDevice) +WriteAction = bm8563_ns.class_("WriteAction", automation.Action) +ReadAction = bm8563_ns.class_("ReadAction", automation.Action) +TimerAction = bm8563_ns.class_("TimerAction", automation.Action) + +CONFIG_SCHEMA = ( + time.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(BM8563), + } + ) + .extend(cv.COMPONENT_SCHEMA) + .extend(i2c.i2c_device_schema(I2C_ADDR)) +) + + +@automation.register_action( + "bm8563.write_time", + WriteAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(BM8563), + } + ), +) +async def bm8563_write_time_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action( + "bm8563.start_timer", + TimerAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(BM8563), + cv.Required(CONF_DURATION): cv.templatable(cv.positive_time_period_seconds), + } + ), +) +async def bm8563_start_timer_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_DURATION], args, cg.uint32) + cg.add(var.set_duration(template_)) + return var + + +@automation.register_action( + "bm8563.read_time", + ReadAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(BM8563), + } + ), +) +async def bm8563_read_time_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + await time.register_time(var, config) diff --git a/esphome/components/bme280_base/bme280_base.cpp b/esphome/components/bme280_base/bme280_base.cpp index 86b65d361d..c5d4c9c0a5 100644 --- a/esphome/components/bme280_base/bme280_base.cpp +++ b/esphome/components/bme280_base/bme280_base.cpp @@ -100,18 +100,18 @@ void BME280Component::setup() { if (!this->read_byte(BME280_REGISTER_CHIPID, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (chip_id != 0x60) { this->error_code_ = WRONG_CHIP_ID; - this->mark_failed(BME280_ERROR_WRONG_CHIP_ID); + this->mark_failed(LOG_STR(BME280_ERROR_WRONG_CHIP_ID)); return; } // Send a soft reset. if (!this->write_byte(BME280_REGISTER_RESET, BME280_SOFT_RESET)) { - this->mark_failed("Reset failed"); + this->mark_failed(LOG_STR("Reset failed")); return; } // Wait until the NVM data has finished loading. @@ -120,12 +120,12 @@ void BME280Component::setup() { do { // NOLINT delay(2); if (!this->read_byte(BME280_REGISTER_STATUS, &status)) { - this->mark_failed("Error reading status register"); + this->mark_failed(LOG_STR("Error reading status register")); return; } } while ((status & BME280_STATUS_IM_UPDATE) && (--retry)); if (status & BME280_STATUS_IM_UPDATE) { - this->mark_failed("Timeout loading NVM"); + this->mark_failed(LOG_STR("Timeout loading NVM")); return; } @@ -153,26 +153,26 @@ void BME280Component::setup() { uint8_t humid_control_val = 0; if (!this->read_byte(BME280_REGISTER_CONTROLHUMID, &humid_control_val)) { - this->mark_failed("Read humidity control"); + this->mark_failed(LOG_STR("Read humidity control")); return; } humid_control_val &= ~0b00000111; humid_control_val |= this->humidity_oversampling_ & 0b111; if (!this->write_byte(BME280_REGISTER_CONTROLHUMID, humid_control_val)) { - this->mark_failed("Write humidity control"); + this->mark_failed(LOG_STR("Write humidity control")); return; } uint8_t config_register = 0; if (!this->read_byte(BME280_REGISTER_CONFIG, &config_register)) { - this->mark_failed("Read config"); + this->mark_failed(LOG_STR("Read config")); return; } config_register &= ~0b11111100; config_register |= 0b101 << 5; // 1000 ms standby time config_register |= (this->iir_filter_ & 0b111) << 2; if (!this->write_byte(BME280_REGISTER_CONFIG, config_register)) { - this->mark_failed("Write config"); + this->mark_failed(LOG_STR("Write config")); return; } } diff --git a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp index f5dcfd65a1..91383c8d45 100644 --- a/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp +++ b/esphome/components/bme68x_bsec2/bme68x_bsec2.cpp @@ -70,6 +70,9 @@ void BME68xBSEC2Component::dump_config() { if (this->is_failed()) { ESP_LOGE(TAG, "Communication failed (BSEC2 status: %d, BME68X status: %d)", this->bsec_status_, this->bme68x_status_); + if (this->bsec_status_ == BSEC_I_SU_SUBSCRIBEDOUTPUTGATES) { + ESP_LOGE(TAG, "No sensors, add at least one sensor to the config"); + } } if (this->algorithm_output_ != ALGORITHM_OUTPUT_IAQ) { diff --git a/esphome/components/bmp280_base/bmp280_base.cpp b/esphome/components/bmp280_base/bmp280_base.cpp index 39654f5875..728eead521 100644 --- a/esphome/components/bmp280_base/bmp280_base.cpp +++ b/esphome/components/bmp280_base/bmp280_base.cpp @@ -65,23 +65,23 @@ void BMP280Component::setup() { // https://community.st.com/t5/stm32-mcus-products/issue-with-reading-bmp280-chip-id-using-spi/td-p/691855 if (!this->bmp_read_byte(0xD0, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (!this->bmp_read_byte(0xD0, &chip_id)) { this->error_code_ = COMMUNICATION_FAILED; - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } if (chip_id != 0x58) { this->error_code_ = WRONG_CHIP_ID; - this->mark_failed(BMP280_ERROR_WRONG_CHIP_ID); + this->mark_failed(LOG_STR(BMP280_ERROR_WRONG_CHIP_ID)); return; } // Send a soft reset. if (!this->bmp_write_byte(BMP280_REGISTER_RESET, BMP280_SOFT_RESET)) { - this->mark_failed("Reset failed"); + this->mark_failed(LOG_STR("Reset failed")); return; } // Wait until the NVM data has finished loading. @@ -90,12 +90,12 @@ void BMP280Component::setup() { do { delay(2); if (!this->bmp_read_byte(BMP280_REGISTER_STATUS, &status)) { - this->mark_failed("Error reading status register"); + this->mark_failed(LOG_STR("Error reading status register")); return; } } while ((status & BMP280_STATUS_IM_UPDATE) && (--retry)); if (status & BMP280_STATUS_IM_UPDATE) { - this->mark_failed("Timeout loading NVM"); + this->mark_failed(LOG_STR("Timeout loading NVM")); return; } @@ -116,14 +116,14 @@ void BMP280Component::setup() { uint8_t config_register = 0; if (!this->bmp_read_byte(BMP280_REGISTER_CONFIG, &config_register)) { - this->mark_failed("Read config"); + this->mark_failed(LOG_STR("Read config")); return; } config_register &= ~0b11111100; config_register |= 0b000 << 5; // 0.5 ms standby time config_register |= (this->iir_filter_ & 0b111) << 2; if (!this->bmp_write_byte(BMP280_REGISTER_CONFIG, config_register)) { - this->mark_failed("Write config"); + this->mark_failed(LOG_STR("Write config")); return; } } diff --git a/esphome/components/button/automation.h b/esphome/components/button/automation.h index 3b792eb5d7..6a54b141a3 100644 --- a/esphome/components/button/automation.h +++ b/esphome/components/button/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -namespace esphome { -namespace button { +namespace esphome::button { template class PressAction : public Action { public: @@ -24,5 +23,4 @@ class ButtonPressTrigger : public Trigger<> { } }; -} // namespace button -} // namespace esphome +} // namespace esphome::button diff --git a/esphome/components/button/button.cpp b/esphome/components/button/button.cpp index c968d31088..87a222776e 100644 --- a/esphome/components/button/button.cpp +++ b/esphome/components/button/button.cpp @@ -1,8 +1,7 @@ #include "button.h" #include "esphome/core/log.h" -namespace esphome { -namespace button { +namespace esphome::button { static const char *const TAG = "button"; @@ -26,5 +25,4 @@ void Button::press() { } void Button::add_on_press_callback(std::function &&callback) { this->press_callback_.add(std::move(callback)); } -} // namespace button -} // namespace esphome +} // namespace esphome::button diff --git a/esphome/components/button/button.h b/esphome/components/button/button.h index 75b76f9dcf..18122f6f2f 100644 --- a/esphome/components/button/button.h +++ b/esphome/components/button/button.h @@ -4,8 +4,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace button { +namespace esphome::button { class Button; void log_button(const char *tag, const char *prefix, const char *type, Button *obj); @@ -45,5 +44,4 @@ class Button : public EntityBase, public EntityBase_DeviceClass { CallbackManager press_callback_{}; }; -} // namespace button -} // namespace esphome +} // namespace esphome::button diff --git a/esphome/components/camera/camera.cpp b/esphome/components/camera/camera.cpp index 3bd632af5c..66b8138f38 100644 --- a/esphome/components/camera/camera.cpp +++ b/esphome/components/camera/camera.cpp @@ -8,7 +8,7 @@ Camera *Camera::global_camera = nullptr; Camera::Camera() { if (global_camera != nullptr) { - this->status_set_error("Multiple cameras are configured, but only one is supported."); + this->status_set_error(LOG_STR("Multiple cameras are configured, but only one is supported.")); this->mark_failed(); return; } diff --git a/esphome/components/camera/camera.h b/esphome/components/camera/camera.h index c28a756a06..6e1fc8cc06 100644 --- a/esphome/components/camera/camera.h +++ b/esphome/components/camera/camera.h @@ -35,6 +35,21 @@ inline const char *to_string(PixelFormat format) { return "PIXEL_FORMAT_UNKNOWN"; } +// Forward declaration +class CameraImage; + +/** Listener interface for camera events. + * + * Components can implement this interface to receive camera notifications + * (new images, stream start/stop) without the overhead of std::function callbacks. + */ +class CameraListener { + public: + virtual void on_camera_image(const std::shared_ptr &image) {} + virtual void on_stream_start() {} + virtual void on_stream_stop() {} +}; + /** Abstract camera image base class. * Encapsulates the JPEG encoded data and it is shared among * all connected clients. @@ -87,12 +102,12 @@ struct CameraImageSpec { }; /** Abstract camera base class. Collaborates with API. - * 1) API server starts and installs callback (add_image_callback) - * which is called by the camera when a new image is available. + * 1) API server starts and registers as a listener (add_listener) + * to receive new images from the camera. * 2) New API client connects and creates a new image reader (create_image_reader). * 3) API connection receives protobuf CameraImageRequest and calls request_image. * 3.a) API connection receives protobuf CameraImageRequest and calls start_stream. - * 4) Camera implementation provides JPEG data in the CameraImage and calls callback. + * 4) Camera implementation provides JPEG data in the CameraImage and notifies listeners. * 5) API connection sets the image in the image reader. * 6) API connection consumes data from the image reader and returns the image when finished. * 7.a) Camera captures a new image and continues with 4) until start_stream is called. @@ -100,8 +115,8 @@ struct CameraImageSpec { class Camera : public EntityBase, public Component { public: Camera(); - // Camera implementation invokes callback to publish a new image. - virtual void add_image_callback(std::function)> &&callback) = 0; + /// Add a listener to receive camera events + virtual void add_listener(CameraListener *listener) = 0; /// Returns a new camera image reader that keeps track of the JPEG data in the camera image. virtual CameraImageReader *create_image_reader() = 0; // Connection, camera or web server requests one new JPEG image. diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 99acb76bcf..25d0a22083 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -1,9 +1,12 @@ +import logging + import esphome.codegen as cg from esphome.components import web_server_base from esphome.components.web_server_base import CONF_WEB_SERVER_BASE_ID from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv from esphome.const import ( + CONF_AP, CONF_ID, PLATFORM_BK72XX, PLATFORM_ESP32, @@ -14,6 +17,10 @@ from esphome.const import ( ) from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) def AUTO_LOAD() -> list[str]: @@ -50,6 +57,37 @@ CONFIG_SCHEMA = cv.All( ) +def _final_validate(config: ConfigType) -> ConfigType: + full_config = fv.full_config.get() + wifi_conf = full_config.get("wifi") + + if wifi_conf is None: + # This shouldn't happen due to DEPENDENCIES = ["wifi"], but check anyway + raise cv.Invalid("Captive portal requires the wifi component to be configured") + + if CONF_AP not in wifi_conf: + _LOGGER.warning( + "Captive portal is enabled but no WiFi AP is configured. " + "The captive portal will not be accessible. " + "Add 'ap:' to your WiFi configuration to enable the captive portal." + ) + + # Register socket needs for DNS server and additional HTTP connections + # - 1 UDP socket for DNS server + # - 3 additional TCP sockets for captive portal detection probes + configuration requests + # OS captive portal detection makes multiple probe requests that stay in TIME_WAIT. + # Need headroom for actual user configuration requests. + # LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts. + from esphome.components import socket + + socket.consume_sockets(4, "captive_portal")(config) + + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate + + @coroutine_with_priority(CoroPriority.CAPTIVE_PORTAL) async def to_code(config): paren = await cg.get_variable(config[CONF_WEB_SERVER_BASE_ID]) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 30438747f2..4eb00835b1 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -13,14 +13,16 @@ static const char *const TAG = "captive_portal"; void CaptivePortal::handle_config(AsyncWebServerRequest *request) { AsyncResponseStream *stream = request->beginResponseStream(ESPHOME_F("application/json")); stream->addHeader(ESPHOME_F("cache-control"), ESPHOME_F("public, max-age=0, must-revalidate")); + char mac_s[18]; + const char *mac_str = get_mac_address_pretty_into_buffer(mac_s); #ifdef USE_ESP8266 stream->print(ESPHOME_F("{\"mac\":\"")); - stream->print(get_mac_address_pretty().c_str()); + stream->print(mac_str); stream->print(ESPHOME_F("\",\"name\":\"")); stream->print(App.get_name().c_str()); stream->print(ESPHOME_F("\",\"aps\":[{}")); #else - stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", get_mac_address_pretty().c_str(), App.get_name().c_str()); + stream->printf(R"({"mac":"%s","name":"%s","aps":[{})", mac_str, App.get_name().c_str()); #endif for (auto &scan : wifi::global_wifi_component->get_scan_result()) { @@ -50,8 +52,8 @@ void CaptivePortal::handle_wifisave(AsyncWebServerRequest *request) { ESP_LOGI(TAG, "Requested WiFi Settings Change:"); ESP_LOGI(TAG, " SSID='%s'", ssid.c_str()); ESP_LOGI(TAG, " Password=" LOG_SECRET("'%s'"), psk.c_str()); - wifi::global_wifi_component->save_wifi_sta(ssid, psk); - wifi::global_wifi_component->start_scanning(); + // Defer save to main loop thread to avoid NVS operations from HTTP thread + this->defer([ssid, psk]() { wifi::global_wifi_component->save_wifi_sta(ssid, psk); }); request->redirect(ESPHOME_F("/?save")); } @@ -63,6 +65,12 @@ void CaptivePortal::start() { this->base_->init(); if (!this->initialized_) { this->base_->add_handler(this); +#ifdef USE_ESP32 + // Enable LRU socket purging to handle captive portal detection probe bursts + // OS captive portal detection makes many simultaneous HTTP requests which can + // exhaust sockets. LRU purging automatically closes oldest idle connections. + this->base_->get_server()->set_lru_purge_enable(true); +#endif } network::IPAddress ip = wifi::global_wifi_component->wifi_soft_ap_ip(); diff --git a/esphome/components/captive_portal/captive_portal.h b/esphome/components/captive_portal/captive_portal.h index f48c286f0c..ae9b9dfba0 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -40,6 +40,10 @@ class CaptivePortal : public AsyncWebHandler, public Component { void end() { this->active_ = false; this->disable_loop(); // Stop processing DNS requests +#ifdef USE_ESP32 + // Disable LRU socket purging now that captive portal is done + this->base_->get_server()->set_lru_purge_enable(false); +#endif this->base_->deinit(); if (this->dns_server_ != nullptr) { this->dns_server_->stop(); diff --git a/esphome/components/climate/automation.h b/esphome/components/climate/automation.h index 36cc8f4f21..fac56d9d9e 100644 --- a/esphome/components/climate/automation.h +++ b/esphome/components/climate/automation.h @@ -3,8 +3,7 @@ #include "esphome/core/automation.h" #include "climate.h" -namespace esphome { -namespace climate { +namespace esphome::climate { template class ControlAction : public Action { public: @@ -58,5 +57,4 @@ class StateTrigger : public Trigger { } }; -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index 82b75660ba..b0fba6aa62 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -3,8 +3,7 @@ #include "esphome/core/controller_registry.h" #include "esphome/core/macros.h" -namespace esphome { -namespace climate { +namespace esphome::climate { static const char *const TAG = "climate"; @@ -762,5 +761,4 @@ void Climate::dump_traits_(const char *tag) { } } -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index b277877c3e..28a73d8c05 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -8,8 +8,7 @@ #include "climate_mode.h" #include "climate_traits.h" -namespace esphome { -namespace climate { +namespace esphome::climate { #define LOG_CLIMATE(prefix, type, obj) \ if ((obj) != nullptr) { \ @@ -345,5 +344,4 @@ class Climate : public EntityBase { const char *custom_preset_{nullptr}; }; -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate_mode.cpp b/esphome/components/climate/climate_mode.cpp index 794f45ccd6..b153ee0424 100644 --- a/esphome/components/climate/climate_mode.cpp +++ b/esphome/components/climate/climate_mode.cpp @@ -1,7 +1,6 @@ #include "climate_mode.h" -namespace esphome { -namespace climate { +namespace esphome::climate { const LogString *climate_mode_to_string(ClimateMode mode) { switch (mode) { @@ -107,5 +106,4 @@ const LogString *climate_preset_to_string(ClimatePreset preset) { } } -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate_mode.h b/esphome/components/climate/climate_mode.h index 44423d2f22..c961c44248 100644 --- a/esphome/components/climate/climate_mode.h +++ b/esphome/components/climate/climate_mode.h @@ -3,8 +3,7 @@ #include #include "esphome/core/log.h" -namespace esphome { -namespace climate { +namespace esphome::climate { /// Enum for all modes a climate device can be in. /// NOTE: If adding values, update ClimateModeMask in climate_traits.h to use the new last value @@ -132,5 +131,4 @@ const LogString *climate_swing_mode_to_string(ClimateSwingMode mode); /// Convert the given PresetMode to a human-readable string. const LogString *climate_preset_to_string(ClimatePreset preset); -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate_traits.cpp b/esphome/components/climate/climate_traits.cpp index 342dffaad6..9bf2d9acd3 100644 --- a/esphome/components/climate/climate_traits.cpp +++ b/esphome/components/climate/climate_traits.cpp @@ -1,7 +1,6 @@ #include "climate_traits.h" -namespace esphome { -namespace climate { +namespace esphome::climate { int8_t ClimateTraits::get_target_temperature_accuracy_decimals() const { return step_to_accuracy_decimals(this->visual_target_temperature_step_); @@ -11,5 +10,4 @@ int8_t ClimateTraits::get_current_temperature_accuracy_decimals() const { return step_to_accuracy_decimals(this->visual_current_temperature_step_); } -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate/climate_traits.h b/esphome/components/climate/climate_traits.h index 0eecf9789f..d358293475 100644 --- a/esphome/components/climate/climate_traits.h +++ b/esphome/components/climate/climate_traits.h @@ -6,8 +6,7 @@ #include "esphome/core/finite_set_mask.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace climate { +namespace esphome::climate { // Type aliases for climate enum bitmasks // These replace std::set to eliminate red-black tree overhead @@ -292,5 +291,4 @@ class ClimateTraits { std::vector supported_custom_presets_; }; -} // namespace climate -} // namespace esphome +} // namespace esphome::climate diff --git a/esphome/components/climate_ir/__init__.py b/esphome/components/climate_ir/__init__.py index 6d66abf4cd..5315be3db6 100644 --- a/esphome/components/climate_ir/__init__.py +++ b/esphome/components/climate_ir/__init__.py @@ -3,7 +3,12 @@ import logging import esphome.codegen as cg from esphome.components import climate, remote_base, sensor import esphome.config_validation as cv -from esphome.const import CONF_SENSOR, CONF_SUPPORTS_COOL, CONF_SUPPORTS_HEAT +from esphome.const import ( + CONF_HUMIDITY_SENSOR, + CONF_SENSOR, + CONF_SUPPORTS_COOL, + CONF_SUPPORTS_HEAT, +) from esphome.cpp_generator import MockObjClass _LOGGER = logging.getLogger(__name__) @@ -32,6 +37,7 @@ def climate_ir_schema( cv.Optional(CONF_SUPPORTS_COOL, default=True): cv.boolean, cv.Optional(CONF_SUPPORTS_HEAT, default=True): cv.boolean, cv.Optional(CONF_SENSOR): cv.use_id(sensor.Sensor), + cv.Optional(CONF_HUMIDITY_SENSOR): cv.use_id(sensor.Sensor), } ) .extend(cv.COMPONENT_SCHEMA) @@ -61,6 +67,9 @@ async def register_climate_ir(var, config): if sensor_id := config.get(CONF_SENSOR): sens = await cg.get_variable(sensor_id) cg.add(var.set_sensor(sens)) + if sensor_id := config.get(CONF_HUMIDITY_SENSOR): + sens = await cg.get_variable(sensor_id) + cg.add(var.set_humidity_sensor(sens)) async def new_climate_ir(config, *args): diff --git a/esphome/components/climate_ir/climate_ir.cpp b/esphome/components/climate_ir/climate_ir.cpp index 2b95792a6c..50c8d459b0 100644 --- a/esphome/components/climate_ir/climate_ir.cpp +++ b/esphome/components/climate_ir/climate_ir.cpp @@ -11,7 +11,9 @@ climate::ClimateTraits ClimateIR::traits() { if (this->sensor_ != nullptr) { traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE); } - + if (this->humidity_sensor_ != nullptr) { + traits.add_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_HUMIDITY); + } traits.set_supported_modes({climate::CLIMATE_MODE_OFF, climate::CLIMATE_MODE_HEAT_COOL}); if (this->supports_cool_) traits.add_supported_mode(climate::CLIMATE_MODE_COOL); @@ -39,9 +41,16 @@ void ClimateIR::setup() { this->publish_state(); }); this->current_temperature = this->sensor_->state; - } else { - this->current_temperature = NAN; } + if (this->humidity_sensor_ != nullptr) { + this->humidity_sensor_->add_on_state_callback([this](float state) { + this->current_humidity = state; + // current humidity changed, publish state + this->publish_state(); + }); + this->current_humidity = this->humidity_sensor_->state; + } + // restore set points auto restore = this->restore_state_(); if (restore.has_value()) { diff --git a/esphome/components/climate_ir/climate_ir.h b/esphome/components/climate_ir/climate_ir.h index 62a43f0b2d..ac76d33853 100644 --- a/esphome/components/climate_ir/climate_ir.h +++ b/esphome/components/climate_ir/climate_ir.h @@ -43,6 +43,7 @@ class ClimateIR : public Component, void set_supports_cool(bool supports_cool) { this->supports_cool_ = supports_cool; } void set_supports_heat(bool supports_heat) { this->supports_heat_ = supports_heat; } void set_sensor(sensor::Sensor *sensor) { this->sensor_ = sensor; } + void set_humidity_sensor(sensor::Sensor *sensor) { this->humidity_sensor_ = sensor; } protected: float minimum_temperature_, maximum_temperature_, temperature_step_; @@ -67,6 +68,7 @@ class ClimateIR : public Component, climate::ClimatePresetMask presets_{}; sensor::Sensor *sensor_{nullptr}; + sensor::Sensor *humidity_sensor_{nullptr}; }; } // namespace climate_ir diff --git a/esphome/components/cover/automation.h b/esphome/components/cover/automation.h index 752e0398c1..c0345a7cc6 100644 --- a/esphome/components/cover/automation.h +++ b/esphome/components/cover/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "cover.h" -namespace esphome { -namespace cover { +namespace esphome::cover { template class OpenAction : public Action { public: @@ -131,5 +130,4 @@ class CoverClosedTrigger : public Trigger<> { } }; -} // namespace cover -} // namespace esphome +} // namespace esphome::cover diff --git a/esphome/components/cover/cover.cpp b/esphome/components/cover/cover.cpp index 3062dba28a..feac9823b9 100644 --- a/esphome/components/cover/cover.cpp +++ b/esphome/components/cover/cover.cpp @@ -6,33 +6,32 @@ #include "esphome/core/log.h" -namespace esphome { -namespace cover { +namespace esphome::cover { static const char *const TAG = "cover"; const float COVER_OPEN = 1.0f; const float COVER_CLOSED = 0.0f; -const char *cover_command_to_str(float pos) { +const LogString *cover_command_to_str(float pos) { if (pos == COVER_OPEN) { - return "OPEN"; + return LOG_STR("OPEN"); } else if (pos == COVER_CLOSED) { - return "CLOSE"; + return LOG_STR("CLOSE"); } else { - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } -const char *cover_operation_to_str(CoverOperation op) { +const LogString *cover_operation_to_str(CoverOperation op) { switch (op) { case COVER_OPERATION_IDLE: - return "IDLE"; + return LOG_STR("IDLE"); case COVER_OPERATION_OPENING: - return "OPENING"; + return LOG_STR("OPENING"); case COVER_OPERATION_CLOSING: - return "CLOSING"; + return LOG_STR("CLOSING"); default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -88,7 +87,7 @@ void CoverCall::perform() { if (traits.get_supports_position()) { ESP_LOGD(TAG, " Position: %.0f%%", *this->position_ * 100.0f); } else { - ESP_LOGD(TAG, " Command: %s", cover_command_to_str(*this->position_)); + ESP_LOGD(TAG, " Command: %s", LOG_STR_ARG(cover_command_to_str(*this->position_))); } } if (this->tilt_.has_value()) { @@ -170,7 +169,7 @@ void Cover::publish_state(bool save) { if (traits.get_supports_tilt()) { ESP_LOGD(TAG, " Tilt: %.0f%%", this->tilt * 100.0f); } - ESP_LOGD(TAG, " Current Operation: %s", cover_operation_to_str(this->current_operation)); + ESP_LOGD(TAG, " Current Operation: %s", LOG_STR_ARG(cover_operation_to_str(this->current_operation))); this->state_callback_.call(); #if defined(USE_COVER) && defined(USE_CONTROLLER_REGISTRY) @@ -212,5 +211,4 @@ void CoverRestoreState::apply(Cover *cover) { cover->publish_state(); } -} // namespace cover -} // namespace esphome +} // namespace esphome::cover diff --git a/esphome/components/cover/cover.h b/esphome/components/cover/cover.h index d5db6cfb4f..d8c45ab2bd 100644 --- a/esphome/components/cover/cover.h +++ b/esphome/components/cover/cover.h @@ -3,12 +3,12 @@ #include "esphome/core/component.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/preferences.h" #include "cover_traits.h" -namespace esphome { -namespace cover { +namespace esphome::cover { const extern float COVER_OPEN; const extern float COVER_CLOSED; @@ -87,7 +87,7 @@ enum CoverOperation : uint8_t { COVER_OPERATION_CLOSING, }; -const char *cover_operation_to_str(CoverOperation op); +const LogString *cover_operation_to_str(CoverOperation op); /** Base class for all cover devices. * @@ -157,5 +157,4 @@ class Cover : public EntityBase, public EntityBase_DeviceClass { ESPPreferenceObject rtc_; }; -} // namespace cover -} // namespace esphome +} // namespace esphome::cover diff --git a/esphome/components/cover/cover_traits.h b/esphome/components/cover/cover_traits.h index 79001c3b03..723516318b 100644 --- a/esphome/components/cover/cover_traits.h +++ b/esphome/components/cover/cover_traits.h @@ -1,7 +1,6 @@ #pragma once -namespace esphome { -namespace cover { +namespace esphome::cover { class CoverTraits { public: @@ -26,5 +25,4 @@ class CoverTraits { bool supports_stop_{false}; }; -} // namespace cover -} // namespace esphome +} // namespace esphome::cover diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp index 0ba2d9df94..f6280a75a1 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp @@ -19,13 +19,14 @@ void CST816Touchscreen::continue_setup_() { case CST816T_CHIP_ID: break; default: + ESP_LOGE(TAG, "Unknown chip ID: 0x%02X", this->chip_id_); + this->status_set_error(LOG_STR("Unknown chip ID")); this->mark_failed(); - this->status_set_error(str_sprintf("Unknown chip ID 0x%02X", this->chip_id_).c_str()); return; } this->write_byte(REG_IRQ_CTL, IRQ_EN_MOTION); } else if (!this->skip_probe_) { - this->status_set_error("Failed to read chip id"); + this->status_set_error(LOG_STR("Failed to read chip id")); this->mark_failed(); return; } diff --git a/esphome/components/dashboard_import/dashboard_import.cpp b/esphome/components/dashboard_import/dashboard_import.cpp index c04696fd53..d4a95b81f6 100644 --- a/esphome/components/dashboard_import/dashboard_import.cpp +++ b/esphome/components/dashboard_import/dashboard_import.cpp @@ -3,10 +3,10 @@ namespace esphome { namespace dashboard_import { -static std::string g_package_import_url; // NOLINT +static const char *g_package_import_url = ""; // NOLINT -const std::string &get_package_import_url() { return g_package_import_url; } -void set_package_import_url(std::string url) { g_package_import_url = std::move(url); } +const char *get_package_import_url() { return g_package_import_url; } +void set_package_import_url(const char *url) { g_package_import_url = url; } } // namespace dashboard_import } // namespace esphome diff --git a/esphome/components/dashboard_import/dashboard_import.h b/esphome/components/dashboard_import/dashboard_import.h index edcda6b803..488bf80a2e 100644 --- a/esphome/components/dashboard_import/dashboard_import.h +++ b/esphome/components/dashboard_import/dashboard_import.h @@ -1,12 +1,10 @@ #pragma once -#include - namespace esphome { namespace dashboard_import { -const std::string &get_package_import_url(); -void set_package_import_url(std::string url); +const char *get_package_import_url(); +void set_package_import_url(const char *url); } // namespace dashboard_import } // namespace esphome diff --git a/esphome/components/datetime/date_entity.cpp b/esphome/components/datetime/date_entity.cpp index 2c2775ecf4..c061bc81f7 100644 --- a/esphome/components/datetime/date_entity.cpp +++ b/esphome/components/datetime/date_entity.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace datetime { +namespace esphome::datetime { static const char *const TAG = "datetime.date_entity"; @@ -129,7 +128,6 @@ void DateEntityRestoreState::apply(DateEntity *date) { date->publish_state(); } -} // namespace datetime -} // namespace esphome +} // namespace esphome::datetime #endif // USE_DATETIME_DATE diff --git a/esphome/components/datetime/date_entity.h b/esphome/components/datetime/date_entity.h index ba2edb127a..069116d162 100644 --- a/esphome/components/datetime/date_entity.h +++ b/esphome/components/datetime/date_entity.h @@ -10,8 +10,7 @@ #include "datetime_base.h" -namespace esphome { -namespace datetime { +namespace esphome::datetime { #define LOG_DATETIME_DATE(prefix, type, obj) \ if ((obj) != nullptr) { \ @@ -111,7 +110,6 @@ template class DateSetAction : public Action, public Pare } }; -} // namespace datetime -} // namespace esphome +} // namespace esphome::datetime #endif // USE_DATETIME_DATE diff --git a/esphome/components/datetime/datetime_base.h b/esphome/components/datetime/datetime_base.h index b5f54ac96f..7b9b281ea4 100644 --- a/esphome/components/datetime/datetime_base.h +++ b/esphome/components/datetime/datetime_base.h @@ -8,8 +8,7 @@ #include "esphome/components/time/real_time_clock.h" #endif -namespace esphome { -namespace datetime { +namespace esphome::datetime { class DateTimeBase : public EntityBase { public: @@ -37,5 +36,4 @@ class DateTimeStateTrigger : public Trigger { } }; -} // namespace datetime -} // namespace esphome +} // namespace esphome::datetime diff --git a/esphome/components/datetime/datetime_entity.cpp b/esphome/components/datetime/datetime_entity.cpp index 8606a47fa7..694f9c5721 100644 --- a/esphome/components/datetime/datetime_entity.cpp +++ b/esphome/components/datetime/datetime_entity.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace datetime { +namespace esphome::datetime { static const char *const TAG = "datetime.datetime_entity"; @@ -250,7 +249,6 @@ bool OnDateTimeTrigger::matches_(const ESPTime &time) const { } #endif -} // namespace datetime -} // namespace esphome +} // namespace esphome::datetime #endif // USE_DATETIME_TIME diff --git a/esphome/components/datetime/datetime_entity.h b/esphome/components/datetime/datetime_entity.h index 43bff5a181..018346b34b 100644 --- a/esphome/components/datetime/datetime_entity.h +++ b/esphome/components/datetime/datetime_entity.h @@ -10,8 +10,7 @@ #include "datetime_base.h" -namespace esphome { -namespace datetime { +namespace esphome::datetime { #define LOG_DATETIME_DATETIME(prefix, type, obj) \ if ((obj) != nullptr) { \ @@ -146,7 +145,6 @@ class OnDateTimeTrigger : public Trigger<>, public Component, public Parented, public Component, public Parentedlog_partition_info_(); // Log partition information for ESP32 -#endif // USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ZEPHYR) + this->log_partition_info_(); // Log partition information +#endif } void DebugComponent::loop() { diff --git a/esphome/components/debug/debug_component.h b/esphome/components/debug/debug_component.h index efd0dafab0..96306f7cdf 100644 --- a/esphome/components/debug/debug_component.h +++ b/esphome/components/debug/debug_component.h @@ -62,19 +62,19 @@ class DebugComponent : public PollingComponent { sensor::Sensor *cpu_frequency_sensor_{nullptr}; #endif // USE_SENSOR -#ifdef USE_ESP32 +#if defined(USE_ESP32) || defined(USE_ZEPHYR) /** * @brief Logs information about the device's partition table. * - * This function iterates through the ESP32's partition table and logs details + * This function iterates through the partition table and logs details * about each partition, including its name, type, subtype, starting address, * and size. The information is useful for diagnosing issues related to flash * memory or verifying the partition configuration dynamically at runtime. * - * Only available when compiled for ESP32 platforms. + * Only available when compiled for ESP32 and ZEPHYR platforms. */ void log_partition_info_(); -#endif // USE_ESP32 +#endif #ifdef USE_TEXT_SENSOR text_sensor::TextSensor *device_info_{nullptr}; diff --git a/esphome/components/debug/debug_zephyr.cpp b/esphome/components/debug/debug_zephyr.cpp index 62fa391e5f..c888c41a78 100644 --- a/esphome/components/debug/debug_zephyr.cpp +++ b/esphome/components/debug/debug_zephyr.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #define BOOTLOADER_VERSION_REGISTER NRF_TIMER2->CC[0] @@ -86,6 +87,37 @@ std::string DebugComponent::get_reset_reason_() { uint32_t DebugComponent::get_free_heap_() { return INT_MAX; } +static void fa_cb(const struct flash_area *fa, void *user_data) { +#if CONFIG_FLASH_MAP_LABELS + const char *fa_label = flash_area_label(fa); + + if (fa_label == nullptr) { + fa_label = "-"; + } + ESP_LOGCONFIG(TAG, "%2d 0x%0*" PRIxPTR " %-26s %-24.24s 0x%-10x 0x%-12x", (int) fa->fa_id, + sizeof(uintptr_t) * 2, (uintptr_t) fa->fa_dev, fa->fa_dev->name, fa_label, (uint32_t) fa->fa_off, + fa->fa_size); +#else + ESP_LOGCONFIG(TAG, "%2d 0x%0*" PRIxPTR " %-26s 0x%-10x 0x%-12x", (int) fa->fa_id, sizeof(uintptr_t) * 2, + (uintptr_t) fa->fa_dev, fa->fa_dev->name, (uint32_t) fa->fa_off, fa->fa_size); +#endif +} + +void DebugComponent::log_partition_info_() { +#if CONFIG_FLASH_MAP_LABELS + ESP_LOGCONFIG(TAG, "ID | Device | Device Name " + "| Label | Offset | Size"); + ESP_LOGCONFIG(TAG, "--------------------------------------------" + "-----------------------------------------------"); +#else + ESP_LOGCONFIG(TAG, "ID | Device | Device Name " + "| Offset | Size"); + ESP_LOGCONFIG(TAG, "-----------------------------------------" + "------------------------------"); +#endif + flash_area_foreach(fa_cb, nullptr); +} + void DebugComponent::get_device_info_(std::string &device_info) { std::string supply = "Main supply status: "; if (nrf_power_mainregstatus_get(NRF_POWER) == NRF_POWER_MAINREGSTATUS_NORMAL) { diff --git a/esphome/components/demo/demo_alarm_control_panel.h b/esphome/components/demo/demo_alarm_control_panel.h index 9902d27882..f59434830b 100644 --- a/esphome/components/demo/demo_alarm_control_panel.h +++ b/esphome/components/demo/demo_alarm_control_panel.h @@ -33,7 +33,7 @@ class DemoAlarmControlPanel : public AlarmControlPanel, public Component { case ACP_STATE_ARMED_AWAY: if (this->get_requires_code_to_arm() && call.get_code().has_value()) { if (call.get_code().value() != "1234") { - this->status_momentary_error("Invalid code", 5000); + this->status_momentary_error("invalid_code", 5000); return; } } @@ -42,7 +42,7 @@ class DemoAlarmControlPanel : public AlarmControlPanel, public Component { case ACP_STATE_DISARMED: if (this->get_requires_code() && call.get_code().has_value()) { if (call.get_code().value() != "1234") { - this->status_momentary_error("Invalid code", 5000); + this->status_momentary_error("invalid_code", 5000); return; } } diff --git a/esphome/components/display/display.cpp b/esphome/components/display/display.cpp index 1451d14e2e..ebc3c0a9f6 100644 --- a/esphome/components/display/display.cpp +++ b/esphome/components/display/display.cpp @@ -7,7 +7,6 @@ namespace esphome { namespace display { - static const char *const TAG = "display"; const Color COLOR_OFF(0, 0, 0, 0); @@ -16,6 +15,7 @@ const Color COLOR_ON(255, 255, 255, 255); void Display::fill(Color color) { this->filled_rectangle(0, 0, this->get_width(), this->get_height(), color); } void Display::clear() { this->fill(COLOR_OFF); } void Display::set_rotation(DisplayRotation rotation) { this->rotation_ = rotation; } + void HOT Display::line(int x1, int y1, int x2, int y2, Color color) { const int32_t dx = abs(x2 - x1), sx = x1 < x2 ? 1 : -1; const int32_t dy = -abs(y2 - y1), sy = y1 < y2 ? 1 : -1; @@ -91,23 +91,27 @@ void HOT Display::horizontal_line(int x, int y, int width, Color color) { for (int i = x; i < x + width; i++) this->draw_pixel_at(i, y, color); } + void HOT Display::vertical_line(int x, int y, int height, Color color) { // Future: Could be made more efficient by manipulating buffer directly in certain rotations. for (int i = y; i < y + height; i++) this->draw_pixel_at(x, i, color); } + void Display::rectangle(int x1, int y1, int width, int height, Color color) { this->horizontal_line(x1, y1, width, color); this->horizontal_line(x1, y1 + height - 1, width, color); this->vertical_line(x1, y1, height, color); this->vertical_line(x1 + width - 1, y1, height, color); } + void Display::filled_rectangle(int x1, int y1, int width, int height, Color color) { // Future: Use vertical_line and horizontal_line methods depending on rotation to reduce memory accesses. for (int i = y1; i < y1 + height; i++) { this->horizontal_line(x1, i, width, color); } } + void HOT Display::circle(int center_x, int center_xy, int radius, Color color) { int dx = -radius; int dy = 0; @@ -131,6 +135,7 @@ void HOT Display::circle(int center_x, int center_xy, int radius, Color color) { } } while (dx <= 0); } + void Display::filled_circle(int center_x, int center_y, int radius, Color color) { int dx = -int32_t(radius); int dy = 0; @@ -157,6 +162,7 @@ void Display::filled_circle(int center_x, int center_y, int radius, Color color) } } while (dx <= 0); } + void Display::filled_ring(int center_x, int center_y, int radius1, int radius2, Color color) { int rmax = radius1 > radius2 ? radius1 : radius2; int rmin = radius1 < radius2 ? radius1 : radius2; @@ -213,6 +219,7 @@ void Display::filled_ring(int center_x, int center_y, int radius1, int radius2, } } while (dxmax <= 0); } + void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, int progress, Color color) { int rmax = radius1 > radius2 ? radius1 : radius2; int rmin = radius1 < radius2 ? radius1 : radius2; @@ -228,7 +235,8 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, // outer dots this->draw_pixel_at(center_x + dxmax, center_y - dymax, color); this->draw_pixel_at(center_x - dxmax, center_y - dymax, color); - if (dymin < rmin) { // side parts + if (dymin < rmin) { + // side parts int lhline_width = -(dxmax - dxmin) + 1; if (progress >= 50) { if (float(dymax) < float(-dxmax) * tan_a) { @@ -239,7 +247,8 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, this->horizontal_line(center_x + dxmax, center_y - dymax, lhline_width, color); // left if (!dymax) this->horizontal_line(center_x - dxmin, center_y, lhline_width, color); // right horizontal border - if (upd_dxmax > -dxmin) { // right + if (upd_dxmax > -dxmin) { + // right int rhline_width = (upd_dxmax + dxmin) + 1; this->horizontal_line(center_x - dxmin, center_y - dymax, rhline_width > lhline_width ? lhline_width : rhline_width, color); @@ -256,7 +265,8 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, if (lhline_width > 0) this->horizontal_line(center_x + dxmax, center_y - dymax, lhline_width, color); } - } else { // top part + } else { + // top part int hline_width = 2 * (-dxmax) + 1; if (progress >= 50) { if (dymax < float(-dxmax) * tan_a) { @@ -300,11 +310,13 @@ void Display::filled_gauge(int center_x, int center_y, int radius1, int radius2, } } while (dxmax <= 0); } + void HOT Display::triangle(int x1, int y1, int x2, int y2, int x3, int y3, Color color) { this->line(x1, y1, x2, y2, color); this->line(x1, y1, x3, y3, color); this->line(x2, y2, x3, y3, color); } + void Display::sort_triangle_points_by_y_(int *x1, int *y1, int *x2, int *y2, int *x3, int *y3) { if (*y1 > *y2) { int x_temp = *x1, y_temp = *y1; @@ -322,6 +334,7 @@ void Display::sort_triangle_points_by_y_(int *x1, int *y1, int *x2, int *y2, int *x3 = x_temp, *y3 = y_temp; } } + void Display::filled_flat_side_triangle_(int x1, int y1, int x2, int y2, int x3, int y3, Color color) { // y2 must be equal to y3 (same horizontal line) @@ -333,7 +346,8 @@ void Display::filled_flat_side_triangle_(int x1, int y1, int x2, int y2, int x3, int s1_dy = abs(y2 - y1); int s1_sign_x = ((x2 - x1) >= 0) ? 1 : -1; int s1_sign_y = ((y2 - y1) >= 0) ? 1 : -1; - if (s1_dy > s1_dx) { // swap values + if (s1_dy > s1_dx) { + // swap values int tmp = s1_dx; s1_dx = s1_dy; s1_dy = tmp; @@ -349,7 +363,8 @@ void Display::filled_flat_side_triangle_(int x1, int y1, int x2, int y2, int x3, int s2_dy = abs(y3 - y1); int s2_sign_x = ((x3 - x1) >= 0) ? 1 : -1; int s2_sign_y = ((y3 - y1) >= 0) ? 1 : -1; - if (s2_dy > s2_dx) { // swap values + if (s2_dy > s2_dx) { + // swap values int tmp = s2_dx; s2_dx = s2_dy; s2_dy = tmp; @@ -402,20 +417,25 @@ void Display::filled_flat_side_triangle_(int x1, int y1, int x2, int y2, int x3, } } } + void Display::filled_triangle(int x1, int y1, int x2, int y2, int x3, int y3, Color color) { // Sort the three points by y-coordinate ascending, so [x1,y1] is the topmost point this->sort_triangle_points_by_y_(&x1, &y1, &x2, &y2, &x3, &y3); - if (y2 == y3) { // Check for special case of a bottom-flat triangle + if (y2 == y3) { + // Check for special case of a bottom-flat triangle this->filled_flat_side_triangle_(x1, y1, x2, y2, x3, y3, color); - } else if (y1 == y2) { // Check for special case of a top-flat triangle + } else if (y1 == y2) { + // Check for special case of a top-flat triangle this->filled_flat_side_triangle_(x3, y3, x1, y1, x2, y2, color); - } else { // General case: split the no-flat-side triangle in a top-flat triangle and bottom-flat triangle + } else { + // General case: split the no-flat-side triangle in a top-flat triangle and bottom-flat triangle int x_temp = (int) (x1 + ((float) (y2 - y1) / (float) (y3 - y1)) * (x3 - x1)), y_temp = y2; this->filled_flat_side_triangle_(x1, y1, x2, y2, x_temp, y_temp, color); this->filled_flat_side_triangle_(x3, y3, x2, y2, x_temp, y_temp, color); } } + void HOT Display::get_regular_polygon_vertex(int vertex_id, int *vertex_x, int *vertex_y, int center_x, int center_y, int radius, int edges, RegularPolygonVariation variation, float rotation_degrees) { @@ -447,7 +467,8 @@ void HOT Display::regular_polygon(int x, int y, int radius, int edges, RegularPo int current_vertex_x, current_vertex_y; get_regular_polygon_vertex(current_vertex_id, ¤t_vertex_x, ¤t_vertex_y, x, y, radius, edges, variation, rotation_degrees); - if (current_vertex_id > 0) { // Start drawing after the 2nd vertex coordinates has been calculated + if (current_vertex_id > 0) { + // Start drawing after the 2nd vertex coordinates has been calculated if (drawing == DRAWING_FILLED) { this->filled_triangle(x, y, previous_vertex_x, previous_vertex_y, current_vertex_x, current_vertex_y, color); } else if (drawing == DRAWING_OUTLINE) { @@ -459,21 +480,26 @@ void HOT Display::regular_polygon(int x, int y, int radius, int edges, RegularPo } } } + void HOT Display::regular_polygon(int x, int y, int radius, int edges, RegularPolygonVariation variation, Color color, RegularPolygonDrawing drawing) { regular_polygon(x, y, radius, edges, variation, ROTATION_0_DEGREES, color, drawing); } + void HOT Display::regular_polygon(int x, int y, int radius, int edges, Color color, RegularPolygonDrawing drawing) { regular_polygon(x, y, radius, edges, VARIATION_POINTY_TOP, ROTATION_0_DEGREES, color, drawing); } + void Display::filled_regular_polygon(int x, int y, int radius, int edges, RegularPolygonVariation variation, float rotation_degrees, Color color) { regular_polygon(x, y, radius, edges, variation, rotation_degrees, color, DRAWING_FILLED); } + void Display::filled_regular_polygon(int x, int y, int radius, int edges, RegularPolygonVariation variation, Color color) { regular_polygon(x, y, radius, edges, variation, ROTATION_0_DEGREES, color, DRAWING_FILLED); } + void Display::filled_regular_polygon(int x, int y, int radius, int edges, Color color) { regular_polygon(x, y, radius, edges, VARIATION_POINTY_TOP, ROTATION_0_DEGREES, color, DRAWING_FILLED); } @@ -584,15 +610,19 @@ void Display::get_text_bounds(int x, int y, const char *text, BaseFont *font, Te break; } } + void Display::print(int x, int y, BaseFont *font, Color color, const char *text, Color background) { this->print(x, y, font, color, TextAlign::TOP_LEFT, text, background); } + void Display::print(int x, int y, BaseFont *font, TextAlign align, const char *text) { this->print(x, y, font, COLOR_ON, align, text); } + void Display::print(int x, int y, BaseFont *font, const char *text) { this->print(x, y, font, COLOR_ON, TextAlign::TOP_LEFT, text); } + void Display::printf(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format, ...) { va_list arg; @@ -600,31 +630,37 @@ void Display::printf(int x, int y, BaseFont *font, Color color, Color background this->vprintf_(x, y, font, color, background, align, format, arg); va_end(arg); } + void Display::printf(int x, int y, BaseFont *font, Color color, TextAlign align, const char *format, ...) { va_list arg; va_start(arg, format); this->vprintf_(x, y, font, color, COLOR_OFF, align, format, arg); va_end(arg); } + void Display::printf(int x, int y, BaseFont *font, Color color, const char *format, ...) { va_list arg; va_start(arg, format); this->vprintf_(x, y, font, color, COLOR_OFF, TextAlign::TOP_LEFT, format, arg); va_end(arg); } + void Display::printf(int x, int y, BaseFont *font, TextAlign align, const char *format, ...) { va_list arg; va_start(arg, format); this->vprintf_(x, y, font, COLOR_ON, COLOR_OFF, align, format, arg); va_end(arg); } + void Display::printf(int x, int y, BaseFont *font, const char *format, ...) { va_list arg; va_start(arg, format); this->vprintf_(x, y, font, COLOR_ON, COLOR_OFF, TextAlign::TOP_LEFT, format, arg); va_end(arg); } + void Display::set_writer(display_writer_t &&writer) { this->writer_ = writer; } + void Display::set_pages(std::vector pages) { for (auto *page : pages) page->set_parent(this); @@ -637,6 +673,7 @@ void Display::set_pages(std::vector pages) { pages[pages.size() - 1]->set_next(pages[0]); this->show_page(pages[0]); } + void Display::show_page(DisplayPage *page) { this->previous_page_ = this->page_; this->page_ = page; @@ -645,8 +682,10 @@ void Display::show_page(DisplayPage *page) { t->process(this->previous_page_, this->page_); } } + void Display::show_next_page() { this->page_->show_next(); } void Display::show_prev_page() { this->page_->show_prev(); } + void Display::do_update_() { if (this->auto_clear_enabled_) { this->clear(); @@ -660,10 +699,12 @@ void Display::do_update_() { } this->clear_clipping_(); } + void DisplayOnPageChangeTrigger::process(DisplayPage *from, DisplayPage *to) { if ((this->from_ == nullptr || this->from_ == from) && (this->to_ == nullptr || this->to_ == to)) this->trigger(from, to); } + void Display::strftime(int x, int y, BaseFont *font, Color color, Color background, TextAlign align, const char *format, ESPTime time) { char buffer[64]; @@ -671,15 +712,19 @@ void Display::strftime(int x, int y, BaseFont *font, Color color, Color backgrou if (ret > 0) this->print(x, y, font, color, align, buffer, background); } + void Display::strftime(int x, int y, BaseFont *font, Color color, TextAlign align, const char *format, ESPTime time) { this->strftime(x, y, font, color, COLOR_OFF, align, format, time); } + void Display::strftime(int x, int y, BaseFont *font, Color color, const char *format, ESPTime time) { this->strftime(x, y, font, color, COLOR_OFF, TextAlign::TOP_LEFT, format, time); } + void Display::strftime(int x, int y, BaseFont *font, TextAlign align, const char *format, ESPTime time) { this->strftime(x, y, font, COLOR_ON, COLOR_OFF, align, format, time); } + void Display::strftime(int x, int y, BaseFont *font, const char *format, ESPTime time) { this->strftime(x, y, font, COLOR_ON, COLOR_OFF, TextAlign::TOP_LEFT, format, time); } @@ -691,6 +736,7 @@ void Display::start_clipping(Rect rect) { } this->clipping_rectangle_.push_back(rect); } + void Display::end_clipping() { if (this->clipping_rectangle_.empty()) { ESP_LOGE(TAG, "clear: Clipping is not set."); @@ -698,6 +744,7 @@ void Display::end_clipping() { this->clipping_rectangle_.pop_back(); } } + void Display::extend_clipping(Rect add_rect) { if (this->clipping_rectangle_.empty()) { ESP_LOGE(TAG, "add: Clipping is not set."); @@ -705,6 +752,7 @@ void Display::extend_clipping(Rect add_rect) { this->clipping_rectangle_.back().extend(add_rect); } } + void Display::shrink_clipping(Rect add_rect) { if (this->clipping_rectangle_.empty()) { ESP_LOGE(TAG, "add: Clipping is not set."); @@ -712,6 +760,7 @@ void Display::shrink_clipping(Rect add_rect) { this->clipping_rectangle_.back().shrink(add_rect); } } + Rect Display::get_clipping() const { if (this->clipping_rectangle_.empty()) { return Rect(); @@ -719,7 +768,9 @@ Rect Display::get_clipping() const { return this->clipping_rectangle_.back(); } } + void Display::clear_clipping_() { this->clipping_rectangle_.clear(); } + bool Display::clip(int x, int y) { if (x < 0 || x >= this->get_width() || y < 0 || y >= this->get_height()) return false; @@ -727,6 +778,7 @@ bool Display::clip(int x, int y) { return false; return true; } + bool Display::clamp_x_(int x, int w, int &min_x, int &max_x) { min_x = std::max(x, 0); max_x = std::min(x + w, this->get_width()); @@ -742,6 +794,7 @@ bool Display::clamp_x_(int x, int w, int &min_x, int &max_x) { return min_x < max_x; } + bool Display::clamp_y_(int y, int h, int &min_y, int &max_y) { min_y = std::max(y, 0); max_y = std::min(y + h, this->get_height()); @@ -766,15 +819,15 @@ void Display::test_card() { int w = get_width(), h = get_height(), image_w, image_h; this->clear(); this->show_test_card_ = false; + image_w = std::min(w - 20, 310); + image_h = std::min(h - 20, 255); + int shift_x = (w - image_w) / 2; + int shift_y = (h - image_h) / 2; + int line_w = (image_w - 6) / 6; + int image_c = image_w / 2; if (this->get_display_type() == DISPLAY_TYPE_COLOR) { Color r(255, 0, 0), g(0, 255, 0), b(0, 0, 255); - image_w = std::min(w - 20, 310); - image_h = std::min(h - 20, 255); - int shift_x = (w - image_w) / 2; - int shift_y = (h - image_h) / 2; - int line_w = (image_w - 6) / 6; - int image_c = image_w / 2; for (auto i = 0; i != image_h; i++) { int c = esp_scale(i, image_h); this->horizontal_line(shift_x + 0, shift_y + i, line_w, r.fade_to_white(c)); @@ -786,26 +839,26 @@ void Display::test_card() { this->horizontal_line(shift_x + image_w - (line_w * 2), shift_y + i, line_w, b.fade_to_white(c)); this->horizontal_line(shift_x + image_w - line_w, shift_y + i, line_w, b.fade_to_black(c)); } - this->rectangle(shift_x, shift_y, image_w, image_h, Color(127, 127, 0)); + } + this->rectangle(shift_x, shift_y, image_w, image_h, Color(127, 127, 0)); - uint16_t shift_r = shift_x + line_w - (8 * 3); - uint16_t shift_g = shift_x + image_c - (8 * 3); - uint16_t shift_b = shift_x + image_w - line_w - (8 * 3); - shift_y = h / 2 - (8 * 3); - for (auto i = 0; i < 8; i++) { - uint8_t ftr = progmem_read_byte(&TESTCARD_FONT[0][i]); - uint8_t ftg = progmem_read_byte(&TESTCARD_FONT[1][i]); - uint8_t ftb = progmem_read_byte(&TESTCARD_FONT[2][i]); - for (auto k = 0; k < 8; k++) { - if ((ftr & (1 << k)) != 0) { - this->filled_rectangle(shift_r + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); - } - if ((ftg & (1 << k)) != 0) { - this->filled_rectangle(shift_g + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); - } - if ((ftb & (1 << k)) != 0) { - this->filled_rectangle(shift_b + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); - } + uint16_t shift_r = shift_x + line_w - (8 * 3); + uint16_t shift_g = shift_x + image_c - (8 * 3); + uint16_t shift_b = shift_x + image_w - line_w - (8 * 3); + shift_y = h / 2 - (8 * 3); + for (auto i = 0; i < 8; i++) { + uint8_t ftr = progmem_read_byte(&TESTCARD_FONT[0][i]); + uint8_t ftg = progmem_read_byte(&TESTCARD_FONT[1][i]); + uint8_t ftb = progmem_read_byte(&TESTCARD_FONT[2][i]); + for (auto k = 0; k < 8; k++) { + if ((ftr & (1 << k)) != 0) { + this->filled_rectangle(shift_r + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); + } + if ((ftg & (1 << k)) != 0) { + this->filled_rectangle(shift_g + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); + } + if ((ftb & (1 << k)) != 0) { + this->filled_rectangle(shift_b + (i * 6), shift_y + (k * 6), 6, 6, COLOR_OFF); } } } @@ -818,7 +871,9 @@ void Display::test_card() { } DisplayPage::DisplayPage(display_writer_t writer) : writer_(std::move(writer)) {} + void DisplayPage::show() { this->parent_->show_page(this); } + void DisplayPage::show_next() { if (this->next_ == nullptr) { ESP_LOGE(TAG, "no next page"); @@ -826,6 +881,7 @@ void DisplayPage::show_next() { } this->next_->show(); } + void DisplayPage::show_prev() { if (this->prev_ == nullptr) { ESP_LOGE(TAG, "no previous page"); @@ -833,6 +889,7 @@ void DisplayPage::show_prev() { } this->prev_->show(); } + void DisplayPage::set_parent(Display *parent) { this->parent_ = parent; } void DisplayPage::set_prev(DisplayPage *prev) { this->prev_ = prev; } void DisplayPage::set_next(DisplayPage *next) { this->next_ = next; } @@ -868,6 +925,5 @@ const LogString *text_align_to_string(TextAlign textalign) { return LOG_STR("UNKNOWN"); } } - } // namespace display } // namespace esphome diff --git a/esphome/components/ds1307/ds1307.cpp b/esphome/components/ds1307/ds1307.cpp index 077db497b1..adbd7b5487 100644 --- a/esphome/components/ds1307/ds1307.cpp +++ b/esphome/components/ds1307/ds1307.cpp @@ -23,7 +23,7 @@ void DS1307Component::dump_config() { if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } - ESP_LOGCONFIG(TAG, " Timezone: '%s'", this->timezone_.c_str()); + RealTimeClock::dump_config(); } float DS1307Component::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index 9ff393b397..ff5693c206 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -4,8 +4,10 @@ import pkgutil from esphome import core, pins import esphome.codegen as cg from esphome.components import display, spi +from esphome.components.display import CONF_SHOW_TEST_CARD, validate_rotation from esphome.components.mipi import flatten_sequence, map_sequence import esphome.config_validation as cv +from esphome.config_validation import update_interval from esphome.const import ( CONF_BUSY_PIN, CONF_CS_PIN, @@ -13,15 +15,25 @@ from esphome.const import ( CONF_DC_PIN, CONF_DIMENSIONS, CONF_ENABLE_PIN, + CONF_FULL_UPDATE_EVERY, CONF_HEIGHT, CONF_ID, CONF_INIT_SEQUENCE, CONF_LAMBDA, + CONF_MIRROR_X, + CONF_MIRROR_Y, CONF_MODEL, + CONF_PAGES, CONF_RESET_DURATION, CONF_RESET_PIN, + CONF_ROTATION, + CONF_SWAP_XY, + CONF_TRANSFORM, + CONF_UPDATE_INTERVAL, CONF_WIDTH, ) +from esphome.cpp_generator import RawExpression +from esphome.final_validate import full_config from . import models @@ -32,8 +44,9 @@ CONF_INIT_SEQUENCE_ID = "init_sequence_id" epaper_spi_ns = cg.esphome_ns.namespace("epaper_spi") EPaperBase = epaper_spi_ns.class_( - "EPaperBase", cg.PollingComponent, spi.SPIDevice, display.DisplayBuffer + "EPaperBase", cg.PollingComponent, spi.SPIDevice, display.Display ) +Transform = epaper_spi_ns.enum("Transform") EPaperSpectraE6 = epaper_spi_ns.class_("EPaperSpectraE6", EPaperBase) EPaper7p3InSpectraE6 = epaper_spi_ns.class_("EPaper7p3InSpectraE6", EPaperSpectraE6) @@ -52,6 +65,8 @@ DIMENSION_SCHEMA = cv.Schema( } ) +TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} + def model_schema(config): model = MODELS[config[CONF_MODEL]] @@ -73,7 +88,18 @@ def model_schema(config): ) .extend( { + cv.Optional(CONF_ROTATION, default=0): validate_rotation, cv.Required(CONF_MODEL): cv.one_of(model.name, upper=True), + cv.Optional( + CONF_UPDATE_INTERVAL, default=cv.UNDEFINED + ): update_interval, + cv.Optional(CONF_TRANSFORM): cv.Schema( + { + cv.Required(CONF_MIRROR_X): cv.boolean, + cv.Required(CONF_MIRROR_Y): cv.boolean, + } + ), + cv.Optional(CONF_FULL_UPDATE_EVERY, default=1): cv.int_range(1, 255), model.option(CONF_DC_PIN, fallback=None): pins.gpio_output_pin_schema, cv.GenerateID(): cv.declare_id(class_name), cv.GenerateID(CONF_INIT_SEQUENCE_ID): cv.declare_id(cg.uint8), @@ -102,7 +128,7 @@ def customise_schema(config): """ config = cv.Schema( { - cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True), + cv.Required(CONF_MODEL): cv.one_of(*MODELS, upper=True, space="-"), }, extra=cv.ALLOW_EXTRA, )(config) @@ -111,9 +137,29 @@ def customise_schema(config): CONFIG_SCHEMA = customise_schema -FINAL_VALIDATE_SCHEMA = spi.final_validate_device_schema( - "epaper_spi", require_miso=False, require_mosi=True -) + +def _final_validate(config): + spi.final_validate_device_schema( + "epaper_spi", require_miso=False, require_mosi=True + )(config) + + global_config = full_config.get() + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + + if CONF_LAMBDA not in config and CONF_PAGES not in config: + if LVGL_DOMAIN in global_config: + if CONF_UPDATE_INTERVAL not in config: + config[CONF_UPDATE_INTERVAL] = update_interval("never") + else: + # If no drawing methods are configured, and LVGL is not enabled, show a test card + config[CONF_SHOW_TEST_CARD] = True + config[CONF_UPDATE_INTERVAL] = core.TimePeriod( + seconds=60 + ).total_milliseconds + return config + + +FINAL_VALIDATE_SCHEMA = _final_validate async def to_code(config): @@ -137,7 +183,9 @@ async def to_code(config): init_sequence_length, ) - await display.register_display(var, config) + # Rotation is handled by setting the transform + display_config = {k: v for k, v in config.items() if k != CONF_ROTATION} + await display.register_display(var, display_config) await spi.register_spi_device(var, config) dc = await cg.gpio_pin_expression(config[CONF_DC_PIN]) @@ -148,11 +196,35 @@ async def to_code(config): config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void ) cg.add(var.set_writer(lambda_)) - if CONF_RESET_PIN in config: - reset = await cg.gpio_pin_expression(config[CONF_RESET_PIN]) + if reset_pin := config.get(CONF_RESET_PIN): + reset = await cg.gpio_pin_expression(reset_pin) cg.add(var.set_reset_pin(reset)) - if CONF_BUSY_PIN in config: - busy = await cg.gpio_pin_expression(config[CONF_BUSY_PIN]) + if busy_pin := config.get(CONF_BUSY_PIN): + busy = await cg.gpio_pin_expression(busy_pin) cg.add(var.set_busy_pin(busy)) + cg.add(var.set_full_update_every(config[CONF_FULL_UPDATE_EVERY])) if CONF_RESET_DURATION in config: cg.add(var.set_reset_duration(config[CONF_RESET_DURATION])) + if transform := config.get(CONF_TRANSFORM): + transform[CONF_SWAP_XY] = False + else: + transform = {x: model.get_default(x, False) for x in TRANSFORM_OPTIONS} + rotation = config[CONF_ROTATION] + if rotation == 180: + transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] + transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] + elif rotation == 90: + transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] + transform[CONF_MIRROR_X] = not transform[CONF_MIRROR_X] + elif rotation == 270: + transform[CONF_SWAP_XY] = not transform[CONF_SWAP_XY] + transform[CONF_MIRROR_Y] = not transform[CONF_MIRROR_Y] + transform_str = "|".join( + { + str(getattr(Transform, x.upper())) + for x in TRANSFORM_OPTIONS + if transform.get(x) + } + ) + if transform_str: + cg.add(var.set_transform(RawExpression(transform_str))) diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index cf6a0b0c3d..f6313d33ef 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -9,9 +9,8 @@ namespace esphome::epaper_spi { static const char *const TAG = "epaper_spi"; static constexpr const char *const EPAPER_STATE_STRINGS[] = { - "IDLE", "UPDATE", "RESET", "RESET_END", - - "SHOULD_WAIT", "INITIALISE", "TRANSFER_DATA", "POWER_ON", "REFRESH_SCREEN", "POWER_OFF", "DEEP_SLEEP", + "IDLE", "UPDATE", "RESET", "RESET_END", "SHOULD_WAIT", "INITIALISE", + "TRANSFER_DATA", "POWER_ON", "REFRESH_SCREEN", "POWER_OFF", "DEEP_SLEEP", }; const char *EPaperBase::epaper_state_to_string_() { @@ -22,7 +21,7 @@ const char *EPaperBase::epaper_state_to_string_() { void EPaperBase::setup() { if (!this->init_buffer_(this->buffer_length_)) { - this->mark_failed("Failed to initialise buffer"); + this->mark_failed(LOG_STR("Failed to initialise buffer")); return; } this->setup_pins_(); @@ -69,8 +68,8 @@ void EPaperBase::data(uint8_t value) { // The command is the first byte, length is the length of data only in the second byte, followed by the data. // [COMMAND, LENGTH, DATA...] void EPaperBase::cmd_data(uint8_t command, const uint8_t *ptr, size_t length) { - ESP_LOGVV(TAG, "Command: 0x%02X, Length: %d, Data: %s", command, length, - format_hex_pretty(ptr, length, '.', false).c_str()); + ESP_LOGV(TAG, "Command: 0x%02X, Length: %d, Data: %s", command, length, + format_hex_pretty(ptr, length, '.', false).c_str()); this->dc_pin_->digital_write(false); this->enable(); @@ -89,7 +88,7 @@ bool EPaperBase::is_idle_() const { return !this->busy_pin_->digital_read(); } -bool EPaperBase::reset_() const { +bool EPaperBase::reset() { if (this->reset_pin_ != nullptr) { if (this->state_ == EPaperState::RESET) { this->reset_pin_->digital_write(false); @@ -105,16 +104,16 @@ void EPaperBase::update() { ESP_LOGE(TAG, "Display already in state %s", epaper_state_to_string_()); return; } - this->set_state_(EPaperState::RESET); + this->set_state_(EPaperState::UPDATE); this->enable_loop(); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + this->update_start_time_ = millis(); +#endif } void EPaperBase::wait_for_idle_(bool should_wait) { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - if (should_wait) { - this->waiting_for_idle_start_ = millis(); - this->waiting_for_idle_last_print_ = this->waiting_for_idle_start_; - } + this->waiting_for_idle_start_ = millis(); #endif this->waiting_for_idle_ = should_wait; } @@ -138,7 +137,9 @@ void EPaperBase::loop() { if (this->waiting_for_idle_) { if (this->is_idle_()) { this->waiting_for_idle_ = false; - ESP_LOGV(TAG, "Screen now idle after %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + ESP_LOGV(TAG, "Screen was busy for %u ms", (unsigned) (millis() - this->waiting_for_idle_start_)); +#endif } else { #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE if (now - this->waiting_for_idle_last_print_ >= 1000) { @@ -164,23 +165,27 @@ void EPaperBase::process_state_() { ESP_LOGV(TAG, "Process state entered in state %s", epaper_state_to_string_()); switch (this->state_) { default: - ESP_LOGD(TAG, "Display is in unhandled state %s", epaper_state_to_string_()); - this->disable_loop(); + ESP_LOGE(TAG, "Display is in unhandled state %s", epaper_state_to_string_()); + this->set_state_(EPaperState::IDLE); break; case EPaperState::IDLE: this->disable_loop(); break; case EPaperState::RESET: case EPaperState::RESET_END: - if (this->reset_()) { - this->set_state_(EPaperState::UPDATE); + if (this->reset()) { + this->set_state_(EPaperState::INITIALISE); } else { - this->set_state_(EPaperState::RESET_END); + this->set_state_(EPaperState::RESET_END, this->reset_duration_); } break; case EPaperState::UPDATE: this->do_update_(); // Calls ESPHome (current page) lambda - this->set_state_(EPaperState::INITIALISE); + if (this->x_high_ < this->x_low_ || this->y_high_ < this->y_low_) { + this->set_state_(EPaperState::IDLE); + return; + } + this->set_state_(EPaperState::RESET); break; case EPaperState::INITIALISE: this->initialise_(); @@ -190,6 +195,10 @@ void EPaperBase::process_state_() { if (!this->transfer_data()) { return; // Not done yet, come back next loop } + this->x_low_ = this->width_; + this->x_high_ = 0; + this->y_low_ = this->height_; + this->y_high_ = 0; this->set_state_(EPaperState::POWER_ON); break; case EPaperState::POWER_ON: @@ -197,7 +206,8 @@ void EPaperBase::process_state_() { this->set_state_(EPaperState::REFRESH_SCREEN); break; case EPaperState::REFRESH_SCREEN: - this->refresh_screen(); + this->refresh_screen(this->update_count_ != 0); + this->update_count_ = (this->update_count_ + 1) % this->full_update_every_; this->set_state_(EPaperState::POWER_OFF); break; case EPaperState::POWER_OFF: @@ -207,6 +217,7 @@ void EPaperBase::process_state_() { case EPaperState::DEEP_SLEEP: this->deep_sleep(); this->set_state_(EPaperState::IDLE); + ESP_LOGD(TAG, "Display update took %" PRIu32 " ms", millis() - this->update_start_time_); break; } } @@ -222,6 +233,9 @@ void EPaperBase::set_state_(EPaperState state, uint16_t delay) { } ESP_LOGV(TAG, "Enter state %s, delay %u, wait_for_idle=%s", this->epaper_state_to_string_(), delay, TRUEFALSE(this->waiting_for_idle_)); + if (state == EPaperState::IDLE) { + this->disable_loop(); + } } void EPaperBase::start_command_() { @@ -246,7 +260,7 @@ void EPaperBase::initialise_() { auto length = this->init_sequence_length_; while (index != length) { if (length - index < 2) { - this->mark_failed("Malformed init sequence"); + this->mark_failed(LOG_STR("Malformed init sequence")); return; } const uint8_t cmd = sequence[index++]; @@ -260,20 +274,73 @@ void EPaperBase::initialise_() { this->mark_failed(); return; } - ESP_LOGV(TAG, "Command %02X, length %d", cmd, num_args); this->cmd_data(cmd, sequence + index, num_args); index += num_args; } } } +/** + * Check and rotate coordinates based on the transform flags. + * @param x + * @param y + * @return false if the coordinates are out of bounds + */ +bool EPaperBase::rotate_coordinates_(int &x, int &y) const { + if (!this->get_clipping().inside(x, y)) + return false; + if (this->transform_ & SWAP_XY) + std::swap(x, y); + if (this->transform_ & MIRROR_X) + x = this->width_ - x - 1; + if (this->transform_ & MIRROR_Y) + y = this->height_ - y - 1; + if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) + return false; + return true; +} + +/** + * Default implementation for monochrome displays where 8 pixels are packed to a byte. + * @param x + * @param y + * @param color + */ +void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { + if (!rotate_coordinates_(x, y)) + return; + const size_t pixel_position = y * this->width_ + x; + const size_t byte_position = pixel_position / 8; + const uint8_t bit_position = pixel_position % 8; + const uint8_t pixel_bit = 0x80 >> bit_position; + const auto original = this->buffer_[byte_position]; + if ((color_to_bit(color) == 0)) { + this->buffer_[byte_position] = original & ~pixel_bit; + } else { + this->buffer_[byte_position] = original | pixel_bit; + } + this->x_low_ = clamp_at_most(this->x_low_, x); + this->x_high_ = clamp_at_least(this->x_high_, x + 1); + this->y_low_ = clamp_at_most(this->y_low_, y); + this->y_high_ = clamp_at_least(this->y_high_, y + 1); +} + void EPaperBase::dump_config() { LOG_DISPLAY("", "E-Paper SPI", this); ESP_LOGCONFIG(TAG, " Model: %s", this->name_); LOG_PIN(" Reset Pin: ", this->reset_pin_); LOG_PIN(" DC Pin: ", this->dc_pin_); LOG_PIN(" Busy Pin: ", this->busy_pin_); + LOG_PIN(" CS Pin: ", this->cs_); LOG_UPDATE_INTERVAL(this); + ESP_LOGCONFIG(TAG, + " SPI Data Rate: %uMHz\n" + " Full update every: %d\n" + " Swap X/Y: %s\n" + " Mirror X: %s\n" + " Mirror Y: %s", + (unsigned) (this->data_rate_ / 1000000), this->full_update_every_, YESNO(this->transform_ & SWAP_XY), + YESNO(this->transform_ & MIRROR_X), YESNO(this->transform_ & MIRROR_Y)); } } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 4745ec7339..544ea3e9ba 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -5,8 +5,6 @@ #include "esphome/components/split_buffer/split_buffer.h" #include "esphome/core/component.h" -#include - namespace esphome::epaper_spi { using namespace display; @@ -25,10 +23,16 @@ enum class EPaperState : uint8_t { DEEP_SLEEP, // deep sleep the display }; -static constexpr uint8_t MAX_TRANSFER_TIME = 10; // Transfer in 10ms blocks to allow the loop to run +static constexpr uint8_t NONE = 0; +static constexpr uint8_t MIRROR_X = 1; +static constexpr uint8_t MIRROR_Y = 2; +static constexpr uint8_t SWAP_XY = 4; + +static constexpr uint32_t MAX_TRANSFER_TIME = 10; // Transfer in 10ms blocks to allow the loop to run +static constexpr size_t MAX_TRANSFER_SIZE = 128; static constexpr uint8_t DELAY_FLAG = 0xFF; -class EPaperBase : public DisplayBuffer, +class EPaperBase : public Display, public spi::SPIDevice { public: @@ -45,6 +49,8 @@ class EPaperBase : public DisplayBuffer, void set_reset_pin(GPIOPin *reset) { this->reset_pin_ = reset; } void set_busy_pin(GPIOPin *busy) { this->busy_pin_ = busy; } void set_reset_duration(uint32_t reset_duration) { this->reset_duration_ = reset_duration; } + void set_transform(uint8_t transform) { this->transform_ = transform; } + void set_full_update_every(uint8_t full_update_every) { this->full_update_every_ = full_update_every; } void dump_config() override; void command(uint8_t value); @@ -60,20 +66,47 @@ class EPaperBase : public DisplayBuffer, DisplayType get_display_type() override { return this->display_type_; }; + // Default implementations for monochrome displays + static uint8_t color_to_bit(Color color) { + // It's always a shade of gray. Map to BLACK or WHITE. + // We split the luminance at a suitable point + if ((static_cast(color.r) + color.g + color.b) > 512) { + return 1; + } + return 0; + } + void fill(Color color) override { + auto pixel_color = color_to_bit(color) ? 0xFF : 0x00; + + // We store 8 pixels per byte + this->buffer_.fill(pixel_color); + this->x_high_ = this->width_; + this->y_high_ = this->height_; + this->x_low_ = 0; + this->y_low_ = 0; + } + + void clear() override { + // clear buffer to white, just like real paper. + this->fill(COLOR_ON); + } + protected: int get_height_internal() override { return this->height_; }; int get_width_internal() override { return this->width_; }; + int get_width() override { return this->transform_ & SWAP_XY ? this->height_ : this->width_; } + int get_height() override { return this->transform_ & SWAP_XY ? this->width_ : this->height_; } + void draw_pixel_at(int x, int y, Color color) override; void process_state_(); const char *epaper_state_to_string_(); bool is_idle_() const; void setup_pins_() const; - bool reset_() const; + virtual bool reset(); void initialise_(); void wait_for_idle_(bool should_wait); bool init_buffer_(size_t buffer_length); - - virtual int get_width_controller() { return this->get_width_internal(); }; + bool rotate_coordinates_(int &x, int &y) const; /** * Methods that must be implemented by concrete classes to control the display @@ -86,7 +119,7 @@ class EPaperBase : public DisplayBuffer, /** * Refresh the screen after data transfer */ - virtual void refresh_screen() = 0; + virtual void refresh_screen(bool partial) = 0; /** * Power the display on @@ -118,24 +151,31 @@ class EPaperBase : public DisplayBuffer, DisplayType display_type_; size_t buffer_length_{}; - size_t current_data_index_{0}; // used by data transfer to track progress - uint32_t reset_duration_{200}; -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - uint32_t transfer_start_time_{}; - uint32_t waiting_for_idle_last_print_{0}; - uint32_t waiting_for_idle_start_{0}; -#endif - + size_t current_data_index_{}; // used by data transfer to track progress + split_buffer::SplitBuffer buffer_{}; GPIOPin *dc_pin_{}; GPIOPin *busy_pin_{}; GPIOPin *reset_pin_{}; + bool waiting_for_idle_{}; + uint32_t delay_until_{}; + uint8_t transform_{}; + uint8_t update_count_{}; + // these values represent the bounds of the updated buffer. Note that x_high and y_high + // point to the pixel past the last one updated, i.e. may range up to width/height. + uint16_t x_low_{}, y_low_{}, x_high_{}, y_high_{}; - bool waiting_for_idle_{false}; - uint32_t delay_until_{0}; - - split_buffer::SplitBuffer buffer_; +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE + uint32_t waiting_for_idle_last_print_{}; + uint32_t waiting_for_idle_start_{}; +#endif +#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG + uint32_t update_start_time_{}; +#endif + // properties with specific initialisers go last EPaperState state_{EPaperState::IDLE}; + uint32_t reset_duration_{10}; + uint8_t full_update_every_{1}; }; } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp index 8e4cbdde2a..d0e68595d0 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.cpp @@ -6,7 +6,6 @@ namespace esphome::epaper_spi { static constexpr const char *const TAG = "epaper_spi.6c"; -static constexpr size_t MAX_TRANSFER_SIZE = 128; static constexpr unsigned char GRAY_THRESHOLD = 50; enum E6Color { @@ -75,24 +74,24 @@ static uint8_t color_to_hex(Color color) { } void EPaperSpectraE6::power_on() { - ESP_LOGD(TAG, "Power on"); + ESP_LOGV(TAG, "Power on"); this->command(0x04); } void EPaperSpectraE6::power_off() { - ESP_LOGD(TAG, "Power off"); + ESP_LOGV(TAG, "Power off"); this->command(0x02); this->data(0x00); } -void EPaperSpectraE6::refresh_screen() { - ESP_LOGD(TAG, "Refresh"); +void EPaperSpectraE6::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh"); this->command(0x12); this->data(0x00); } void EPaperSpectraE6::deep_sleep() { - ESP_LOGD(TAG, "Deep sleep"); + ESP_LOGV(TAG, "Deep sleep"); this->command(0x07); this->data(0xA5); } @@ -109,12 +108,11 @@ void EPaperSpectraE6::clear() { this->fill(COLOR_ON); } -void HOT EPaperSpectraE6::draw_absolute_pixel_internal(int x, int y, Color color) { - if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) +void HOT EPaperSpectraE6::draw_pixel_at(int x, int y, Color color) { + if (!this->rotate_coordinates_(x, y)) return; - auto pixel_bits = color_to_hex(color); - uint32_t pixel_position = x + y * this->get_width_controller(); + uint32_t pixel_position = x + y * this->get_width_internal(); uint32_t byte_position = pixel_position / 2; auto original = this->buffer_[byte_position]; if ((pixel_position & 1) != 0) { @@ -128,10 +126,6 @@ bool HOT EPaperSpectraE6::transfer_data() { const uint32_t start_time = App.get_loop_component_start_time(); const size_t buffer_length = this->buffer_length_; if (this->current_data_index_ == 0) { -#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE - this->transfer_start_time_ = millis(); -#endif - ESP_LOGV(TAG, "Start sending data at %ums", (unsigned) millis()); this->command(0x10); } @@ -160,7 +154,6 @@ bool HOT EPaperSpectraE6::transfer_data() { this->end_data_(); } this->current_data_index_ = 0; - ESP_LOGV(TAG, "Sent data in %" PRIu32 " ms", millis() - this->transfer_start_time_); return true; } } // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h index 48356ad74b..b8dbf0b0c5 100644 --- a/esphome/components/epaper_spi/epaper_spi_spectra_e6.h +++ b/esphome/components/epaper_spi/epaper_spi_spectra_e6.h @@ -16,11 +16,11 @@ class EPaperSpectraE6 : public EPaperBase { void clear() override; protected: - void refresh_screen() override; + void refresh_screen(bool partial) override; void power_on() override; void power_off() override; void deep_sleep() override; - void draw_absolute_pixel_internal(int x, int y, Color color) override; + void draw_pixel_at(int x, int y, Color color) override; bool transfer_data() override; }; diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp new file mode 100644 index 0000000000..e4f04657ad --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.cpp @@ -0,0 +1,86 @@ +#include "epaper_spi_ssd1677.h" + +#include + +#include "esphome/core/log.h" + +namespace esphome::epaper_spi { +static constexpr const char *const TAG = "epaper_spi.ssd1677"; + +void EPaperSSD1677::refresh_screen(bool partial) { + ESP_LOGV(TAG, "Refresh screen"); + this->command(0x22); + this->data(partial ? 0xFF : 0xF7); + this->command(0x20); +} + +void EPaperSSD1677::deep_sleep() { + ESP_LOGV(TAG, "Deep sleep"); + this->command(0x10); +} + +bool EPaperSSD1677::reset() { + if (EPaperBase::reset()) { + this->command(0x12); + return true; + } + return false; +} + +bool HOT EPaperSSD1677::transfer_data() { + auto start_time = millis(); + if (this->current_data_index_ == 0) { + uint8_t data[4]{}; + // round to byte boundaries + this->x_low_ &= ~7; + this->y_low_ &= ~7; + this->x_high_ += 7; + this->x_high_ &= ~7; + this->y_high_ += 7; + this->y_high_ &= ~7; + data[0] = this->x_low_; + data[1] = this->x_low_ / 256; + data[2] = this->x_high_ - 1; + data[3] = (this->x_high_ - 1) / 256; + cmd_data(0x4E, data, 2); + cmd_data(0x44, data, sizeof(data)); + data[0] = this->y_low_; + data[1] = this->y_low_ / 256; + data[2] = this->y_high_ - 1; + data[3] = (this->y_high_ - 1) / 256; + cmd_data(0x4F, data, 2); + this->cmd_data(0x45, data, sizeof(data)); + // for monochrome, we still need to clear the red data buffer at least once to prevent it + // causing dirty pixels after partial refresh. + this->command(this->send_red_ ? 0x26 : 0x24); + this->current_data_index_ = this->y_low_; // actually current line + } + size_t row_length = (this->x_high_ - this->x_low_) / 8; + FixedVector bytes_to_send{}; + bytes_to_send.init(row_length); + ESP_LOGV(TAG, "Writing bytes at line %zu at %ums", this->current_data_index_, (unsigned) millis()); + this->start_data_(); + while (this->current_data_index_ != this->y_high_) { + size_t data_idx = (this->current_data_index_ * this->width_ + this->x_low_) / 8; + for (size_t i = 0; i != row_length; i++) { + bytes_to_send[i] = this->send_red_ ? 0 : this->buffer_[data_idx++]; + } + ++this->current_data_index_; + this->write_array(&bytes_to_send.front(), row_length); // NOLINT + if (millis() - start_time > MAX_TRANSFER_TIME) { + // Let the main loop run and come back next loop + this->end_data_(); + return false; + } + } + + this->end_data_(); + this->current_data_index_ = 0; + if (this->send_red_) { + this->send_red_ = false; + return false; + } + return true; +} + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/epaper_spi_ssd1677.h b/esphome/components/epaper_spi/epaper_spi_ssd1677.h new file mode 100644 index 0000000000..47584d24c0 --- /dev/null +++ b/esphome/components/epaper_spi/epaper_spi_ssd1677.h @@ -0,0 +1,25 @@ +#pragma once + +#include "epaper_spi.h" + +namespace esphome::epaper_spi { + +class EPaperSSD1677 : public EPaperBase { + public: + EPaperSSD1677(const char *name, uint16_t width, uint16_t height, const uint8_t *init_sequence, + size_t init_sequence_length) + : EPaperBase(name, width, height, init_sequence, init_sequence_length, DISPLAY_TYPE_BINARY) { + this->buffer_length_ = width * height / 8; // 8 pixels per byte + } + + protected: + void refresh_screen(bool partial) override; + void power_on() override {} + void power_off() override{}; + void deep_sleep() override; + bool reset() override; + bool transfer_data() override; + bool send_red_{true}; +}; + +} // namespace esphome::epaper_spi diff --git a/esphome/components/epaper_spi/models/spectra_e6.py b/esphome/components/epaper_spi/models/spectra_e6.py index 9f0b673d69..42a5a7da72 100644 --- a/esphome/components/epaper_spi/models/spectra_e6.py +++ b/esphome/components/epaper_spi/models/spectra_e6.py @@ -32,11 +32,15 @@ class SpectraE6(EpaperModel): spectra_e6 = SpectraE6("spectra-e6") -spectra_e6.extend( - "Seeed-reTerminal-E1002", +spectra_e6_7p3 = spectra_e6.extend( + "7.3in-Spectra-E6", width=800, height=480, data_rate="20MHz", +) + +spectra_e6_7p3.extend( + "Seeed-reTerminal-E1002", cs_pin=10, dc_pin=11, reset_pin=12, diff --git a/esphome/components/epaper_spi/models/ssd1677.py b/esphome/components/epaper_spi/models/ssd1677.py new file mode 100644 index 0000000000..3eb53d650e --- /dev/null +++ b/esphome/components/epaper_spi/models/ssd1677.py @@ -0,0 +1,42 @@ +from esphome.const import CONF_DATA_RATE + +from . import EpaperModel + + +class SSD1677(EpaperModel): + def __init__(self, name, class_name="EPaperSSD1677", **kwargs): + if CONF_DATA_RATE not in kwargs: + kwargs[CONF_DATA_RATE] = "20MHz" + super().__init__(name, class_name, **kwargs) + + # fmt: off + def get_init_sequence(self, config: dict): + width, _height = self.get_dimensions(config) + return ( + (0x18, 0x80), # Select internal Temp sensor + (0x0C, 0xAE, 0xC7, 0xC3, 0xC0, 0x80), # inrush current level 2 + (0x01, (width - 1) % 256, (width - 1) // 256, 0x02), # Set column gate limit + (0x3C, 0x01), # Set border waveform + (0x11, 3), # Set transform + ) + + +ssd1677 = SSD1677("ssd1677") + +ssd1677.extend( + "seeed-ee04-mono-4.26", + width=800, + height=480, + mirror_x=True, + cs_pin=44, + dc_pin=10, + reset_pin=38, + busy_pin={ + "number": 4, + "inverted": False, + "mode": { + "input": True, + "pulldown": True, + }, + }, +) diff --git a/esphome/components/es8388/es8388.cpp b/esphome/components/es8388/es8388.cpp index 69c16a9615..5abe7a5e5f 100644 --- a/esphome/components/es8388/es8388.cpp +++ b/esphome/components/es8388/es8388.cpp @@ -225,7 +225,7 @@ bool ES8388::set_dac_output(DacOutputLine line) { optional ES8388::get_dac_power() { uint8_t dac_power; if (!this->read_byte(ES8388_DACPOWER, &dac_power)) { - this->status_momentary_warning("Failed to read ES8388_DACPOWER"); + this->status_momentary_warning("dacpower_read"); return {}; } switch (dac_power) { @@ -268,7 +268,7 @@ bool ES8388::set_adc_input_mic(AdcInputMicLine line) { optional ES8388::get_mic_input() { uint8_t mic_input; if (!this->read_byte(ES8388_ADCCONTROL2, &mic_input)) { - this->status_momentary_warning("Failed to read ES8388_ADCCONTROL2"); + this->status_momentary_warning("adccontrol2_read"); return {}; } switch (mic_input) { diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 6981662d77..14db25fd46 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -37,6 +37,7 @@ from esphome.const import ( __version__, ) from esphome.core import CORE, HexInt, TimePeriod +from esphome.coroutine import CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.helpers import copy_file_if_changed, write_file_if_changed from esphome.types import ConfigType @@ -262,15 +263,32 @@ def add_idf_component( "deprecated and will be removed in ESPHome 2026.1. If you are seeing this, report " "an issue to the external_component author and ask them to update it." ) + components_registry = CORE.data[KEY_ESP32][KEY_COMPONENTS] if components: for comp in components: - CORE.data[KEY_ESP32][KEY_COMPONENTS][comp] = { + existing = components_registry.get(comp) + if existing and existing.get(KEY_REF) != ref: + _LOGGER.warning( + "IDF component %s version conflict %s replaced by %s", + comp, + existing.get(KEY_REF), + ref, + ) + components_registry[comp] = { KEY_REPO: repo, KEY_REF: ref, KEY_PATH: f"{path}/{comp}" if path else comp, } else: - CORE.data[KEY_ESP32][KEY_COMPONENTS][name] = { + existing = components_registry.get(name) + if existing and existing.get(KEY_REF) != ref: + _LOGGER.warning( + "IDF component %s version conflict %s replaced by %s", + name, + existing.get(KEY_REF), + ref, + ) + components_registry[name] = { KEY_REPO: repo, KEY_REF: ref, KEY_PATH: path, @@ -334,12 +352,14 @@ def _is_framework_url(source: str) -> str: # - https://github.com/espressif/arduino-esp32/releases ARDUINO_FRAMEWORK_VERSION_LOOKUP = { "recommended": cv.Version(3, 3, 2), - "latest": cv.Version(3, 3, 2), - "dev": cv.Version(3, 3, 2), + "latest": cv.Version(3, 3, 4), + "dev": cv.Version(3, 3, 4), } ARDUINO_PLATFORM_VERSION_LOOKUP = { - cv.Version(3, 3, 2): cv.Version(55, 3, 31, "1"), - cv.Version(3, 3, 1): cv.Version(55, 3, 31, "1"), + cv.Version(3, 3, 4): cv.Version(55, 3, 31, "2"), + cv.Version(3, 3, 3): cv.Version(55, 3, 31, "2"), + cv.Version(3, 3, 2): cv.Version(55, 3, 31, "2"), + cv.Version(3, 3, 1): cv.Version(55, 3, 31, "2"), cv.Version(3, 3, 0): cv.Version(55, 3, 30, "2"), cv.Version(3, 2, 1): cv.Version(54, 3, 21, "2"), cv.Version(3, 2, 0): cv.Version(54, 3, 20), @@ -357,8 +377,8 @@ ESP_IDF_FRAMEWORK_VERSION_LOOKUP = { "dev": cv.Version(5, 5, 1), } ESP_IDF_PLATFORM_VERSION_LOOKUP = { - cv.Version(5, 5, 1): cv.Version(55, 3, 31, "1"), - cv.Version(5, 5, 0): cv.Version(55, 3, 31, "1"), + cv.Version(5, 5, 1): cv.Version(55, 3, 31, "2"), + cv.Version(5, 5, 0): cv.Version(55, 3, 31, "2"), cv.Version(5, 4, 3): cv.Version(55, 3, 32), cv.Version(5, 4, 2): cv.Version(54, 3, 21, "2"), cv.Version(5, 4, 1): cv.Version(54, 3, 21, "2"), @@ -373,14 +393,15 @@ ESP_IDF_PLATFORM_VERSION_LOOKUP = { # The platform-espressif32 version # - https://github.com/pioarduino/platform-espressif32/releases PLATFORM_VERSION_LOOKUP = { - "recommended": cv.Version(55, 3, 31, "1"), - "latest": cv.Version(55, 3, 31, "1"), - "dev": cv.Version(55, 3, 31, "1"), + "recommended": cv.Version(55, 3, 31, "2"), + "latest": cv.Version(55, 3, 31, "2"), + "dev": cv.Version(55, 3, 31, "2"), } -def _check_versions(value): - value = value.copy() +def _check_versions(config): + config = config.copy() + value = config[CONF_FRAMEWORK] if value[CONF_VERSION] in PLATFORM_VERSION_LOOKUP: if CONF_SOURCE in value or CONF_PLATFORM_VERSION in value: @@ -445,7 +466,7 @@ def _check_versions(value): "If there are connectivity or build issues please remove the manual version." ) - return value + return config def _parse_platform_version(value): @@ -495,6 +516,8 @@ def final_validate(config): from esphome.components.psram import DOMAIN as PSRAM_DOMAIN errs = [] + conf_fw = config[CONF_FRAMEWORK] + advanced = conf_fw[CONF_ADVANCED] full_config = fv.full_config.get() if pio_options := full_config[CONF_ESPHOME].get(CONF_PLATFORMIO_OPTIONS): pio_flash_size_key = "board_upload.flash_size" @@ -511,22 +534,14 @@ def final_validate(config): f"Please specify {CONF_FLASH_SIZE} within esp32 configuration only" ) ) - if ( - config[CONF_VARIANT] != VARIANT_ESP32 - and CONF_ADVANCED in (conf_fw := config[CONF_FRAMEWORK]) - and CONF_IGNORE_EFUSE_MAC_CRC in conf_fw[CONF_ADVANCED] - ): + if config[CONF_VARIANT] != VARIANT_ESP32 and advanced[CONF_IGNORE_EFUSE_MAC_CRC]: errs.append( cv.Invalid( f"'{CONF_IGNORE_EFUSE_MAC_CRC}' is not supported on {config[CONF_VARIANT]}", path=[CONF_FRAMEWORK, CONF_ADVANCED, CONF_IGNORE_EFUSE_MAC_CRC], ) ) - if ( - config.get(CONF_FRAMEWORK, {}) - .get(CONF_ADVANCED, {}) - .get(CONF_EXECUTE_FROM_PSRAM) - ): + if advanced[CONF_EXECUTE_FROM_PSRAM]: if config[CONF_VARIANT] != VARIANT_ESP32S3: errs.append( cv.Invalid( @@ -542,6 +557,17 @@ def final_validate(config): ) ) + if ( + config[CONF_FLASH_SIZE] == "32MB" + and "ota" in full_config + and not advanced[CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES] + ): + errs.append( + cv.Invalid( + f"OTA with 32MB flash requires '{CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES}' to be set in the '{CONF_ADVANCED}' section of the esp32 configuration", + path=[CONF_FLASH_SIZE], + ) + ) if errs: raise cv.MultipleInvalid(errs) @@ -558,6 +584,8 @@ CONF_DISABLE_LIBC_LOCKS_IN_IRAM = "disable_libc_locks_in_iram" CONF_DISABLE_VFS_SUPPORT_TERMIOS = "disable_vfs_support_termios" CONF_DISABLE_VFS_SUPPORT_SELECT = "disable_vfs_support_select" CONF_DISABLE_VFS_SUPPORT_DIR = "disable_vfs_support_dir" +CONF_FREERTOS_IN_IRAM = "freertos_in_iram" +CONF_RINGBUF_IN_IRAM = "ringbuf_in_iram" CONF_LOOP_TASK_STACK_SIZE = "loop_task_stack_size" # VFS requirement tracking @@ -584,6 +612,14 @@ def require_vfs_dir() -> None: CORE.data[KEY_VFS_DIR_REQUIRED] = True +def _parse_idf_component(value: str) -> ConfigType: + """Parse IDF component shorthand syntax like 'owner/component^version'""" + if "^" not in value: + raise cv.Invalid(f"Invalid IDF component shorthand '{value}'") + name, ref = value.split("^", 1) + return {CONF_NAME: name, CONF_REF: ref} + + def _validate_idf_component(config: ConfigType) -> ConfigType: """Validate IDF component config and warn about deprecated options.""" if CONF_REFRESH in config: @@ -596,72 +632,65 @@ def _validate_idf_component(config: ConfigType) -> ConfigType: FRAMEWORK_ESP_IDF = "esp-idf" FRAMEWORK_ARDUINO = "arduino" -FRAMEWORK_SCHEMA = cv.All( - cv.Schema( - { - cv.Optional(CONF_TYPE, default=FRAMEWORK_ARDUINO): cv.one_of( - FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO - ), - cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, - cv.Optional(CONF_RELEASE): cv.string_strict, - cv.Optional(CONF_SOURCE): cv.string_strict, - cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, - cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { - cv.string_strict: cv.string_strict - }, - cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( - *LOG_LEVELS_IDF, upper=True - ), - cv.Optional(CONF_ADVANCED, default={}): cv.Schema( - { - cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( - *ASSERTION_LEVELS, upper=True - ), - cv.Optional(CONF_COMPILER_OPTIMIZATION, default="SIZE"): cv.one_of( - *COMPILER_OPTIMIZATIONS, upper=True - ), - cv.Optional(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): cv.boolean, - cv.Optional(CONF_ENABLE_LWIP_ASSERT, default=True): cv.boolean, - cv.Optional( - CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False - ): cv.boolean, - cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC): cv.boolean, - # DHCP server is needed for WiFi AP mode. When WiFi component is used, - # it will handle disabling DHCP server when AP is not configured. - # Default to false (disabled) when WiFi is not used. - cv.OnlyWithout( - CONF_ENABLE_LWIP_DHCP_SERVER, "wifi", default=False - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_MDNS_QUERIES, default=True - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_BRIDGE_INTERFACE, default=False - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, default=True - ): cv.boolean, - cv.Optional( - CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True - ): cv.boolean, - cv.Optional( - CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True - ): cv.boolean, - cv.Optional( - CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True - ): cv.boolean, - cv.Optional( - CONF_DISABLE_VFS_SUPPORT_SELECT, default=True - ): cv.boolean, - cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, - cv.Optional(CONF_EXECUTE_FROM_PSRAM): cv.boolean, - cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( - min=8192, max=32768 - ), - } - ), - cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( - cv.All( +FRAMEWORK_SCHEMA = cv.Schema( + { + cv.Optional(CONF_TYPE): cv.one_of(FRAMEWORK_ESP_IDF, FRAMEWORK_ARDUINO), + cv.Optional(CONF_VERSION, default="recommended"): cv.string_strict, + cv.Optional(CONF_RELEASE): cv.string_strict, + cv.Optional(CONF_SOURCE): cv.string_strict, + cv.Optional(CONF_PLATFORM_VERSION): _parse_platform_version, + cv.Optional(CONF_SDKCONFIG_OPTIONS, default={}): { + cv.string_strict: cv.string_strict + }, + cv.Optional(CONF_LOG_LEVEL, default="ERROR"): cv.one_of( + *LOG_LEVELS_IDF, upper=True + ), + cv.Optional(CONF_ADVANCED, default={}): cv.Schema( + { + cv.Optional(CONF_ASSERTION_LEVEL): cv.one_of( + *ASSERTION_LEVELS, upper=True + ), + cv.Optional(CONF_COMPILER_OPTIMIZATION, default="SIZE"): cv.one_of( + *COMPILER_OPTIMIZATIONS, upper=True + ), + cv.Optional( + CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES, default=False + ): cv.boolean, + cv.Optional(CONF_ENABLE_LWIP_ASSERT, default=True): cv.boolean, + cv.Optional(CONF_IGNORE_EFUSE_CUSTOM_MAC, default=False): cv.boolean, + cv.Optional(CONF_IGNORE_EFUSE_MAC_CRC, default=False): cv.boolean, + # DHCP server is needed for WiFi AP mode. When WiFi component is used, + # it will handle disabling DHCP server when AP is not configured. + # Default to false (disabled) when WiFi is not used. + cv.OnlyWithout( + CONF_ENABLE_LWIP_DHCP_SERVER, "wifi", default=False + ): cv.boolean, + cv.Optional(CONF_ENABLE_LWIP_MDNS_QUERIES, default=True): cv.boolean, + cv.Optional( + CONF_ENABLE_LWIP_BRIDGE_INTERFACE, default=False + ): cv.boolean, + cv.Optional( + CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, default=True + ): cv.boolean, + cv.Optional( + CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, default=True + ): cv.boolean, + cv.Optional(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_VFS_SUPPORT_TERMIOS, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_VFS_SUPPORT_SELECT, default=True): cv.boolean, + cv.Optional(CONF_DISABLE_VFS_SUPPORT_DIR, default=True): cv.boolean, + cv.Optional(CONF_FREERTOS_IN_IRAM, default=False): cv.boolean, + cv.Optional(CONF_RINGBUF_IN_IRAM, default=False): cv.boolean, + cv.Optional(CONF_EXECUTE_FROM_PSRAM, default=False): cv.boolean, + cv.Optional(CONF_LOOP_TASK_STACK_SIZE, default=8192): cv.int_range( + min=8192, max=32768 + ), + } + ), + cv.Optional(CONF_COMPONENTS, default=[]): cv.ensure_list( + cv.All( + cv.Any( + cv.All(cv.string_strict, _parse_idf_component), cv.Schema( { cv.Required(CONF_NAME): cv.string_strict, @@ -673,12 +702,11 @@ FRAMEWORK_SCHEMA = cv.All( ), } ), - _validate_idf_component, - ) - ), - } - ), - _check_versions, + ), + _validate_idf_component, + ) + ), + } ) @@ -741,11 +769,11 @@ def _show_framework_migration_message(name: str, variant: str) -> None: def _set_default_framework(config): + config = config.copy() if CONF_FRAMEWORK not in config: - config = config.copy() - - variant = config[CONF_VARIANT] config[CONF_FRAMEWORK] = FRAMEWORK_SCHEMA({}) + if CONF_TYPE not in config[CONF_FRAMEWORK]: + variant = config[CONF_VARIANT] if variant in ARDUINO_ALLOWED_VARIANTS: config[CONF_FRAMEWORK][CONF_TYPE] = FRAMEWORK_ARDUINO _show_framework_migration_message( @@ -785,6 +813,7 @@ CONFIG_SCHEMA = cv.All( ), _detect_variant, _set_default_framework, + _check_versions, set_core_data, cv.has_at_least_one_key(CONF_BOARD, CONF_VARIANT), ) @@ -803,9 +832,7 @@ def _configure_lwip_max_sockets(conf: dict) -> None: from esphome.components.socket import KEY_SOCKET_CONSUMERS # Check if user manually specified CONFIG_LWIP_MAX_SOCKETS - user_max_sockets = conf.get(CONF_SDKCONFIG_OPTIONS, {}).get( - "CONFIG_LWIP_MAX_SOCKETS" - ) + user_max_sockets = conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_LWIP_MAX_SOCKETS") socket_consumers: dict[str, int] = CORE.data.get(KEY_SOCKET_CONSUMERS, {}) total_sockets = sum(socket_consumers.values()) @@ -859,9 +886,25 @@ def _configure_lwip_max_sockets(conf: dict) -> None: add_idf_sdkconfig_option("CONFIG_LWIP_MAX_SOCKETS", max_sockets) +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_yaml_idf_components(components: list[ConfigType]): + """Add IDF components from YAML config with final priority to override code-added components.""" + for component in components: + add_idf_component( + name=component[CONF_NAME], + repo=component.get(CONF_SOURCE), + ref=component.get(CONF_REF), + path=component.get(CONF_PATH), + ) + + async def to_code(config): cg.add_platformio_option("board", config[CONF_BOARD]) cg.add_platformio_option("board_upload.flash_size", config[CONF_FLASH_SIZE]) + cg.add_platformio_option( + "board_upload.maximum_size", + int(config[CONF_FLASH_SIZE].removesuffix("MB")) * 1024 * 1024, + ) cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) @@ -891,6 +934,12 @@ async def to_code(config): CORE.relative_internal_path(".espressif") ) + add_extra_script( + "pre", + "pre_build.py", + Path(__file__).parent / "pre_build.py.script", + ) + add_extra_script( "post", "post_build.py", @@ -939,6 +988,12 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_MBEDTLS_CERTIFICATE_BUNDLE", True) add_idf_sdkconfig_option("CONFIG_ESP_PHY_REDUCE_TX_POWER", True) + # ESP32-S2 Arduino: Disable USB Serial on boot to avoid TinyUSB dependency + if get_esp32_variant() == VARIANT_ESP32S2: + cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=1") + cg.add_build_unflag("-DARDUINO_USB_CDC_ON_BOOT=0") + cg.add_build_flag("-DARDUINO_USB_CDC_ON_BOOT=0") + cg.add_build_flag("-Wno-nonnull-compare") add_idf_sdkconfig_option(f"CONFIG_IDF_TARGET_{variant}", True) @@ -952,6 +1007,33 @@ async def to_code(config): # Increase freertos tick speed from 100Hz to 1kHz so that delay() resolution is 1ms add_idf_sdkconfig_option("CONFIG_FREERTOS_HZ", 1000) + # Place non-ISR FreeRTOS functions into flash instead of IRAM + # This saves up to 8KB of IRAM. ISR-safe functions (FromISR variants) stay in IRAM. + # In ESP-IDF 6.0 this becomes the default and CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH + # is removed (replaced by CONFIG_FREERTOS_IN_IRAM to restore old behavior). + # We enable this now to match IDF 6.0 behavior and catch any issues early. + # Users can set freertos_in_iram: true as an escape hatch if they encounter problems + # with code that incorrectly calls FreeRTOS functions from ISRs with cache disabled. + if conf[CONF_ADVANCED][CONF_FREERTOS_IN_IRAM]: + # IDF 5.x: don't set the flash option (keeps functions in IRAM) + # IDF 6.0+: will need CONFIG_FREERTOS_IN_IRAM=y to restore IRAM placement + add_idf_sdkconfig_option("CONFIG_FREERTOS_IN_IRAM", True) + else: + # IDF 5.x: explicitly place functions in flash + # IDF 6.0+: this is the default, option no longer exists + add_idf_sdkconfig_option("CONFIG_FREERTOS_PLACE_FUNCTIONS_INTO_FLASH", True) + + # Place ring buffer functions into flash instead of IRAM by default + # This saves IRAM. In ESP-IDF 6.0 flash placement becomes the default. + # Users can set ringbuf_in_iram: true as an escape hatch if they encounter issues. + if conf[CONF_ADVANCED][CONF_RINGBUF_IN_IRAM]: + # User requests ring buffer in IRAM + # IDF 6.0+: will need CONFIG_RINGBUF_PLACE_ISR_FUNCTIONS_INTO_FLASH=n + add_idf_sdkconfig_option("CONFIG_RINGBUF_PLACE_ISR_FUNCTIONS_INTO_FLASH", False) + else: + # Place in flash to save IRAM (default) + add_idf_sdkconfig_option("CONFIG_RINGBUF_PLACE_FUNCTIONS_INTO_FLASH", True) + # Setup watchdog add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT", True) add_idf_sdkconfig_option("CONFIG_ESP_TASK_WDT_PANIC", True) @@ -975,23 +1057,18 @@ async def to_code(config): # WiFi component handles its own optimization when AP mode is not used # When using Arduino with Ethernet, DHCP server functions must be available # for the Network library to compile, even if not actively used - if ( - CONF_ENABLE_LWIP_DHCP_SERVER in advanced - and not advanced[CONF_ENABLE_LWIP_DHCP_SERVER] - and not ( - conf[CONF_TYPE] == FRAMEWORK_ARDUINO - and "ethernet" in CORE.loaded_integrations - ) + if advanced.get(CONF_ENABLE_LWIP_DHCP_SERVER) is False and not ( + conf[CONF_TYPE] == FRAMEWORK_ARDUINO and "ethernet" in CORE.loaded_integrations ): add_idf_sdkconfig_option("CONFIG_LWIP_DHCPS", False) - if not advanced.get(CONF_ENABLE_LWIP_MDNS_QUERIES, True): + if not advanced[CONF_ENABLE_LWIP_MDNS_QUERIES]: add_idf_sdkconfig_option("CONFIG_LWIP_DNS_SUPPORT_MDNS_QUERIES", False) - if not advanced.get(CONF_ENABLE_LWIP_BRIDGE_INTERFACE, False): + if not advanced[CONF_ENABLE_LWIP_BRIDGE_INTERFACE]: add_idf_sdkconfig_option("CONFIG_LWIP_BRIDGEIF_MAX_PORTS", 0) _configure_lwip_max_sockets(conf) - if advanced.get(CONF_EXECUTE_FROM_PSRAM, False): + if advanced[CONF_EXECUTE_FROM_PSRAM]: add_idf_sdkconfig_option("CONFIG_SPIRAM_FETCH_INSTRUCTIONS", True) add_idf_sdkconfig_option("CONFIG_SPIRAM_RODATA", True) @@ -1002,23 +1079,22 @@ async def to_code(config): # - select() on 4 sockets: ~190μs (Arduino/core locking) vs ~235μs (ESP-IDF default) # - Up to 200% slower under load when all operations queue through tcpip_thread # Enabling this makes ESP-IDF socket performance match Arduino framework. - if advanced.get(CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING, True): + if advanced[CONF_ENABLE_LWIP_TCPIP_CORE_LOCKING]: add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_CORE_LOCKING", True) - if advanced.get(CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY, True): + if advanced[CONF_ENABLE_LWIP_CHECK_THREAD_SAFETY]: add_idf_sdkconfig_option("CONFIG_LWIP_CHECK_THREAD_SAFETY", True) # Disable placing libc locks in IRAM to save RAM # This is safe for ESPHome since no IRAM ISRs (interrupts that run while cache is disabled) # use libc lock APIs. Saves approximately 1.3KB (1,356 bytes) of IRAM. - if advanced.get(CONF_DISABLE_LIBC_LOCKS_IN_IRAM, True): + if advanced[CONF_DISABLE_LIBC_LOCKS_IN_IRAM]: add_idf_sdkconfig_option("CONFIG_LIBC_LOCKS_PLACE_IN_IRAM", False) # Disable VFS support for termios (terminal I/O functions) # ESPHome doesn't use termios functions on ESP32 (only used in host UART driver). # Saves approximately 1.8KB of flash when disabled (default). add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_TERMIOS", - not advanced.get(CONF_DISABLE_VFS_SUPPORT_TERMIOS, True), + "CONFIG_VFS_SUPPORT_TERMIOS", not advanced[CONF_DISABLE_VFS_SUPPORT_TERMIOS] ) # Disable VFS support for select() with file descriptors @@ -1032,8 +1108,7 @@ async def to_code(config): else: # No component needs it - allow user to control (default: disabled) add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_SELECT", - not advanced.get(CONF_DISABLE_VFS_SUPPORT_SELECT, True), + "CONFIG_VFS_SUPPORT_SELECT", not advanced[CONF_DISABLE_VFS_SUPPORT_SELECT] ) # Disable VFS support for directory functions (opendir, readdir, mkdir, etc.) @@ -1046,8 +1121,7 @@ async def to_code(config): else: # No component needs it - allow user to control (default: disabled) add_idf_sdkconfig_option( - "CONFIG_VFS_SUPPORT_DIR", - not advanced.get(CONF_DISABLE_VFS_SUPPORT_DIR, True), + "CONFIG_VFS_SUPPORT_DIR", not advanced[CONF_DISABLE_VFS_SUPPORT_DIR] ) cg.add_platformio_option("board_build.partitions", "partitions.csv") @@ -1061,7 +1135,7 @@ async def to_code(config): add_idf_sdkconfig_option(flag, assertion_level == key) add_idf_sdkconfig_option("CONFIG_COMPILER_OPTIMIZATION_DEFAULT", False) - compiler_optimization = advanced.get(CONF_COMPILER_OPTIMIZATION) + compiler_optimization = advanced[CONF_COMPILER_OPTIMIZATION] for key, flag in COMPILER_OPTIMIZATIONS.items(): add_idf_sdkconfig_option(flag, compiler_optimization == key) @@ -1070,18 +1144,20 @@ async def to_code(config): conf[CONF_ADVANCED][CONF_ENABLE_LWIP_ASSERT], ) - if advanced.get(CONF_IGNORE_EFUSE_MAC_CRC): + if advanced[CONF_IGNORE_EFUSE_MAC_CRC]: add_idf_sdkconfig_option("CONFIG_ESP_MAC_IGNORE_MAC_CRC_ERROR", True) add_idf_sdkconfig_option("CONFIG_ESP_PHY_CALIBRATION_AND_DATA_STORAGE", False) - if advanced.get(CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES): + if advanced[CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES]: _LOGGER.warning( "Using experimental features in ESP-IDF may result in unexpected failures." ) add_idf_sdkconfig_option("CONFIG_IDF_EXPERIMENTAL_FEATURES", True) + if config[CONF_FLASH_SIZE] == "32MB": + add_idf_sdkconfig_option( + "CONFIG_BOOTLOADER_CACHE_32BIT_ADDR_QUAD_FLASH", True + ) - cg.add_define( - "ESPHOME_LOOP_TASK_STACK_SIZE", advanced.get(CONF_LOOP_TASK_STACK_SIZE) - ) + cg.add_define("ESPHOME_LOOP_TASK_STACK_SIZE", advanced[CONF_LOOP_TASK_STACK_SIZE]) cg.add_define( "USE_ESP_IDF_VERSION_CODE", @@ -1095,13 +1171,10 @@ async def to_code(config): for name, value in conf[CONF_SDKCONFIG_OPTIONS].items(): add_idf_sdkconfig_option(name, RawSdkconfigValue(value)) - for component in conf[CONF_COMPONENTS]: - add_idf_component( - name=component[CONF_NAME], - repo=component.get(CONF_SOURCE), - ref=component.get(CONF_REF), - path=component.get(CONF_PATH), - ) + # Components from YAML are added in a separate coroutine with FINAL priority + # Schedule it to run after all other components + if conf[CONF_COMPONENTS]: + CORE.add_job(_add_yaml_idf_components, conf[CONF_COMPONENTS]) APP_PARTITION_SIZES = { diff --git a/esphome/components/esp32/pre_build.py.script b/esphome/components/esp32/pre_build.py.script new file mode 100644 index 0000000000..af12275a0b --- /dev/null +++ b/esphome/components/esp32/pre_build.py.script @@ -0,0 +1,9 @@ +Import("env") # noqa: F821 + +# Remove custom_sdkconfig from the board config as it causes +# pioarduino to enable some strange hybrid build mode that breaks IDF +board = env.BoardConfig() +if "espidf.custom_sdkconfig" in board: + del board._manifest["espidf"]["custom_sdkconfig"] + if not board._manifest["espidf"]: + del board._manifest["espidf"] diff --git a/esphome/components/esp32_ble/ble.cpp b/esphome/components/esp32_ble/ble.cpp index 8bbb21e3ca..a0ed9ee90c 100644 --- a/esphome/components/esp32_ble/ble.cpp +++ b/esphome/components/esp32_ble/ble.cpp @@ -256,29 +256,38 @@ bool ESP32BLE::ble_setup_() { } #endif - std::string name; - if (this->name_.has_value()) { - name = this->name_.value(); + const char *device_name; + std::string name_with_suffix; + + if (this->name_ != nullptr) { if (App.is_name_add_mac_suffix_enabled()) { + // MAC address length: 12 hex chars + null terminator + constexpr size_t mac_address_len = 13; // MAC address suffix length (last 6 characters of 12-char MAC address string) constexpr size_t mac_address_suffix_len = 6; - const std::string mac_addr = get_mac_address(); - const char *mac_suffix_ptr = mac_addr.c_str() + mac_address_suffix_len; - name = make_name_with_suffix(name, '-', mac_suffix_ptr, mac_address_suffix_len); + char mac_addr[mac_address_len]; + get_mac_address_into_buffer(mac_addr); + const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len; + name_with_suffix = + make_name_with_suffix(this->name_, strlen(this->name_), '-', mac_suffix_ptr, mac_address_suffix_len); + device_name = name_with_suffix.c_str(); + } else { + device_name = this->name_; } } else { - name = App.get_name(); - if (name.length() > 20) { + name_with_suffix = App.get_name(); + if (name_with_suffix.length() > 20) { if (App.is_name_add_mac_suffix_enabled()) { // Keep first 13 chars and last 7 chars (MAC suffix), remove middle - name.erase(13, name.length() - 20); + name_with_suffix.erase(13, name_with_suffix.length() - 20); } else { - name.resize(20); + name_with_suffix.resize(20); } } + device_name = name_with_suffix.c_str(); } - err = esp_ble_gap_set_device_name(name.c_str()); + err = esp_ble_gap_set_device_name(device_name); if (err != ESP_OK) { ESP_LOGE(TAG, "esp_ble_gap_set_device_name failed: %d", err); return false; @@ -634,11 +643,13 @@ void ESP32BLE::dump_config() { io_capability_s = "invalid"; break; } + char mac_s[18]; + format_mac_addr_upper(mac_address, mac_s); ESP_LOGCONFIG(TAG, "BLE:\n" " MAC address: %s\n" " IO Capability: %s", - format_mac_address_pretty(mac_address).c_str(), io_capability_s); + mac_s, io_capability_s); } else { ESP_LOGCONFIG(TAG, "Bluetooth stack is not enabled"); } diff --git a/esphome/components/esp32_ble/ble.h b/esphome/components/esp32_ble/ble.h index 2fb60bb822..393ec2e911 100644 --- a/esphome/components/esp32_ble/ble.h +++ b/esphome/components/esp32_ble/ble.h @@ -112,7 +112,7 @@ class ESP32BLE : public Component { void loop() override; void dump_config() override; float get_setup_priority() const override; - void set_name(const std::string &name) { this->name_ = name; } + void set_name(const char *name) { this->name_ = name; } #ifdef USE_ESP32_BLE_ADVERTISING void advertising_start(); @@ -191,13 +191,11 @@ class ESP32BLE : public Component { esphome::LockFreeQueue ble_events_; esphome::EventPool ble_event_pool_; - // optional (typically 16+ bytes on 32-bit, aligned to 4 bytes) - optional name_; - // 4-byte aligned members #ifdef USE_ESP32_BLE_ADVERTISING BLEAdvertising *advertising_{}; // 4 bytes (pointer) #endif + const char *name_{nullptr}; // 4 bytes (pointer to string literal in flash) esp_ble_io_cap_t io_cap_{ESP_IO_CAP_NONE}; // 4 bytes (enum) uint32_t advertising_cycle_time_{}; // 4 bytes diff --git a/esphome/components/esp32_ble_client/ble_characteristic.cpp b/esphome/components/esp32_ble_client/ble_characteristic.cpp index 36229c23c3..e0d0174c57 100644 --- a/esphome/components/esp32_ble_client/ble_characteristic.cpp +++ b/esphome/components/esp32_ble_client/ble_characteristic.cpp @@ -38,7 +38,7 @@ void BLECharacteristic::parse_descriptors() { } if (status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_get_all_descr error, status=%d", - this->service->client->get_connection_index(), this->service->client->address_str().c_str(), status); + this->service->client->get_connection_index(), this->service->client->address_str(), status); break; } if (count == 0) { @@ -51,7 +51,7 @@ void BLECharacteristic::parse_descriptors() { desc->characteristic = this; this->descriptors.push_back(desc); ESP_LOGV(TAG, "[%d] [%s] descriptor %s, handle 0x%x", this->service->client->get_connection_index(), - this->service->client->address_str().c_str(), desc->uuid.to_string().c_str(), desc->handle); + this->service->client->address_str(), desc->uuid.to_string().c_str(), desc->handle); offset++; } } @@ -84,7 +84,7 @@ esp_err_t BLECharacteristic::write_value(uint8_t *new_val, int16_t new_val_size, new_val, write_type, ESP_GATT_AUTH_REQ_NONE); if (status) { ESP_LOGW(TAG, "[%d] [%s] Error sending write value to BLE gattc server, status=%d", - this->service->client->get_connection_index(), this->service->client->address_str().c_str(), status); + this->service->client->get_connection_index(), this->service->client->address_str(), status); } return status; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 18321ef91c..07e88c7528 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -41,7 +41,7 @@ void BLEClientBase::setup() { } void BLEClientBase::set_state(espbt::ClientState st) { - ESP_LOGV(TAG, "[%d] [%s] Set state %d", this->connection_index_, this->address_str_.c_str(), (int) st); + ESP_LOGV(TAG, "[%d] [%s] Set state %d", this->connection_index_, this->address_str_, (int) st); ESPBTClient::set_state(st); } @@ -71,7 +71,7 @@ void BLEClientBase::dump_config() { ESP_LOGCONFIG(TAG, " Address: %s\n" " Auto-Connect: %s", - this->address_str().c_str(), TRUEFALSE(this->auto_connect_)); + this->address_str(), TRUEFALSE(this->auto_connect_)); ESP_LOGCONFIG(TAG, " State: %s", espbt::client_state_to_string(this->state())); if (this->status_ == ESP_GATT_NO_RESOURCES) { ESP_LOGE(TAG, " Failed due to no resources. Try to reduce number of BLE clients in config."); @@ -104,12 +104,11 @@ void BLEClientBase::connect() { // Prevent duplicate connection attempts if (this->state_ == espbt::ClientState::CONNECTING || this->state_ == espbt::ClientState::CONNECTED || this->state_ == espbt::ClientState::ESTABLISHED) { - ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, - this->address_str_.c_str(), espbt::client_state_to_string(this->state_)); + ESP_LOGW(TAG, "[%d] [%s] Connection already in progress, state=%s", this->connection_index_, this->address_str_, + espbt::client_state_to_string(this->state_)); return; } - ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_.c_str(), - this->remote_addr_type_); + ESP_LOGI(TAG, "[%d] [%s] 0x%02x Connecting", this->connection_index_, this->address_str_, this->remote_addr_type_); this->paired_ = false; // Enable loop for state processing this->enable_loop(); @@ -135,13 +134,13 @@ esp_err_t BLEClientBase::pair() { return esp_ble_set_encryption(this->remote_bda void BLEClientBase::disconnect() { if (this->state_ == espbt::ClientState::IDLE || this->state_ == espbt::ClientState::DISCONNECTING) { - ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already %s", this->connection_index_, this->address_str_.c_str(), + ESP_LOGI(TAG, "[%d] [%s] Disconnect requested, but already %s", this->connection_index_, this->address_str_, espbt::client_state_to_string(this->state_)); return; } if (this->state_ == espbt::ClientState::CONNECTING || this->conn_id_ == UNSET_CONN_ID) { ESP_LOGD(TAG, "[%d] [%s] Disconnect before connected, disconnect scheduled", this->connection_index_, - this->address_str_.c_str()); + this->address_str_); this->want_disconnect_ = true; return; } @@ -150,8 +149,7 @@ void BLEClientBase::disconnect() { void BLEClientBase::unconditional_disconnect() { // Disconnect without checking the state. - ESP_LOGI(TAG, "[%d] [%s] Disconnecting (conn_id: %d).", this->connection_index_, this->address_str_.c_str(), - this->conn_id_); + ESP_LOGI(TAG, "[%d] [%s] Disconnecting (conn_id: %d).", this->connection_index_, this->address_str_, this->conn_id_); if (this->state_ == espbt::ClientState::DISCONNECTING) { this->log_error_("Already disconnecting"); return; @@ -192,24 +190,23 @@ void BLEClientBase::release_services() { } void BLEClientBase::log_event_(const char *name) { - ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), name); + ESP_LOGD(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_, name); } void BLEClientBase::log_gattc_event_(const char *name) { - ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_%s_EVT", this->connection_index_, this->address_str_.c_str(), name); + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_%s_EVT", this->connection_index_, this->address_str_, name); } void BLEClientBase::log_gattc_warning_(const char *operation, esp_gatt_status_t status) { - ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_.c_str(), operation, - status); + ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_, operation, status); } void BLEClientBase::log_gattc_warning_(const char *operation, esp_err_t err) { - ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_.c_str(), operation, err); + ESP_LOGW(TAG, "[%d] [%s] %s error, status=%d", this->connection_index_, this->address_str_, operation, err); } void BLEClientBase::log_connection_params_(const char *param_type) { - ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_.c_str(), param_type); + ESP_LOGD(TAG, "[%d] [%s] %s conn params", this->connection_index_, this->address_str_, param_type); } void BLEClientBase::handle_connection_result_(esp_err_t ret) { @@ -220,15 +217,15 @@ void BLEClientBase::handle_connection_result_(esp_err_t ret) { } void BLEClientBase::log_error_(const char *message) { - ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); + ESP_LOGE(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_, message); } void BLEClientBase::log_error_(const char *message, int code) { - ESP_LOGE(TAG, "[%d] [%s] %s=%d", this->connection_index_, this->address_str_.c_str(), message, code); + ESP_LOGE(TAG, "[%d] [%s] %s=%d", this->connection_index_, this->address_str_, message, code); } void BLEClientBase::log_warning_(const char *message) { - ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_.c_str(), message); + ESP_LOGW(TAG, "[%d] [%s] %s", this->connection_index_, this->address_str_, message); } void BLEClientBase::update_conn_params_(uint16_t min_interval, uint16_t max_interval, uint16_t latency, @@ -264,13 +261,13 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (event != ESP_GATTC_REG_EVT && esp_gattc_if != ESP_GATT_IF_NONE && esp_gattc_if != this->gattc_if_) return false; - ESP_LOGV(TAG, "[%d] [%s] gattc_event_handler: event=%d gattc_if=%d", this->connection_index_, - this->address_str_.c_str(), event, esp_gattc_if); + ESP_LOGV(TAG, "[%d] [%s] gattc_event_handler: event=%d gattc_if=%d", this->connection_index_, this->address_str_, + event, esp_gattc_if); switch (event) { case ESP_GATTC_REG_EVT: { if (param->reg.status == ESP_GATT_OK) { - ESP_LOGV(TAG, "[%d] [%s] gattc registered app id %d", this->connection_index_, this->address_str_.c_str(), + ESP_LOGV(TAG, "[%d] [%s] gattc registered app id %d", this->connection_index_, this->address_str_, this->app_id); this->gattc_if_ = esp_gattc_if; } else { @@ -292,7 +289,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // arriving after we've already transitioned to IDLE state. if (this->state_ == espbt::ClientState::IDLE) { ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in IDLE state (status=%d), ignoring", this->connection_index_, - this->address_str_.c_str(), param->open.status); + this->address_str_, param->open.status); break; } @@ -301,7 +298,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ // because it means we have a bad assumption about how the // ESP BT stack works. ESP_LOGE(TAG, "[%d] [%s] ESP_GATTC_OPEN_EVT in %s state (status=%d)", this->connection_index_, - this->address_str_.c_str(), espbt::client_state_to_string(this->state_), param->open.status); + this->address_str_, espbt::client_state_to_string(this->state_), param->open.status); } if (param->open.status != ESP_GATT_OK && param->open.status != ESP_GATT_ALREADY_OPEN) { this->log_gattc_warning_("Connection open", param->open.status); @@ -318,7 +315,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } // MTU negotiation already started in ESP_GATTC_CONNECT_EVT this->set_state(espbt::ClientState::CONNECTED); - ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_.c_str()); + ESP_LOGI(TAG, "[%d] [%s] Connection open", this->connection_index_, this->address_str_); if (this->connection_type_ == espbt::ConnectionType::V3_WITH_CACHE) { // Cached connections already connected with medium parameters, no update needed // only set our state, subclients might have more stuff to do yet. @@ -354,8 +351,8 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ this->state_ == espbt::ClientState::CONNECTED) { this->log_warning_("Remote closed during discovery"); } else { - ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, - this->address_str_.c_str(), param->disconnect.reason); + ESP_LOGD(TAG, "[%d] [%s] ESP_GATTC_DISCONNECT_EVT, reason 0x%02x", this->connection_index_, this->address_str_, + param->disconnect.reason); } this->release_services(); this->set_state(espbt::ClientState::IDLE); @@ -366,12 +363,12 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ if (this->conn_id_ != param->cfg_mtu.conn_id) return false; if (param->cfg_mtu.status != ESP_GATT_OK) { - ESP_LOGW(TAG, "[%d] [%s] cfg_mtu failed, mtu %d, status %d", this->connection_index_, - this->address_str_.c_str(), param->cfg_mtu.mtu, param->cfg_mtu.status); + ESP_LOGW(TAG, "[%d] [%s] cfg_mtu failed, mtu %d, status %d", this->connection_index_, this->address_str_, + param->cfg_mtu.mtu, param->cfg_mtu.status); // No state change required here - disconnect event will follow if needed. break; } - ESP_LOGD(TAG, "[%d] [%s] cfg_mtu status %d, mtu %d", this->connection_index_, this->address_str_.c_str(), + ESP_LOGD(TAG, "[%d] [%s] cfg_mtu status %d, mtu %d", this->connection_index_, this->address_str_, param->cfg_mtu.status, param->cfg_mtu.mtu); this->mtu_ = param->cfg_mtu.mtu; break; @@ -415,14 +412,14 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ } else if (this->connection_type_ != espbt::ConnectionType::V3_WITH_CACHE) { #ifdef USE_ESP32_BLE_DEVICE for (auto &svc : this->services_) { - ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_.c_str(), + ESP_LOGV(TAG, "[%d] [%s] Service UUID: %s", this->connection_index_, this->address_str_, svc->uuid.to_string().c_str()); - ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, - this->address_str_.c_str(), svc->start_handle, svc->end_handle); + ESP_LOGV(TAG, "[%d] [%s] start_handle: 0x%x end_handle: 0x%x", this->connection_index_, this->address_str_, + svc->start_handle, svc->end_handle); } #endif } - ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_.c_str()); + ESP_LOGI(TAG, "[%d] [%s] Service discovery complete", this->connection_index_, this->address_str_); this->state_ = espbt::ClientState::ESTABLISHED; break; } @@ -503,7 +500,7 @@ bool BLEClientBase::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_ default: // ideally would check all other events for matching conn_id - ESP_LOGD(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_.c_str(), event); + ESP_LOGD(TAG, "[%d] [%s] Event %d", this->connection_index_, this->address_str_, event); break; } return true; @@ -520,7 +517,7 @@ void BLEClientBase::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_ case ESP_GAP_BLE_SEC_REQ_EVT: if (!this->check_addr(param->ble_security.auth_cmpl.bd_addr)) return; - ESP_LOGV(TAG, "[%d] [%s] ESP_GAP_BLE_SEC_REQ_EVT %x", this->connection_index_, this->address_str_.c_str(), event); + ESP_LOGV(TAG, "[%d] [%s] ESP_GAP_BLE_SEC_REQ_EVT %x", this->connection_index_, this->address_str_, event); esp_ble_gap_security_rsp(param->ble_security.ble_req.bd_addr, true); break; // This event is sent once authentication has completed @@ -529,13 +526,13 @@ void BLEClientBase::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_ return; esp_bd_addr_t bd_addr; memcpy(bd_addr, param->ble_security.auth_cmpl.bd_addr, sizeof(esp_bd_addr_t)); - ESP_LOGI(TAG, "[%d] [%s] auth complete addr: %s", this->connection_index_, this->address_str_.c_str(), + ESP_LOGI(TAG, "[%d] [%s] auth complete addr: %s", this->connection_index_, this->address_str_, format_hex(bd_addr, 6).c_str()); if (!param->ble_security.auth_cmpl.success) { this->log_error_("auth fail reason", param->ble_security.auth_cmpl.fail_reason); } else { this->paired_ = true; - ESP_LOGD(TAG, "[%d] [%s] auth success type = %d mode = %d", this->connection_index_, this->address_str_.c_str(), + ESP_LOGD(TAG, "[%d] [%s] auth success type = %d mode = %d", this->connection_index_, this->address_str_, param->ble_security.auth_cmpl.addr_type, param->ble_security.auth_cmpl.auth_mode); } break; @@ -598,7 +595,7 @@ float BLEClientBase::parse_char_value(uint8_t *value, uint16_t length) { } } ESP_LOGW(TAG, "[%d] [%s] Cannot parse characteristic value of type 0x%x length %d", this->connection_index_, - this->address_str_.c_str(), value[0], length); + this->address_str_, value[0], length); return NAN; } diff --git a/esphome/components/esp32_ble_client/ble_client_base.h b/esphome/components/esp32_ble_client/ble_client_base.h index 7f0ae3b83e..7786495915 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.h +++ b/esphome/components/esp32_ble_client/ble_client_base.h @@ -10,7 +10,6 @@ #endif #include -#include #include #include @@ -23,6 +22,7 @@ namespace esphome::esp32_ble_client { namespace espbt = esphome::esp32_ble_tracker; static const int UNSET_CONN_ID = 0xFFFF; +static constexpr size_t MAC_ADDR_STR_LEN = 18; // "AA:BB:CC:DD:EE:FF\0" class BLEClientBase : public espbt::ESPBTClient, public Component { public: @@ -58,14 +58,12 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { this->remote_bda_[4] = (address >> 8) & 0xFF; this->remote_bda_[5] = (address >> 0) & 0xFF; if (address == 0) { - this->address_str_ = ""; + this->address_str_[0] = '\0'; } else { - char buf[18]; - format_mac_addr_upper(this->remote_bda_, buf); - this->address_str_ = buf; + format_mac_addr_upper(this->remote_bda_, this->address_str_); } } - const std::string &address_str() const { return this->address_str_; } + const char *address_str() const { return this->address_str_; } #ifdef USE_ESP32_BLE_DEVICE BLEService *get_service(espbt::ESPBTUUID uuid); @@ -104,7 +102,6 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { uint64_t address_{0}; // Group 2: Container types (grouped for memory optimization) - std::string address_str_{}; #ifdef USE_ESP32_BLE_DEVICE std::vector services_; #endif @@ -113,8 +110,9 @@ class BLEClientBase : public espbt::ESPBTClient, public Component { int gattc_if_; esp_gatt_status_t status_{ESP_GATT_OK}; - // Group 4: Arrays (6 bytes) - esp_bd_addr_t remote_bda_; + // Group 4: Arrays + char address_str_[MAC_ADDR_STR_LEN]{}; // 18 bytes: "AA:BB:CC:DD:EE:FF\0" + esp_bd_addr_t remote_bda_; // 6 bytes // Group 5: 2-byte types uint16_t conn_id_{UNSET_CONN_ID}; diff --git a/esphome/components/esp32_ble_client/ble_service.cpp b/esphome/components/esp32_ble_client/ble_service.cpp index accaad15e1..deaaa3de02 100644 --- a/esphome/components/esp32_ble_client/ble_service.cpp +++ b/esphome/components/esp32_ble_client/ble_service.cpp @@ -51,7 +51,7 @@ void BLEService::parse_characteristics() { } if (status != ESP_GATT_OK) { ESP_LOGW(TAG, "[%d] [%s] esp_ble_gattc_get_all_char error, status=%d", this->client->get_connection_index(), - this->client->address_str().c_str(), status); + this->client->address_str(), status); break; } if (count == 0) { @@ -65,7 +65,7 @@ void BLEService::parse_characteristics() { characteristic->service = this; this->characteristics.push_back(characteristic); ESP_LOGV(TAG, "[%d] [%s] characteristic %s, handle 0x%x, properties 0x%x", this->client->get_connection_index(), - this->client->address_str().c_str(), characteristic->uuid.to_string().c_str(), characteristic->handle, + this->client->address_str(), characteristic->uuid.to_string().c_str(), characteristic->handle, characteristic->properties); offset++; } diff --git a/esphome/components/esp32_ble_tracker/automation.h b/esphome/components/esp32_ble_tracker/automation.h index 054cbaa7df..bbf7992fa4 100644 --- a/esphome/components/esp32_ble_tracker/automation.h +++ b/esphome/components/esp32_ble_tracker/automation.h @@ -10,7 +10,7 @@ namespace esphome::esp32_ble_tracker { class ESPBTAdvertiseTrigger : public Trigger, public ESPBTDeviceListener { public: explicit ESPBTAdvertiseTrigger(ESP32BLETracker *parent) { parent->register_listener(this); } - void set_addresses(const std::vector &addresses) { this->address_vec_ = addresses; } + void set_addresses(std::initializer_list addresses) { this->address_vec_ = addresses; } bool parse_device(const ESPBTDevice &device) override { uint64_t u64_addr = device.address_uint64(); diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp index 8577f12a92..d3c5edfb94 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.cpp @@ -373,7 +373,9 @@ void ESP32BLETracker::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_i void ESP32BLETracker::set_scanner_state_(ScannerState state) { this->scanner_state_ = state; - this->scanner_state_callbacks_.call(state); + for (auto *listener : this->scanner_state_listeners_) { + listener->on_scanner_state(state); + } } #ifdef USE_ESP32_BLE_DEVICE diff --git a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h index f80f3e2670..92d13a62ad 100644 --- a/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h +++ b/esphome/components/esp32_ble_tracker/esp32_ble_tracker.h @@ -180,6 +180,16 @@ enum class ScannerState { STOPPING, }; +/** Listener interface for BLE scanner state changes. + * + * Components can implement this interface to receive scanner state updates + * without the overhead of std::function callbacks. + */ +class BLEScannerStateListener { + public: + virtual void on_scanner_state(ScannerState state) = 0; +}; + // Helper function to convert ClientState to string const char *client_state_to_string(ClientState state); @@ -264,8 +274,9 @@ class ESP32BLETracker : public Component, void gap_scan_event_handler(const BLEScanResult &scan_result) override; void ble_before_disabled_event_handler() override; - void add_scanner_state_callback(std::function &&callback) { - this->scanner_state_callbacks_.add(std::move(callback)); + /// Add a listener for scanner state changes + void add_scanner_state_listener(BLEScannerStateListener *listener) { + this->scanner_state_listeners_.push_back(listener); } ScannerState get_scanner_state() const { return this->scanner_state_; } @@ -322,14 +333,14 @@ class ESP32BLETracker : public Component, return counts; } - // Group 1: Large objects (12+ bytes) - vectors and callback manager + // Group 1: Large objects (12+ bytes) - vectors #ifdef ESPHOME_ESP32_BLE_TRACKER_LISTENER_COUNT StaticVector listeners_; #endif #ifdef ESPHOME_ESP32_BLE_TRACKER_CLIENT_COUNT StaticVector clients_; #endif - CallbackManager scanner_state_callbacks_; + std::vector scanner_state_listeners_; #ifdef USE_ESP32_BLE_DEVICE /// Vector of addresses that have already been printed in print_bt_device_info std::vector already_discovered_; diff --git a/esphome/components/esp32_camera/esp32_camera.cpp b/esphome/components/esp32_camera/esp32_camera.cpp index 38bd8d5822..5080a6f32d 100644 --- a/esphome/components/esp32_camera/esp32_camera.cpp +++ b/esphome/components/esp32_camera/esp32_camera.cpp @@ -205,7 +205,9 @@ void ESP32Camera::loop() { this->current_image_ = std::make_shared(fb, this->single_requesters_ | this->stream_requesters_); ESP_LOGD(TAG, "Got Image: len=%u", fb->len); - this->new_image_callback_.call(this->current_image_); + for (auto *listener : this->listeners_) { + listener->on_camera_image(this->current_image_); + } this->last_update_ = now; this->single_requesters_ = 0; } @@ -357,21 +359,16 @@ void ESP32Camera::set_frame_buffer_location(camera_fb_location_t fb_location) { } /* ---------------- public API (specific) ---------------- */ -void ESP32Camera::add_image_callback(std::function)> &&callback) { - this->new_image_callback_.add(std::move(callback)); -} -void ESP32Camera::add_stream_start_callback(std::function &&callback) { - this->stream_start_callback_.add(std::move(callback)); -} -void ESP32Camera::add_stream_stop_callback(std::function &&callback) { - this->stream_stop_callback_.add(std::move(callback)); -} void ESP32Camera::start_stream(camera::CameraRequester requester) { - this->stream_start_callback_.call(); + for (auto *listener : this->listeners_) { + listener->on_stream_start(); + } this->stream_requesters_ |= (1U << requester); } void ESP32Camera::stop_stream(camera::CameraRequester requester) { - this->stream_stop_callback_.call(); + for (auto *listener : this->listeners_) { + listener->on_stream_stop(); + } this->stream_requesters_ &= ~(1U << requester); } void ESP32Camera::request_image(camera::CameraRequester requester) { this->single_requesters_ |= (1U << requester); } diff --git a/esphome/components/esp32_camera/esp32_camera.h b/esphome/components/esp32_camera/esp32_camera.h index 0e7f7c0ea6..54a7d6064a 100644 --- a/esphome/components/esp32_camera/esp32_camera.h +++ b/esphome/components/esp32_camera/esp32_camera.h @@ -165,9 +165,8 @@ class ESP32Camera : public camera::Camera { void request_image(camera::CameraRequester requester) override; void update_camera_parameters(); - void add_image_callback(std::function)> &&callback) override; - void add_stream_start_callback(std::function &&callback); - void add_stream_stop_callback(std::function &&callback); + /// Add a listener to receive camera events + void add_listener(camera::CameraListener *listener) override { this->listeners_.push_back(listener); } camera::CameraImageReader *create_image_reader() override; protected: @@ -210,9 +209,7 @@ class ESP32Camera : public camera::Camera { uint8_t stream_requesters_{0}; QueueHandle_t framebuffer_get_queue_; QueueHandle_t framebuffer_return_queue_; - CallbackManager)> new_image_callback_{}; - CallbackManager stream_start_callback_{}; - CallbackManager stream_stop_callback_{}; + std::vector listeners_; uint32_t last_idle_request_{0}; uint32_t last_update_{0}; @@ -221,33 +218,27 @@ class ESP32Camera : public camera::Camera { #endif // USE_I2C }; -class ESP32CameraImageTrigger : public Trigger { +class ESP32CameraImageTrigger : public Trigger, public camera::CameraListener { public: - explicit ESP32CameraImageTrigger(ESP32Camera *parent) { - parent->add_image_callback([this](const std::shared_ptr &image) { - CameraImageData camera_image_data{}; - camera_image_data.length = image->get_data_length(); - camera_image_data.data = image->get_data_buffer(); - this->trigger(camera_image_data); - }); + explicit ESP32CameraImageTrigger(ESP32Camera *parent) { parent->add_listener(this); } + void on_camera_image(const std::shared_ptr &image) override { + CameraImageData camera_image_data{}; + camera_image_data.length = image->get_data_length(); + camera_image_data.data = image->get_data_buffer(); + this->trigger(camera_image_data); } }; -class ESP32CameraStreamStartTrigger : public Trigger<> { +class ESP32CameraStreamStartTrigger : public Trigger<>, public camera::CameraListener { public: - explicit ESP32CameraStreamStartTrigger(ESP32Camera *parent) { - parent->add_stream_start_callback([this]() { this->trigger(); }); - } - - protected: + explicit ESP32CameraStreamStartTrigger(ESP32Camera *parent) { parent->add_listener(this); } + void on_stream_start() override { this->trigger(); } }; -class ESP32CameraStreamStopTrigger : public Trigger<> { - public: - explicit ESP32CameraStreamStopTrigger(ESP32Camera *parent) { - parent->add_stream_stop_callback([this]() { this->trigger(); }); - } - protected: +class ESP32CameraStreamStopTrigger : public Trigger<>, public camera::CameraListener { + public: + explicit ESP32CameraStreamStopTrigger(ESP32Camera *parent) { parent->add_listener(this); } + void on_stream_stop() override { this->trigger(); } }; } // namespace esp32_camera diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.cpp b/esphome/components/esp32_camera_web_server/camera_web_server.cpp index 1b81989296..f49578c425 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.cpp +++ b/esphome/components/esp32_camera_web_server/camera_web_server.cpp @@ -67,12 +67,14 @@ void CameraWebServer::setup() { httpd_register_uri_handler(this->httpd_, &uri); - camera::Camera::instance()->add_image_callback([this](std::shared_ptr image) { - if (this->running_ && image->was_requested_by(camera::WEB_REQUESTER)) { - this->image_ = std::move(image); - xSemaphoreGive(this->semaphore_); - } - }); + camera::Camera::instance()->add_listener(this); +} + +void CameraWebServer::on_camera_image(const std::shared_ptr &image) { + if (this->running_ && image->was_requested_by(camera::WEB_REQUESTER)) { + this->image_ = image; + xSemaphoreGive(this->semaphore_); + } } void CameraWebServer::on_shutdown() { diff --git a/esphome/components/esp32_camera_web_server/camera_web_server.h b/esphome/components/esp32_camera_web_server/camera_web_server.h index e70246745c..ad7b29fb11 100644 --- a/esphome/components/esp32_camera_web_server/camera_web_server.h +++ b/esphome/components/esp32_camera_web_server/camera_web_server.h @@ -18,7 +18,7 @@ namespace esp32_camera_web_server { enum Mode { STREAM, SNAPSHOT }; -class CameraWebServer : public Component { +class CameraWebServer : public Component, public camera::CameraListener { public: CameraWebServer(); ~CameraWebServer(); @@ -31,6 +31,9 @@ class CameraWebServer : public Component { void set_mode(Mode mode) { this->mode_ = mode; } void loop() override; + /// CameraListener interface + void on_camera_image(const std::shared_ptr &image) override; + protected: std::shared_ptr wait_for_image_(); esp_err_t handler_(struct httpd_req *req); diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index dfa98b2eff..acc3785f22 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -10,6 +10,7 @@ from esphome.components.esp32.const import ( VARIANT_ESP32C3, VARIANT_ESP32C6, VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, ) @@ -59,6 +60,7 @@ CAN_SPEEDS_ESP32_S3 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_C3 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_C6 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_H2 = {**CAN_SPEEDS_ESP32_S2} +CAN_SPEEDS_ESP32_P4 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS = { VARIANT_ESP32: CAN_SPEEDS_ESP32, @@ -67,6 +69,7 @@ CAN_SPEEDS = { VARIANT_ESP32C3: CAN_SPEEDS_ESP32_C3, VARIANT_ESP32C6: CAN_SPEEDS_ESP32_C6, VARIANT_ESP32H2: CAN_SPEEDS_ESP32_H2, + VARIANT_ESP32P4: CAN_SPEEDS_ESP32_P4, } diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index cdef7b1930..f9b63b8ebc 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -17,7 +17,7 @@ static const char *const TAG = "esp32_can"; static bool get_bitrate(canbus::CanSpeed bitrate, twai_timing_config_t *t_config) { switch (bitrate) { #if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) case canbus::CAN_1KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_1KBITS(); return true; diff --git a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp index adbcc5bf11..de130ca71f 100644 --- a/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp +++ b/esphome/components/esp32_hosted/update/esp32_hosted_update.cpp @@ -22,6 +22,11 @@ constexpr size_t CHUNK_SIZE = 1500; void Esp32HostedUpdate::setup() { this->update_info_.title = "ESP32 Hosted Coprocessor"; + // if wifi is not present, connect to the coprocessor +#ifndef USE_WIFI + esp_hosted_connect_to_slave(); // NOLINT +#endif + // get coprocessor version esp_hosted_coprocessor_fwver_t ver_info; if (esp_hosted_get_coprocessor_fwversion(&ver_info) == ESP_OK) { @@ -88,7 +93,7 @@ void Esp32HostedUpdate::perform(bool force) { hasher.add(this->firmware_data_, this->firmware_size_); hasher.calculate(); if (!hasher.equals_bytes(this->firmware_sha256_.data())) { - this->status_set_error("SHA256 verification failed"); + this->status_set_error(LOG_STR("SHA256 verification failed")); this->publish_state(); return; } @@ -105,7 +110,7 @@ void Esp32HostedUpdate::perform(bool force) { if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to begin OTA: %s", esp_err_to_name(err)); this->state_ = prev_state; - this->status_set_error("Failed to begin OTA"); + this->status_set_error(LOG_STR("Failed to begin OTA")); this->publish_state(); return; } @@ -121,7 +126,7 @@ void Esp32HostedUpdate::perform(bool force) { ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); esp_hosted_slave_ota_end(); // NOLINT this->state_ = prev_state; - this->status_set_error("Failed to write OTA data"); + this->status_set_error(LOG_STR("Failed to write OTA data")); this->publish_state(); return; } @@ -134,7 +139,7 @@ void Esp32HostedUpdate::perform(bool force) { if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(err)); this->state_ = prev_state; - this->status_set_error("Failed to end OTA"); + this->status_set_error(LOG_STR("Failed to end OTA")); this->publish_state(); return; } @@ -144,7 +149,7 @@ void Esp32HostedUpdate::perform(bool force) { if (err != ESP_OK) { ESP_LOGE(TAG, "Failed to activate OTA: %s", esp_err_to_name(err)); this->state_ = prev_state; - this->status_set_error("Failed to activate OTA"); + this->status_set_error(LOG_STR("Failed to activate OTA")); this->publish_state(); return; } diff --git a/esphome/components/esp32_improv/__init__.py b/esphome/components/esp32_improv/__init__.py index 1a7194da81..2e69d400ca 100644 --- a/esphome/components/esp32_improv/__init__.py +++ b/esphome/components/esp32_improv/__init__.py @@ -20,6 +20,10 @@ CONF_ON_STOP = "on_stop" CONF_STATUS_INDICATOR = "status_indicator" CONF_WIFI_TIMEOUT = "wifi_timeout" +# Default WiFi timeout - aligned with WiFi component ap_timeout +# Allows sufficient time to try all BSSIDs before starting provisioning mode +DEFAULT_WIFI_TIMEOUT = "90s" + improv_ns = cg.esphome_ns.namespace("improv") Error = improv_ns.enum("Error") @@ -59,7 +63,7 @@ CONFIG_SCHEMA = ( CONF_AUTHORIZED_DURATION, default="1min" ): cv.positive_time_period_milliseconds, cv.Optional( - CONF_WIFI_TIMEOUT, default="1min" + CONF_WIFI_TIMEOUT, default=DEFAULT_WIFI_TIMEOUT ): cv.positive_time_period_milliseconds, cv.Optional(CONF_ON_PROVISIONED): automation.validate_automation( { diff --git a/esphome/components/esp32_improv/esp32_improv_component.cpp b/esphome/components/esp32_improv/esp32_improv_component.cpp index 2fa9d8f523..0ad54bbb15 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.cpp +++ b/esphome/components/esp32_improv/esp32_improv_component.cpp @@ -127,6 +127,7 @@ void ESP32ImprovComponent::loop() { // Set initial state based on whether we have an authorizer this->set_state_(this->get_initial_state_(), false); this->set_error_(improv::ERROR_NONE); + this->should_start_ = false; // Clear flag after starting ESP_LOGD(TAG, "Service started!"); } } @@ -336,7 +337,7 @@ void ESP32ImprovComponent::process_incoming_data_() { this->connecting_sta_ = sta; wifi::global_wifi_component->set_sta(sta); - wifi::global_wifi_component->start_connecting(sta, false); + wifi::global_wifi_component->start_connecting(sta); this->set_state_(improv::STATE_PROVISIONING); ESP_LOGD(TAG, "Received Improv Wi-Fi settings ssid=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); diff --git a/esphome/components/esp32_improv/esp32_improv_component.h b/esphome/components/esp32_improv/esp32_improv_component.h index 989552ea56..8f4cfd7958 100644 --- a/esphome/components/esp32_improv/esp32_improv_component.h +++ b/esphome/components/esp32_improv/esp32_improv_component.h @@ -45,6 +45,7 @@ class ESP32ImprovComponent : public Component, public improv_base::ImprovBase { void start(); void stop(); bool is_active() const { return this->state_ != improv::STATE_STOPPED; } + bool should_start() const { return this->should_start_; } #ifdef USE_ESP32_IMPROV_STATE_CALLBACK void add_on_state_callback(std::function &&callback) { diff --git a/esphome/components/esp8266/core.h b/esphome/components/esp8266/core.h index ac33305669..1abe67be86 100644 --- a/esphome/components/esp8266/core.h +++ b/esphome/components/esp8266/core.h @@ -7,8 +7,6 @@ extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_MODE[16]; extern const uint8_t ESPHOME_ESP8266_GPIO_INITIAL_LEVEL[16]; -namespace esphome { -namespace esp8266 {} // namespace esp8266 -} // namespace esphome +namespace esphome::esp8266 {} // namespace esphome::esp8266 #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/gpio.cpp b/esphome/components/esp8266/gpio.cpp index ee3683c67d..124df39ce3 100644 --- a/esphome/components/esp8266/gpio.cpp +++ b/esphome/components/esp8266/gpio.cpp @@ -3,17 +3,18 @@ #include "gpio.h" #include "esphome/core/log.h" -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { static const char *const TAG = "esp8266"; static int flags_to_mode(gpio::Flags flags, uint8_t pin) { - if (flags == gpio::FLAG_INPUT) { // NOLINT(bugprone-branch-clone) - return INPUT; - } else if (flags == gpio::FLAG_OUTPUT) { + if (flags == gpio::FLAG_OUTPUT || flags == (gpio::FLAG_OUTPUT | gpio::FLAG_INPUT)) { return OUTPUT; - } else if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLUP)) { + } + if (flags == gpio::FLAG_INPUT) { + return INPUT; + } + if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLUP)) { if (pin == 16) { // GPIO16 doesn't have a pullup, so pinMode would fail. // However, sometimes this method is called with pullup mode anyway @@ -22,13 +23,14 @@ static int flags_to_mode(gpio::Flags flags, uint8_t pin) { return INPUT; } return INPUT_PULLUP; - } else if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLDOWN)) { - return INPUT_PULLDOWN_16; - } else if (flags == (gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN)) { - return OUTPUT_OPEN_DRAIN; - } else { - return 0; } + if (flags == (gpio::FLAG_INPUT | gpio::FLAG_PULLDOWN)) { + return INPUT_PULLDOWN_16; + } + if (flags == (gpio::FLAG_OUTPUT | gpio::FLAG_OPEN_DRAIN)) { + return OUTPUT_OPEN_DRAIN; + } + return INPUT; } struct ISRPinArg { @@ -110,9 +112,11 @@ void ESP8266GPIOPin::digital_write(bool value) { } void ESP8266GPIOPin::detach_interrupt() const { detachInterrupt(pin_); } -} // namespace esp8266 +} // namespace esphome::esp8266 -using namespace esp8266; +namespace esphome { + +using esp8266::ISRPinArg; bool IRAM_ATTR ISRInternalGPIOPin::digital_read() { auto *arg = reinterpret_cast(this->arg_); diff --git a/esphome/components/esp8266/gpio.h b/esphome/components/esp8266/gpio.h index a1b6d79b3b..213a5c54be 100644 --- a/esphome/components/esp8266/gpio.h +++ b/esphome/components/esp8266/gpio.h @@ -5,8 +5,7 @@ #include "esphome/core/hal.h" #include -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { class ESP8266GPIOPin : public InternalGPIOPin { public: @@ -33,7 +32,6 @@ class ESP8266GPIOPin : public InternalGPIOPin { gpio::Flags flags_{}; }; -} // namespace esp8266 -} // namespace esphome +} // namespace esphome::esp8266 #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/preferences.cpp b/esphome/components/esp8266/preferences.cpp index a26e9cc498..197d244dc4 100644 --- a/esphome/components/esp8266/preferences.cpp +++ b/esphome/components/esp8266/preferences.cpp @@ -15,24 +15,24 @@ extern "C" { #include #include -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { static const char *const TAG = "esp8266.preferences"; -static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static uint32_t *s_flash_storage = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_prevent_write = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_flash_dirty = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static const uint32_t ESP_RTC_USER_MEM_START = 0x60001200; +static constexpr uint32_t ESP_RTC_USER_MEM_START = 0x60001200; +static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; +static constexpr uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; + #define ESP_RTC_USER_MEM ((uint32_t *) ESP_RTC_USER_MEM_START) -static const uint32_t ESP_RTC_USER_MEM_SIZE_WORDS = 128; -static const uint32_t ESP_RTC_USER_MEM_SIZE_BYTES = ESP_RTC_USER_MEM_SIZE_WORDS * 4; #ifdef USE_ESP8266_PREFERENCES_FLASH -static const uint32_t ESP8266_FLASH_STORAGE_SIZE = 128; +static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 128; #else -static const uint32_t ESP8266_FLASH_STORAGE_SIZE = 64; +static constexpr uint32_t ESP8266_FLASH_STORAGE_SIZE = 64; #endif static inline bool esp_rtc_user_mem_read(uint32_t index, uint32_t *dest) { @@ -284,10 +284,10 @@ void setup_preferences() { } void preferences_prevent_write(bool prevent) { s_prevent_write = prevent; } -} // namespace esp8266 +} // namespace esphome::esp8266 +namespace esphome { ESPPreferences *global_preferences; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) - } // namespace esphome #endif // USE_ESP8266 diff --git a/esphome/components/esp8266/preferences.h b/esphome/components/esp8266/preferences.h index edec915794..16cf80a129 100644 --- a/esphome/components/esp8266/preferences.h +++ b/esphome/components/esp8266/preferences.h @@ -2,13 +2,11 @@ #ifdef USE_ESP8266 -namespace esphome { -namespace esp8266 { +namespace esphome::esp8266 { void setup_preferences(); void preferences_prevent_write(bool prevent); -} // namespace esp8266 -} // namespace esphome +} // namespace esphome::esp8266 #endif // USE_ESP8266 diff --git a/esphome/components/esp_ldo/esp_ldo.cpp b/esphome/components/esp_ldo/esp_ldo.cpp index eb04670d7e..5e3d4159f3 100644 --- a/esphome/components/esp_ldo/esp_ldo.cpp +++ b/esphome/components/esp_ldo/esp_ldo.cpp @@ -14,8 +14,8 @@ void EspLdo::setup() { config.flags.adjustable = this->adjustable_; auto err = esp_ldo_acquire_channel(&config, &this->handle_); if (err != ESP_OK) { - auto msg = str_sprintf("Failed to acquire LDO channel %d with voltage %fV", this->channel_, this->voltage_); - this->mark_failed(msg.c_str()); + ESP_LOGE(TAG, "Failed to acquire LDO channel %d with voltage %fV", this->channel_, this->voltage_); + this->mark_failed(LOG_STR("Failed to acquire LDO channel")); } else { ESP_LOGD(TAG, "Acquired LDO channel %d with voltage %fV", this->channel_, this->voltage_); } diff --git a/esphome/components/esphome/ota/ota_esphome.cpp b/esphome/components/esphome/ota/ota_esphome.cpp index eb6c61a69b..6cfd543553 100644 --- a/esphome/components/esphome/ota/ota_esphome.cpp +++ b/esphome/components/esphome/ota/ota_esphome.cpp @@ -10,7 +10,6 @@ #endif #include "esphome/components/network/util.h" #include "esphome/components/ota/ota_backend.h" -#include "esphome/components/ota/ota_backend_arduino_esp32.h" #include "esphome/components/ota/ota_backend_arduino_esp8266.h" #include "esphome/components/ota/ota_backend_arduino_libretiny.h" #include "esphome/components/ota/ota_backend_arduino_rp2040.h" @@ -402,7 +401,7 @@ error: this->backend_->abort(); } - this->status_momentary_error("onerror", 5000); + this->status_momentary_error("err", 5000); #ifdef USE_OTA_STATE_CALLBACK this->state_callback_.call(ota::OTA_ERROR, 0.0f, static_cast(error_code)); #endif diff --git a/esphome/components/espnow/espnow_component.cpp b/esphome/components/espnow/espnow_component.cpp index d2f136d1c7..bc05833709 100644 --- a/esphome/components/espnow/espnow_component.cpp +++ b/esphome/components/espnow/espnow_component.cpp @@ -10,6 +10,7 @@ #include #include +#include #include #include #include @@ -157,6 +158,12 @@ bool ESPNowComponent::is_wifi_enabled() { } void ESPNowComponent::setup() { +#ifndef USE_WIFI + // Initialize LwIP stack for wake_loop_threadsafe() socket support + // When WiFi component is present, it handles esp_netif_init() + ESP_ERROR_CHECK(esp_netif_init()); +#endif + if (this->enable_on_boot_) { this->enable_(); } else { diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index 2f02d227d7..b4d67635c1 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -383,6 +383,7 @@ async def to_code(config): cg.add(var.set_use_address(config[CONF_USE_ADDRESS])) if CONF_MANUAL_IP in config: + cg.add_define("USE_ETHERNET_MANUAL_IP") cg.add(var.set_manual_ip(manual_ip(config[CONF_MANUAL_IP]))) # Add compile-time define for PHY types with specific code diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 893d0285be..9a46aa2687 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -381,7 +381,10 @@ void EthernetComponent::dump_config() { break; } - ESP_LOGCONFIG(TAG, "Ethernet:"); + ESP_LOGCONFIG(TAG, + "Ethernet:\n" + " Connected: %s", + YESNO(this->is_connected())); this->dump_connect_params_(); #ifdef USE_ETHERNET_SPI ESP_LOGCONFIG(TAG, @@ -418,8 +421,6 @@ void EthernetComponent::dump_config() { float EthernetComponent::get_setup_priority() const { return setup_priority::WIFI; } -bool EthernetComponent::can_proceed() { return this->is_connected(); } - network::IPAddresses EthernetComponent::get_ip_addresses() { network::IPAddresses addresses; esp_netif_ip_info_t ip; @@ -552,11 +553,14 @@ void EthernetComponent::start_connect_() { } esp_netif_ip_info_t info; +#ifdef USE_ETHERNET_MANUAL_IP if (this->manual_ip_.has_value()) { info.ip = this->manual_ip_->static_ip; info.gw = this->manual_ip_->gateway; info.netmask = this->manual_ip_->subnet; - } else { + } else +#endif + { info.ip.addr = 0; info.gw.addr = 0; info.netmask.addr = 0; @@ -577,6 +581,7 @@ void EthernetComponent::start_connect_() { err = esp_netif_set_ip_info(this->eth_netif_, &info); ESPHL_ERROR_CHECK(err, "DHCPC set IP info error"); +#ifdef USE_ETHERNET_MANUAL_IP if (this->manual_ip_.has_value()) { LwIPLock lock; if (this->manual_ip_->dns1.is_set()) { @@ -589,7 +594,9 @@ void EthernetComponent::start_connect_() { d = this->manual_ip_->dns2; dns_setserver(1, &d); } - } else { + } else +#endif + { err = esp_netif_dhcpc_start(this->eth_netif_); if (err != ESP_ERR_ESP_NETIF_DHCP_ALREADY_STARTED) { ESPHL_ERROR_CHECK(err, "DHCPC start error"); @@ -687,7 +694,9 @@ void EthernetComponent::set_clk_mode(emac_rmii_clock_mode_t clk_mode) { this->cl void EthernetComponent::add_phy_register(PHYRegister register_value) { this->phy_registers_.push_back(register_value); } #endif void EthernetComponent::set_type(EthernetType type) { this->type_ = type; } +#ifdef USE_ETHERNET_MANUAL_IP void EthernetComponent::set_manual_ip(const ManualIP &manual_ip) { this->manual_ip_ = manual_ip; } +#endif // set_use_address() is guaranteed to be called during component setup by Python code generation, // so use_address_ will always be valid when get_use_address() is called - no fallback needed. diff --git a/esphome/components/ethernet/ethernet_component.h b/esphome/components/ethernet/ethernet_component.h index 31f9fa360a..bffed4dc4a 100644 --- a/esphome/components/ethernet/ethernet_component.h +++ b/esphome/components/ethernet/ethernet_component.h @@ -58,7 +58,6 @@ class EthernetComponent : public Component { void loop() override; void dump_config() override; float get_setup_priority() const override; - bool can_proceed() override; void on_powerdown() override { powerdown(); } bool is_connected(); @@ -83,7 +82,9 @@ class EthernetComponent : public Component { void add_phy_register(PHYRegister register_value); #endif void set_type(EthernetType type); +#ifdef USE_ETHERNET_MANUAL_IP void set_manual_ip(const ManualIP &manual_ip); +#endif void set_fixed_mac(const std::array &mac) { this->fixed_mac_ = mac; } network::IPAddresses get_ip_addresses(); @@ -138,7 +139,9 @@ class EthernetComponent : public Component { uint8_t mdc_pin_{23}; uint8_t mdio_pin_{18}; #endif +#ifdef USE_ETHERNET_MANUAL_IP optional manual_ip_{}; +#endif uint32_t connect_begin_; // Group all uint8_t types together (enums and bools) diff --git a/esphome/components/font/__init__.py b/esphome/components/font/__init__.py index ddcee14635..2667dbdbdf 100644 --- a/esphome/components/font/__init__.py +++ b/esphome/components/font/__init__.py @@ -36,7 +36,6 @@ from esphome.const import ( CONF_WEIGHT, ) from esphome.core import CORE, HexInt -from esphome.helpers import cpp_string_escape from esphome.types import ConfigType _LOGGER = logging.getLogger(__name__) @@ -50,7 +49,6 @@ font_ns = cg.esphome_ns.namespace("font") Font = font_ns.class_("Font") Glyph = font_ns.class_("Glyph") -GlyphData = font_ns.struct("GlyphData") CONF_BPP = "bpp" CONF_EXTRAS = "extras" @@ -463,7 +461,7 @@ FONT_SCHEMA = cv.Schema( ) ), cv.GenerateID(CONF_RAW_DATA_ID): cv.declare_id(cg.uint8), - cv.GenerateID(CONF_RAW_GLYPH_ID): cv.declare_id(GlyphData), + cv.GenerateID(CONF_RAW_GLYPH_ID): cv.declare_id(Glyph), }, ) @@ -488,6 +486,8 @@ class GlyphInfo: def glyph_to_glyphinfo(glyph, font, size, bpp): + # Convert to 32 bit unicode codepoint + glyph = ord(glyph) scale = 256 // (1 << bpp) if not font.is_scalable: sizes = [pt_to_px(x.size) for x in font.available_sizes] @@ -583,22 +583,15 @@ async def to_code(config): # Create the glyph table that points to data in the above array. glyph_initializer = [ - cg.StructInitializer( - GlyphData, - ( - "a_char", - cg.RawExpression(f"(const uint8_t *){cpp_string_escape(x.glyph)}"), - ), - ( - "data", - cg.RawExpression(f"{str(prog_arr)} + {str(y - len(x.bitmap_data))}"), - ), - ("advance", x.advance), - ("offset_x", x.offset_x), - ("offset_y", x.offset_y), - ("width", x.width), - ("height", x.height), - ) + [ + x.glyph, + prog_arr + (y - len(x.bitmap_data)), + x.advance, + x.offset_x, + x.offset_y, + x.width, + x.height, + ] for (x, y) in zip( glyph_args, list(accumulate([len(x.bitmap_data) for x in glyph_args])) ) diff --git a/esphome/components/font/font.cpp b/esphome/components/font/font.cpp index 8b2420ac07..5e3bf1dd20 100644 --- a/esphome/components/font/font.cpp +++ b/esphome/components/font/font.cpp @@ -6,133 +6,245 @@ namespace esphome { namespace font { - static const char *const TAG = "font"; -const uint8_t *Glyph::get_char() const { return this->glyph_data_->a_char; } -// Compare the char at the string position with this char. -// Return true if this char is less than or equal the other. -bool Glyph::compare_to(const uint8_t *str) const { - // 1 -> this->char_ - // 2 -> str - for (uint32_t i = 0;; i++) { - if (this->glyph_data_->a_char[i] == '\0') - return true; - if (str[i] == '\0') - return false; - if (this->glyph_data_->a_char[i] > str[i]) - return false; - if (this->glyph_data_->a_char[i] < str[i]) - return true; +#ifdef USE_LVGL_FONT +const uint8_t *Font::get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter) { + auto *fe = (Font *) font->dsc; + const auto *gd = fe->get_glyph_data_(unicode_letter); + if (gd == nullptr) { + return nullptr; } - // this should not happen - return false; -} -int Glyph::match_length(const uint8_t *str) const { - for (uint32_t i = 0;; i++) { - if (this->glyph_data_->a_char[i] == '\0') - return i; - if (str[i] != this->glyph_data_->a_char[i]) - return 0; - } - // this should not happen - return 0; -} -void Glyph::scan_area(int *x1, int *y1, int *width, int *height) const { - *x1 = this->glyph_data_->offset_x; - *y1 = this->glyph_data_->offset_y; - *width = this->glyph_data_->width; - *height = this->glyph_data_->height; + return gd->data; } -Font::Font(const GlyphData *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, +bool Font::get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next) { + auto *fe = (Font *) font->dsc; + const auto *gd = fe->get_glyph_data_(unicode_letter); + if (gd == nullptr) { + return false; + } + dsc->adv_w = gd->advance; + dsc->ofs_x = gd->offset_x; + dsc->ofs_y = fe->height_ - gd->height - gd->offset_y - fe->lv_font_.base_line; + dsc->box_w = gd->width; + dsc->box_h = gd->height; + dsc->is_placeholder = 0; + dsc->bpp = fe->get_bpp(); + return true; +} + +const Glyph *Font::get_glyph_data_(uint32_t unicode_letter) { + if (unicode_letter == this->last_letter_ && this->last_letter_ != 0) + return this->last_data_; + auto *glyph = this->find_glyph(unicode_letter); + if (glyph == nullptr) { + return nullptr; + } + this->last_data_ = glyph; + this->last_letter_ = unicode_letter; + return glyph; +} +#endif + +/** + * Attempt to extract a 32 bit Unicode codepoint from a UTF-8 string. + * If successful, return the codepoint and set the length to the number of bytes read. + * If the end of the string has been reached and a valid codepoint has not been found, return 0 and set the length to + * 0. + * + * @param utf8_str The input string + * @param length Pointer to length storage + * @return The extracted code point + */ +static uint32_t extract_unicode_codepoint(const char *utf8_str, size_t *length) { + // Safely cast to uint8_t* for correct bitwise operations on bytes + const uint8_t *current = reinterpret_cast(utf8_str); + uint32_t code_point = 0; + uint8_t c1 = *current++; + + // check for end of string + if (c1 == 0) { + *length = 0; + return 0; + } + + // --- 1-Byte Sequence: 0xxxxxxx (ASCII) --- + if (c1 < 0x80) { + // Valid ASCII byte. + code_point = c1; + // Optimization: No need to check for continuation bytes. + } + // --- 2-Byte Sequence: 110xxxxx 10xxxxxx --- + else if ((c1 & 0xE0) == 0xC0) { + uint8_t c2 = *current++; + + // Error Check 1: Check if c2 is a valid continuation byte (10xxxxxx) + if ((c2 & 0xC0) != 0x80) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x1F) << 6; + code_point |= (c2 & 0x3F); + + // Error Check 2: Overlong check (2-byte must be > 0x7F) + if (code_point <= 0x7F) { + *length = 0; + return 0; + } + } + // --- 3-Byte Sequence: 1110xxxx 10xxxxxx 10xxxxxx --- + else if ((c1 & 0xF0) == 0xE0) { + uint8_t c2 = *current++; + uint8_t c3 = *current++; + + // Error Check 1: Check continuation bytes + if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80)) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x0F) << 12; + code_point |= (c2 & 0x3F) << 6; + code_point |= (c3 & 0x3F); + + // Error Check 2: Overlong check (3-byte must be > 0x7FF) + // Also check for surrogates (0xD800-0xDFFF) + if (code_point <= 0x7FF || (code_point >= 0xD800 && code_point <= 0xDFFF)) { + *length = 0; + return 0; + } + } + // --- 4-Byte Sequence: 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx --- + else if ((c1 & 0xF8) == 0xF0) { + uint8_t c2 = *current++; + uint8_t c3 = *current++; + uint8_t c4 = *current++; + + // Error Check 1: Check continuation bytes + if (((c2 & 0xC0) != 0x80) || ((c3 & 0xC0) != 0x80) || ((c4 & 0xC0) != 0x80)) { + *length = 0; + return 0; + } + + code_point = (c1 & 0x07) << 18; + code_point |= (c2 & 0x3F) << 12; + code_point |= (c3 & 0x3F) << 6; + code_point |= (c4 & 0x3F); + + // Error Check 2: Overlong check (4-byte must be > 0xFFFF) + // Also check for valid Unicode range (must be <= 0x10FFFF) + if (code_point <= 0xFFFF || code_point > 0x10FFFF) { + *length = 0; + return 0; + } + } + // --- Invalid leading byte (e.g., 10xxxxxx or 11111xxx) --- + else { + *length = 0; + return 0; + } + *length = current - reinterpret_cast(utf8_str); + return code_point; +} + +Font::Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, uint8_t bpp) - : baseline_(baseline), + : glyphs_(ConstVector(data, data_nr)), + baseline_(baseline), height_(height), descender_(descender), linegap_(height - baseline - descender), xheight_(xheight), capheight_(capheight), bpp_(bpp) { - glyphs_.reserve(data_nr); - for (int i = 0; i < data_nr; ++i) - glyphs_.emplace_back(&data[i]); +#ifdef USE_LVGL_FONT + this->lv_font_.dsc = this; + this->lv_font_.line_height = this->get_height(); + this->lv_font_.base_line = this->lv_font_.line_height - this->get_baseline(); + this->lv_font_.get_glyph_dsc = get_glyph_dsc_cb; + this->lv_font_.get_glyph_bitmap = get_glyph_bitmap; + this->lv_font_.subpx = LV_FONT_SUBPX_NONE; + this->lv_font_.underline_position = -1; + this->lv_font_.underline_thickness = 1; +#endif } -int Font::match_next_glyph(const uint8_t *str, int *match_length) { + +const Glyph *Font::find_glyph(uint32_t codepoint) const { int lo = 0; int hi = this->glyphs_.size() - 1; while (lo != hi) { int mid = (lo + hi + 1) / 2; - if (this->glyphs_[mid].compare_to(str)) { + if (this->glyphs_[mid].is_less_or_equal(codepoint)) { lo = mid; } else { hi = mid - 1; } } - *match_length = this->glyphs_[lo].match_length(str); - if (*match_length <= 0) - return -1; - return lo; + auto *result = &this->glyphs_[lo]; + if (result->code_point == codepoint) + return result; + return nullptr; } + #ifdef USE_DISPLAY void Font::measure(const char *str, int *width, int *x_offset, int *baseline, int *height) { *baseline = this->baseline_; *height = this->height_; - int i = 0; int min_x = 0; bool has_char = false; int x = 0; - while (str[i] != '\0') { - int match_length; - int glyph_n = this->match_next_glyph((const uint8_t *) str + i, &match_length); - if (glyph_n < 0) { + for (;;) { + size_t length; + auto code_point = extract_unicode_codepoint(str, &length); + if (length == 0) + break; + str += length; + auto *glyph = this->find_glyph(code_point); + if (glyph == nullptr) { // Unknown char, skip - if (!this->get_glyphs().empty()) - x += this->get_glyphs()[0].glyph_data_->advance; - i++; + if (!this->glyphs_.empty()) + x += this->glyphs_[0].advance; continue; } - const Glyph &glyph = this->glyphs_[glyph_n]; if (!has_char) { - min_x = glyph.glyph_data_->offset_x; + min_x = glyph->offset_x; } else { - min_x = std::min(min_x, x + glyph.glyph_data_->offset_x); + min_x = std::min(min_x, x + glyph->offset_x); } - x += glyph.glyph_data_->advance; + x += glyph->advance; - i += match_length; has_char = true; } *x_offset = min_x; *width = x - min_x; } + void Font::print(int x_start, int y_start, display::Display *display, Color color, const char *text, Color background) { - int i = 0; int x_at = x_start; - int scan_x1, scan_y1, scan_width, scan_height; - while (text[i] != '\0') { - int match_length; - int glyph_n = this->match_next_glyph((const uint8_t *) text + i, &match_length); - if (glyph_n < 0) { + for (;;) { + size_t length; + auto code_point = extract_unicode_codepoint(text, &length); + if (length == 0) + break; + text += length; + auto *glyph = this->find_glyph(code_point); + if (glyph == nullptr) { // Unknown char, skip - ESP_LOGW(TAG, "Encountered character without representation in font: '%c'", text[i]); - if (!this->get_glyphs().empty()) { - uint8_t glyph_width = this->get_glyphs()[0].glyph_data_->advance; - display->filled_rectangle(x_at, y_start, glyph_width, this->height_, color); + ESP_LOGW(TAG, "Codepoint 0x%08" PRIx32 " not found in font", code_point); + if (!this->glyphs_.empty()) { + uint8_t glyph_width = this->glyphs_[0].advance; + display->rectangle(x_at, y_start, glyph_width, this->height_, color); x_at += glyph_width; } - - i++; continue; } - const Glyph &glyph = this->get_glyphs()[glyph_n]; - glyph.scan_area(&scan_x1, &scan_y1, &scan_width, &scan_height); - - const uint8_t *data = glyph.glyph_data_->data; - const int max_x = x_at + scan_x1 + scan_width; - const int max_y = y_start + scan_y1 + scan_height; + const uint8_t *data = glyph->data; + const int max_x = x_at + glyph->offset_x + glyph->width; + const int max_y = y_start + glyph->offset_y + glyph->height; uint8_t bitmask = 0; uint8_t pixel_data = 0; @@ -145,10 +257,10 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo auto b_g = (float) background.g; auto b_b = (float) background.b; auto b_w = (float) background.w; - for (int glyph_y = y_start + scan_y1; glyph_y != max_y; glyph_y++) { - for (int glyph_x = x_at + scan_x1; glyph_x != max_x; glyph_x++) { + for (int glyph_y = y_start + glyph->offset_y; glyph_y != max_y; glyph_y++) { + for (int glyph_x = x_at + glyph->offset_x; glyph_x != max_x; glyph_x++) { uint8_t pixel = 0; - for (int bit_num = 0; bit_num != this->bpp_; bit_num++) { + for (uint8_t bit_num = 0; bit_num != this->bpp_; bit_num++) { if (bitmask == 0) { pixel_data = progmem_read_byte(data++); bitmask = 0x80; @@ -168,12 +280,9 @@ void Font::print(int x_start, int y_start, display::Display *display, Color colo } } } - x_at += glyph.glyph_data_->advance; - - i += match_length; + x_at += glyph->advance; } } #endif - } // namespace font } // namespace esphome diff --git a/esphome/components/font/font.h b/esphome/components/font/font.h index 28832d647d..262ded3be4 100644 --- a/esphome/components/font/font.h +++ b/esphome/components/font/font.h @@ -6,14 +6,30 @@ #ifdef USE_DISPLAY #include "esphome/components/display/display.h" #endif +#ifdef USE_LVGL_FONT +#include +#endif namespace esphome { namespace font { class Font; -struct GlyphData { - const uint8_t *a_char; +class Glyph { + public: + constexpr Glyph(uint32_t code_point, const uint8_t *data, int advance, int offset_x, int offset_y, int width, + int height) + : code_point(code_point), + data(data), + advance(advance), + offset_x(offset_x), + offset_y(offset_y), + width(width), + height(height) {} + + bool is_less_or_equal(uint32_t other) const { return this->code_point <= other; } + + const uint32_t code_point; const uint8_t *data; int advance; int offset_x; @@ -22,26 +38,6 @@ struct GlyphData { int height; }; -class Glyph { - public: - Glyph(const GlyphData *data) : glyph_data_(data) {} - - const uint8_t *get_char() const; - - bool compare_to(const uint8_t *str) const; - - int match_length(const uint8_t *str) const; - - void scan_area(int *x1, int *y1, int *width, int *height) const; - - const GlyphData *get_glyph_data() const { return this->glyph_data_; } - - protected: - friend Font; - - const GlyphData *glyph_data_; -}; - class Font #ifdef USE_DISPLAY : public display::BaseFont @@ -50,8 +46,8 @@ class Font public: /** Construct the font with the given glyphs. * - * @param data A vector of glyphs, must be sorted lexicographically. - * @param data_nr The number of glyphs in data. + * @param data A list of glyphs, must be sorted lexicographically. + * @param data_nr The number of glyphs * @param baseline The y-offset from the top of the text to the baseline. * @param height The y-offset from the top of the text to the bottom. * @param descender The y-offset from the baseline to the lowest stroke in the font (e.g. from letters like g or p). @@ -59,10 +55,10 @@ class Font * @param capheight The height of capital letters, usually measured at the "X" glyph. * @param bpp The bits per pixel used for this font. Used to read data out of the glyph bitmaps. */ - Font(const GlyphData *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, + Font(const Glyph *data, int data_nr, int baseline, int height, int descender, int xheight, int capheight, uint8_t bpp = 1); - int match_next_glyph(const uint8_t *str, int *match_length); + const Glyph *find_glyph(uint32_t codepoint) const; #ifdef USE_DISPLAY void print(int x_start, int y_start, display::Display *display, Color color, const char *text, @@ -77,11 +73,14 @@ class Font inline int get_xheight() { return this->xheight_; } inline int get_capheight() { return this->capheight_; } inline int get_bpp() { return this->bpp_; } +#ifdef USE_LVGL_FONT + const lv_font_t *get_lv_font() const { return &this->lv_font_; } +#endif - const std::vector> &get_glyphs() const { return glyphs_; } + const ConstVector &get_glyphs() const { return glyphs_; } protected: - std::vector> glyphs_; + ConstVector glyphs_; int baseline_; int height_; int descender_; @@ -89,6 +88,14 @@ class Font int xheight_; int capheight_; uint8_t bpp_; // bits per pixel +#ifdef USE_LVGL_FONT + lv_font_t lv_font_{}; + static const uint8_t *get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter); + static bool get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next); + const Glyph *get_glyph_data_(uint32_t unicode_letter); + uint32_t last_letter_{}; + const Glyph *last_data_{}; +#endif }; } // namespace font diff --git a/esphome/components/gdk101/gdk101.cpp b/esphome/components/gdk101/gdk101.cpp index 6c218f03d9..617e2138fb 100644 --- a/esphome/components/gdk101/gdk101.cpp +++ b/esphome/components/gdk101/gdk101.cpp @@ -36,20 +36,20 @@ void GDK101Component::setup() { uint8_t data[2]; // first, reset the sensor if (!this->reset_sensor_(data)) { - this->status_set_error("Reset failed!"); + this->status_set_error(LOG_STR("Reset failed!")); this->mark_failed(); return; } // sensor should acknowledge success of the reset procedure if (data[0] != 1) { - this->status_set_error("Reset not acknowledged!"); + this->status_set_error(LOG_STR("Reset not acknowledged!")); this->mark_failed(); return; } delay(10); // read firmware version if (!this->read_fw_version_(data)) { - this->status_set_error("Failed to read firmware version"); + this->status_set_error(LOG_STR("Failed to read firmware version")); this->mark_failed(); return; } diff --git a/esphome/components/graph/graph.cpp b/esphome/components/graph/graph.cpp index 88bb306408..e3b9119108 100644 --- a/esphome/components/graph/graph.cpp +++ b/esphome/components/graph/graph.cpp @@ -337,7 +337,7 @@ void Graph::draw_legend(display::Display *buff, uint16_t x_offset, uint16_t y_of return; /// Plot border - if (this->border_) { + if (legend_->border_) { int w = legend_->width_; int h = legend_->height_; buff->horizontal_line(x_offset, y_offset, w, color); diff --git a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp index 992a86cc21..b11880a042 100644 --- a/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp +++ b/esphome/components/gt911/touchscreen/gt911_touchscreen.cpp @@ -79,13 +79,13 @@ void GT911Touchscreen::setup_internal_() { } } if (err != i2c::ERROR_OK) { - this->mark_failed("Calibration error"); + this->mark_failed(LOG_STR("Calibration error")); return; } } if (err != i2c::ERROR_OK) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } this->setup_done_ = true; diff --git a/esphome/components/hc8/__init__.py b/esphome/components/hc8/__init__.py new file mode 100644 index 0000000000..e1028456b0 --- /dev/null +++ b/esphome/components/hc8/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@omartijn"] diff --git a/esphome/components/hc8/hc8.cpp b/esphome/components/hc8/hc8.cpp new file mode 100644 index 0000000000..5b649c2735 --- /dev/null +++ b/esphome/components/hc8/hc8.cpp @@ -0,0 +1,99 @@ +#include "hc8.h" +#include "esphome/core/application.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" + +#include + +namespace esphome::hc8 { + +static const char *const TAG = "hc8"; +static const std::array HC8_COMMAND_GET_PPM{0x64, 0x69, 0x03, 0x5E, 0x4E}; +static const std::array HC8_COMMAND_CALIBRATE_PREAMBLE{0x11, 0x03, 0x03}; + +void HC8Component::setup() { + // send an initial query to the device, this will + // get it out of "active output mode", where it + // generates data every second + this->write_array(HC8_COMMAND_GET_PPM); + this->flush(); + + // ensure the buffer is empty + while (this->available()) + this->read(); +} + +void HC8Component::update() { + uint32_t now_ms = App.get_loop_component_start_time(); + uint32_t warmup_ms = this->warmup_seconds_ * 1000; + if (now_ms < warmup_ms) { + ESP_LOGW(TAG, "HC8 warming up, %" PRIu32 " s left", (warmup_ms - now_ms) / 1000); + this->status_set_warning(); + return; + } + + while (this->available()) + this->read(); + + this->write_array(HC8_COMMAND_GET_PPM); + this->flush(); + + // the sensor is a bit slow in responding, so trying to + // read immediately after sending a query will timeout + this->set_timeout(50, [this]() { + std::array response; + if (!this->read_array(response.data(), response.size())) { + ESP_LOGW(TAG, "Reading data from HC8 failed!"); + this->status_set_warning(); + return; + } + + if (response[0] != 0x64 || response[1] != 0x69) { + ESP_LOGW(TAG, "Invalid preamble from HC8!"); + this->status_set_warning(); + return; + } + + if (crc16(response.data(), 12) != encode_uint16(response[13], response[12])) { + ESP_LOGW(TAG, "HC8 Checksum mismatch"); + this->status_set_warning(); + return; + } + + this->status_clear_warning(); + + const uint16_t ppm = encode_uint16(response[5], response[4]); + ESP_LOGD(TAG, "HC8 Received CO₂=%uppm", ppm); + if (this->co2_sensor_ != nullptr) + this->co2_sensor_->publish_state(ppm); + }); +} + +void HC8Component::calibrate(uint16_t baseline) { + ESP_LOGD(TAG, "HC8 Calibrating baseline to %uppm", baseline); + + std::array command{}; + std::copy(begin(HC8_COMMAND_CALIBRATE_PREAMBLE), end(HC8_COMMAND_CALIBRATE_PREAMBLE), begin(command)); + command[3] = baseline >> 8; + command[4] = baseline; + command[5] = 0; + + // the last byte is a checksum over the data + for (uint8_t i = 0; i < 5; ++i) + command[5] -= command[i]; + + this->write_array(command); + this->flush(); +} + +float HC8Component::get_setup_priority() const { return setup_priority::DATA; } + +void HC8Component::dump_config() { + ESP_LOGCONFIG(TAG, "HC8:"); + LOG_SENSOR(" ", "CO2", this->co2_sensor_); + this->check_uart_settings(9600); + + ESP_LOGCONFIG(TAG, " Warmup time: %" PRIu32 " s", this->warmup_seconds_); +} + +} // namespace esphome::hc8 diff --git a/esphome/components/hc8/hc8.h b/esphome/components/hc8/hc8.h new file mode 100644 index 0000000000..7711fb8c97 --- /dev/null +++ b/esphome/components/hc8/hc8.h @@ -0,0 +1,37 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +#include + +namespace esphome::hc8 { + +class HC8Component : public PollingComponent, public uart::UARTDevice { + public: + float get_setup_priority() const override; + + void setup() override; + void update() override; + void dump_config() override; + + void calibrate(uint16_t baseline); + + void set_co2_sensor(sensor::Sensor *co2_sensor) { co2_sensor_ = co2_sensor; } + void set_warmup_seconds(uint32_t seconds) { warmup_seconds_ = seconds; } + + protected: + sensor::Sensor *co2_sensor_{nullptr}; + uint32_t warmup_seconds_{0}; +}; + +template class HC8CalibrateAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(uint16_t, baseline) + + void play(const Ts &...x) override { this->parent_->calibrate(this->baseline_.value(x...)); } +}; + +} // namespace esphome::hc8 diff --git a/esphome/components/hc8/sensor.py b/esphome/components/hc8/sensor.py new file mode 100644 index 0000000000..90698b2661 --- /dev/null +++ b/esphome/components/hc8/sensor.py @@ -0,0 +1,79 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import sensor, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_BASELINE, + CONF_CO2, + CONF_ID, + DEVICE_CLASS_CARBON_DIOXIDE, + ICON_MOLECULE_CO2, + STATE_CLASS_MEASUREMENT, + UNIT_PARTS_PER_MILLION, +) + +DEPENDENCIES = ["uart"] + +CONF_WARMUP_TIME = "warmup_time" + +hc8_ns = cg.esphome_ns.namespace("hc8") +HC8Component = hc8_ns.class_("HC8Component", cg.PollingComponent, uart.UARTDevice) +HC8CalibrateAction = hc8_ns.class_("HC8CalibrateAction", automation.Action) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HC8Component), + cv.Optional(CONF_CO2): sensor.sensor_schema( + unit_of_measurement=UNIT_PARTS_PER_MILLION, + icon=ICON_MOLECULE_CO2, + accuracy_decimals=0, + device_class=DEVICE_CLASS_CARBON_DIOXIDE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional( + CONF_WARMUP_TIME, default="75s" + ): cv.positive_time_period_seconds, + } + ) + .extend(cv.polling_component_schema("60s")) + .extend(uart.UART_DEVICE_SCHEMA) +) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "hc8", + baud_rate=9600, + require_rx=True, + require_tx=True, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + if co2 := config.get(CONF_CO2): + sens = await sensor.new_sensor(co2) + cg.add(var.set_co2_sensor(sens)) + + cg.add(var.set_warmup_seconds(config[CONF_WARMUP_TIME])) + + +CALIBRATION_ACTION_SCHEMA = cv.Schema( + { + cv.Required(CONF_ID): cv.use_id(HC8Component), + cv.Required(CONF_BASELINE): cv.templatable(cv.uint16_t), + } +) + + +@automation.register_action( + "hc8.calibrate", HC8CalibrateAction, CALIBRATION_ACTION_SCHEMA +) +async def hc8_calibration_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + template_ = await cg.templatable(config[CONF_BASELINE], args, cg.uint16) + cg.add(var.set_baseline(template_)) + return var diff --git a/esphome/components/hlk_fm22x/__init__.py b/esphome/components/hlk_fm22x/__init__.py new file mode 100644 index 0000000000..efd64b6513 --- /dev/null +++ b/esphome/components/hlk_fm22x/__init__.py @@ -0,0 +1,247 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_DIRECTION, + CONF_ID, + CONF_NAME, + CONF_ON_ENROLLMENT_DONE, + CONF_ON_ENROLLMENT_FAILED, + CONF_TRIGGER_ID, +) + +CODEOWNERS = ["@OnFreund"] +DEPENDENCIES = ["uart"] +AUTO_LOAD = ["binary_sensor", "sensor", "text_sensor"] +MULTI_CONF = True + +CONF_HLK_FM22X_ID = "hlk_fm22x_id" +CONF_FACE_ID = "face_id" +CONF_ON_FACE_SCAN_MATCHED = "on_face_scan_matched" +CONF_ON_FACE_SCAN_UNMATCHED = "on_face_scan_unmatched" +CONF_ON_FACE_SCAN_INVALID = "on_face_scan_invalid" +CONF_ON_FACE_INFO = "on_face_info" + +hlk_fm22x_ns = cg.esphome_ns.namespace("hlk_fm22x") +HlkFm22xComponent = hlk_fm22x_ns.class_( + "HlkFm22xComponent", cg.PollingComponent, uart.UARTDevice +) + +FaceScanMatchedTrigger = hlk_fm22x_ns.class_( + "FaceScanMatchedTrigger", automation.Trigger.template(cg.int16, cg.std_string) +) + +FaceScanUnmatchedTrigger = hlk_fm22x_ns.class_( + "FaceScanUnmatchedTrigger", automation.Trigger.template() +) + +FaceScanInvalidTrigger = hlk_fm22x_ns.class_( + "FaceScanInvalidTrigger", automation.Trigger.template(cg.uint8) +) + +FaceInfoTrigger = hlk_fm22x_ns.class_( + "FaceInfoTrigger", + automation.Trigger.template( + cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16, cg.int16 + ), +) + +EnrollmentDoneTrigger = hlk_fm22x_ns.class_( + "EnrollmentDoneTrigger", automation.Trigger.template(cg.int16, cg.uint8) +) + +EnrollmentFailedTrigger = hlk_fm22x_ns.class_( + "EnrollmentFailedTrigger", automation.Trigger.template(cg.uint8) +) + +EnrollmentAction = hlk_fm22x_ns.class_("EnrollmentAction", automation.Action) +DeleteAction = hlk_fm22x_ns.class_("DeleteAction", automation.Action) +DeleteAllAction = hlk_fm22x_ns.class_("DeleteAllAction", automation.Action) +ScanAction = hlk_fm22x_ns.class_("ScanAction", automation.Action) +ResetAction = hlk_fm22x_ns.class_("ResetAction", automation.Action) + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(HlkFm22xComponent), + cv.Optional(CONF_ON_FACE_SCAN_MATCHED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + FaceScanMatchedTrigger + ), + } + ), + cv.Optional(CONF_ON_FACE_SCAN_UNMATCHED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + FaceScanUnmatchedTrigger + ), + } + ), + cv.Optional(CONF_ON_FACE_SCAN_INVALID): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + FaceScanInvalidTrigger + ), + } + ), + cv.Optional(CONF_ON_FACE_INFO): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(FaceInfoTrigger), + } + ), + cv.Optional(CONF_ON_ENROLLMENT_DONE): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + EnrollmentDoneTrigger + ), + } + ), + cv.Optional(CONF_ON_ENROLLMENT_FAILED): automation.validate_automation( + { + cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id( + EnrollmentFailedTrigger + ), + } + ), + } + ) + .extend(cv.polling_component_schema("50ms")) + .extend(uart.UART_DEVICE_SCHEMA), +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await uart.register_uart_device(var, config) + + for conf in config.get(CONF_ON_FACE_SCAN_MATCHED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation( + trigger, [(cg.int16, "face_id"), (cg.std_string, "name")], conf + ) + + for conf in config.get(CONF_ON_FACE_SCAN_UNMATCHED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [], conf) + + for conf in config.get(CONF_ON_FACE_SCAN_INVALID, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(cg.uint8, "error")], conf) + + for conf in config.get(CONF_ON_FACE_INFO, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation( + trigger, + [ + (cg.int16, "status"), + (cg.int16, "left"), + (cg.int16, "top"), + (cg.int16, "right"), + (cg.int16, "bottom"), + (cg.int16, "yaw"), + (cg.int16, "pitch"), + (cg.int16, "roll"), + ], + conf, + ) + + for conf in config.get(CONF_ON_ENROLLMENT_DONE, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation( + trigger, [(cg.int16, "face_id"), (cg.uint8, "direction")], conf + ) + + for conf in config.get(CONF_ON_ENROLLMENT_FAILED, []): + trigger = cg.new_Pvariable(conf[CONF_TRIGGER_ID], var) + await automation.build_automation(trigger, [(cg.uint8, "error")], conf) + + +@automation.register_action( + "hlk_fm22x.enroll", + EnrollmentAction, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(HlkFm22xComponent), + cv.Required(CONF_NAME): cv.templatable(cv.string), + cv.Required(CONF_DIRECTION): cv.templatable(cv.uint8_t), + }, + key=CONF_NAME, + ), +) +async def hlk_fm22x_enroll_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + template_ = await cg.templatable(config[CONF_NAME], args, cg.std_string) + cg.add(var.set_name(template_)) + template_ = await cg.templatable(config[CONF_DIRECTION], args, cg.uint8) + cg.add(var.set_direction(template_)) + return var + + +@automation.register_action( + "hlk_fm22x.delete", + DeleteAction, + cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(HlkFm22xComponent), + cv.Required(CONF_FACE_ID): cv.templatable(cv.uint16_t), + }, + key=CONF_FACE_ID, + ), +) +async def hlk_fm22x_delete_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + + template_ = await cg.templatable(config[CONF_FACE_ID], args, cg.int16) + cg.add(var.set_face_id(template_)) + return var + + +@automation.register_action( + "hlk_fm22x.delete_all", + DeleteAllAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(HlkFm22xComponent), + } + ), +) +async def hlk_fm22x_delete_all_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action( + "hlk_fm22x.scan", + ScanAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(HlkFm22xComponent), + } + ), +) +async def hlk_fm22x_scan_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action( + "hlk_fm22x.reset", + ResetAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(HlkFm22xComponent), + } + ), +) +async def hlk_fm22x_reset_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var diff --git a/esphome/components/hlk_fm22x/binary_sensor.py b/esphome/components/hlk_fm22x/binary_sensor.py new file mode 100644 index 0000000000..3620f33ac0 --- /dev/null +++ b/esphome/components/hlk_fm22x/binary_sensor.py @@ -0,0 +1,21 @@ +import esphome.codegen as cg +from esphome.components import binary_sensor +import esphome.config_validation as cv +from esphome.const import CONF_ICON, ICON_KEY_PLUS + +from . import CONF_HLK_FM22X_ID, HlkFm22xComponent + +DEPENDENCIES = ["hlk_fm22x"] + +CONFIG_SCHEMA = binary_sensor.binary_sensor_schema().extend( + { + cv.GenerateID(CONF_HLK_FM22X_ID): cv.use_id(HlkFm22xComponent), + cv.Optional(CONF_ICON, default=ICON_KEY_PLUS): cv.icon, + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_HLK_FM22X_ID]) + var = await binary_sensor.new_binary_sensor(config) + cg.add(hub.set_enrolling_binary_sensor(var)) diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.cpp b/esphome/components/hlk_fm22x/hlk_fm22x.cpp new file mode 100644 index 0000000000..ab15a2340d --- /dev/null +++ b/esphome/components/hlk_fm22x/hlk_fm22x.cpp @@ -0,0 +1,325 @@ +#include "hlk_fm22x.h" +#include "esphome/core/log.h" +#include "esphome/core/helpers.h" +#include +#include + +namespace esphome::hlk_fm22x { + +static const char *const TAG = "hlk_fm22x"; + +void HlkFm22xComponent::setup() { + ESP_LOGCONFIG(TAG, "Setting up HLK-FM22X..."); + this->set_enrolling_(false); + while (this->available()) { + this->read(); + } + this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_STATUS); }); +} + +void HlkFm22xComponent::update() { + if (this->active_command_ != HlkFm22xCommand::NONE) { + if (this->wait_cycles_ > 600) { + ESP_LOGE(TAG, "Command 0x%.2X timed out", this->active_command_); + if (HlkFm22xCommand::RESET == this->active_command_) { + this->mark_failed(); + } else { + this->reset(); + } + } + } + this->recv_command_(); +} + +void HlkFm22xComponent::enroll_face(const std::string &name, HlkFm22xFaceDirection direction) { + if (name.length() > 31) { + ESP_LOGE(TAG, "enroll_face(): name too long '%s'", name.c_str()); + return; + } + ESP_LOGI(TAG, "Starting enrollment for %s", name.c_str()); + std::array data{}; + data[0] = 0; // admin + std::copy(name.begin(), name.end(), data.begin() + 1); + // Remaining bytes are already zero-initialized + data[33] = (uint8_t) direction; + data[34] = 10; // timeout + this->send_command_(HlkFm22xCommand::ENROLL, data.data(), data.size()); + this->set_enrolling_(true); +} + +void HlkFm22xComponent::scan_face() { + ESP_LOGI(TAG, "Verify face"); + static const uint8_t DATA[] = {0, 0}; + this->send_command_(HlkFm22xCommand::VERIFY, DATA, sizeof(DATA)); +} + +void HlkFm22xComponent::delete_face(int16_t face_id) { + ESP_LOGI(TAG, "Deleting face in slot %d", face_id); + const uint8_t data[] = {(uint8_t) (face_id >> 8), (uint8_t) (face_id & 0xFF)}; + this->send_command_(HlkFm22xCommand::DELETE_FACE, data, sizeof(data)); +} + +void HlkFm22xComponent::delete_all_faces() { + ESP_LOGI(TAG, "Deleting all stored faces"); + this->send_command_(HlkFm22xCommand::DELETE_ALL_FACES); +} + +void HlkFm22xComponent::get_face_count_() { + ESP_LOGD(TAG, "Getting face count"); + this->send_command_(HlkFm22xCommand::GET_ALL_FACE_IDS); +} + +void HlkFm22xComponent::reset() { + ESP_LOGI(TAG, "Resetting module"); + this->active_command_ = HlkFm22xCommand::NONE; + this->wait_cycles_ = 0; + this->set_enrolling_(false); + this->send_command_(HlkFm22xCommand::RESET); +} + +void HlkFm22xComponent::send_command_(HlkFm22xCommand command, const uint8_t *data, size_t size) { + ESP_LOGV(TAG, "Send command: 0x%.2X", command); + if (this->active_command_ != HlkFm22xCommand::NONE) { + ESP_LOGW(TAG, "Command 0x%.2X already active", this->active_command_); + return; + } + this->wait_cycles_ = 0; + this->active_command_ = command; + while (this->available()) + this->read(); + this->write((uint8_t) (START_CODE >> 8)); + this->write((uint8_t) (START_CODE & 0xFF)); + this->write((uint8_t) command); + uint16_t data_size = size; + this->write((uint8_t) (data_size >> 8)); + this->write((uint8_t) (data_size & 0xFF)); + + uint8_t checksum = 0; + checksum ^= (uint8_t) command; + checksum ^= (data_size >> 8); + checksum ^= (data_size & 0xFF); + for (size_t i = 0; i < size; i++) { + this->write(data[i]); + checksum ^= data[i]; + } + + this->write(checksum); + this->active_command_ = command; + this->wait_cycles_ = 0; +} + +void HlkFm22xComponent::recv_command_() { + uint8_t byte, checksum = 0; + uint16_t length = 0; + + if (this->available() < 7) { + ++this->wait_cycles_; + return; + } + this->wait_cycles_ = 0; + + if ((this->read() != (uint8_t) (START_CODE >> 8)) || (this->read() != (uint8_t) (START_CODE & 0xFF))) { + ESP_LOGE(TAG, "Invalid start code"); + return; + } + + byte = this->read(); + checksum ^= byte; + HlkFm22xResponseType response_type = (HlkFm22xResponseType) byte; + + byte = this->read(); + checksum ^= byte; + length = byte << 8; + byte = this->read(); + checksum ^= byte; + length |= byte; + + std::vector data; + data.reserve(length); + for (uint16_t idx = 0; idx < length; ++idx) { + byte = this->read(); + checksum ^= byte; + data.push_back(byte); + } + + ESP_LOGV(TAG, "Recv type: 0x%.2X, data: %s", response_type, format_hex_pretty(data).c_str()); + + byte = this->read(); + if (byte != checksum) { + ESP_LOGE(TAG, "Invalid checksum for data. Calculated: 0x%.2X, Received: 0x%.2X", checksum, byte); + return; + } + switch (response_type) { + case HlkFm22xResponseType::NOTE: + this->handle_note_(data); + break; + case HlkFm22xResponseType::REPLY: + this->handle_reply_(data); + break; + default: + ESP_LOGW(TAG, "Unexpected response type: 0x%.2X", response_type); + break; + } +} + +void HlkFm22xComponent::handle_note_(const std::vector &data) { + switch (data[0]) { + case HlkFm22xNoteType::FACE_STATE: + if (data.size() < 17) { + ESP_LOGE(TAG, "Invalid face note data size: %u", data.size()); + break; + } + { + int16_t info[8]; + uint8_t offset = 1; + for (int16_t &i : info) { + i = ((int16_t) data[offset + 1] << 8) | data[offset]; + offset += 2; + } + ESP_LOGV(TAG, "Face state: status: %d, left: %d, top: %d, right: %d, bottom: %d, yaw: %d, pitch: %d, roll: %d", + info[0], info[1], info[2], info[3], info[4], info[5], info[6], info[7]); + this->face_info_callback_.call(info[0], info[1], info[2], info[3], info[4], info[5], info[6], info[7]); + } + break; + case HlkFm22xNoteType::READY: + ESP_LOGE(TAG, "Command 0x%.2X timed out", this->active_command_); + switch (this->active_command_) { + case HlkFm22xCommand::ENROLL: + this->set_enrolling_(false); + this->enrollment_failed_callback_.call(HlkFm22xResult::FAILED4_TIMEOUT); + break; + case HlkFm22xCommand::VERIFY: + this->face_scan_invalid_callback_.call(HlkFm22xResult::FAILED4_TIMEOUT); + break; + default: + break; + } + this->active_command_ = HlkFm22xCommand::NONE; + this->wait_cycles_ = 0; + break; + default: + ESP_LOGW(TAG, "Unhandled note: 0x%.2X", data[0]); + break; + } +} + +void HlkFm22xComponent::handle_reply_(const std::vector &data) { + auto expected = this->active_command_; + this->active_command_ = HlkFm22xCommand::NONE; + if (data[0] != (uint8_t) expected) { + ESP_LOGE(TAG, "Unexpected response command. Expected: 0x%.2X, Received: 0x%.2X", expected, data[0]); + return; + } + + if (data[1] != HlkFm22xResult::SUCCESS) { + ESP_LOGE(TAG, "Command <0x%.2X> failed. Error: 0x%.2X", data[0], data[1]); + switch (expected) { + case HlkFm22xCommand::ENROLL: + this->set_enrolling_(false); + this->enrollment_failed_callback_.call(data[1]); + break; + case HlkFm22xCommand::VERIFY: + if (data[1] == HlkFm22xResult::REJECTED) { + this->face_scan_unmatched_callback_.call(); + } else { + this->face_scan_invalid_callback_.call(data[1]); + } + break; + default: + break; + } + return; + } + switch (expected) { + case HlkFm22xCommand::VERIFY: { + int16_t face_id = ((int16_t) data[2] << 8) | data[3]; + std::string name(data.begin() + 4, data.begin() + 36); + ESP_LOGD(TAG, "Face verified. ID: %d, name: %s", face_id, name.c_str()); + if (this->last_face_id_sensor_ != nullptr) { + this->last_face_id_sensor_->publish_state(face_id); + } + if (this->last_face_name_text_sensor_ != nullptr) { + this->last_face_name_text_sensor_->publish_state(name); + } + this->face_scan_matched_callback_.call(face_id, name); + break; + } + case HlkFm22xCommand::ENROLL: { + int16_t face_id = ((int16_t) data[2] << 8) | data[3]; + HlkFm22xFaceDirection direction = (HlkFm22xFaceDirection) data[4]; + ESP_LOGI(TAG, "Face enrolled. ID: %d, Direction: 0x%.2X", face_id, direction); + this->enrollment_done_callback_.call(face_id, (uint8_t) direction); + this->set_enrolling_(false); + this->defer([this]() { this->get_face_count_(); }); + break; + } + case HlkFm22xCommand::GET_STATUS: + if (this->status_sensor_ != nullptr) { + this->status_sensor_->publish_state(data[2]); + } + this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_VERSION); }); + break; + case HlkFm22xCommand::GET_VERSION: + if (this->version_text_sensor_ != nullptr) { + std::string version(data.begin() + 2, data.end()); + this->version_text_sensor_->publish_state(version); + } + this->defer([this]() { this->get_face_count_(); }); + break; + case HlkFm22xCommand::GET_ALL_FACE_IDS: + if (this->face_count_sensor_ != nullptr) { + this->face_count_sensor_->publish_state(data[2]); + } + break; + case HlkFm22xCommand::DELETE_FACE: + ESP_LOGI(TAG, "Deleted face"); + break; + case HlkFm22xCommand::DELETE_ALL_FACES: + ESP_LOGI(TAG, "Deleted all faces"); + break; + case HlkFm22xCommand::RESET: + ESP_LOGI(TAG, "Module reset"); + this->defer([this]() { this->send_command_(HlkFm22xCommand::GET_STATUS); }); + break; + default: + ESP_LOGW(TAG, "Unhandled command: 0x%.2X", this->active_command_); + break; + } +} + +void HlkFm22xComponent::set_enrolling_(bool enrolling) { + if (this->enrolling_binary_sensor_ != nullptr) { + this->enrolling_binary_sensor_->publish_state(enrolling); + } +} + +void HlkFm22xComponent::dump_config() { + ESP_LOGCONFIG(TAG, "HLK_FM22X:"); + LOG_UPDATE_INTERVAL(this); + if (this->version_text_sensor_) { + LOG_TEXT_SENSOR(" ", "Version", this->version_text_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %s", this->version_text_sensor_->get_state().c_str()); + } + if (this->enrolling_binary_sensor_) { + LOG_BINARY_SENSOR(" ", "Enrolling", this->enrolling_binary_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %s", this->enrolling_binary_sensor_->state ? "ON" : "OFF"); + } + if (this->face_count_sensor_) { + LOG_SENSOR(" ", "Face Count", this->face_count_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %u", (uint16_t) this->face_count_sensor_->get_state()); + } + if (this->status_sensor_) { + LOG_SENSOR(" ", "Status", this->status_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %u", (uint8_t) this->status_sensor_->get_state()); + } + if (this->last_face_id_sensor_) { + LOG_SENSOR(" ", "Last Face ID", this->last_face_id_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %u", (int16_t) this->last_face_id_sensor_->get_state()); + } + if (this->last_face_name_text_sensor_) { + LOG_TEXT_SENSOR(" ", "Last Face Name", this->last_face_name_text_sensor_); + ESP_LOGCONFIG(TAG, " Current Value: %s", this->last_face_name_text_sensor_->get_state().c_str()); + } +} + +} // namespace esphome::hlk_fm22x diff --git a/esphome/components/hlk_fm22x/hlk_fm22x.h b/esphome/components/hlk_fm22x/hlk_fm22x.h new file mode 100644 index 0000000000..9c981d3c44 --- /dev/null +++ b/esphome/components/hlk_fm22x/hlk_fm22x.h @@ -0,0 +1,224 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/automation.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/binary_sensor/binary_sensor.h" +#include "esphome/components/text_sensor/text_sensor.h" +#include "esphome/components/uart/uart.h" + +#include +#include + +namespace esphome::hlk_fm22x { + +static const uint16_t START_CODE = 0xEFAA; +enum HlkFm22xCommand { + NONE = 0x00, + RESET = 0x10, + GET_STATUS = 0x11, + VERIFY = 0x12, + ENROLL = 0x13, + DELETE_FACE = 0x20, + DELETE_ALL_FACES = 0x21, + GET_ALL_FACE_IDS = 0x24, + GET_VERSION = 0x30, + GET_SERIAL_NUMBER = 0x93, +}; + +enum HlkFm22xResponseType { + REPLY = 0x00, + NOTE = 0x01, + IMAGE = 0x02, +}; + +enum HlkFm22xNoteType { + READY = 0x00, + FACE_STATE = 0x01, +}; + +enum HlkFm22xResult { + SUCCESS = 0x00, + REJECTED = 0x01, + ABORTED = 0x02, + FAILED4_CAMERA = 0x04, + FAILED4_UNKNOWNREASON = 0x05, + FAILED4_INVALIDPARAM = 0x06, + FAILED4_NOMEMORY = 0x07, + FAILED4_UNKNOWNUSER = 0x08, + FAILED4_MAXUSER = 0x09, + FAILED4_FACEENROLLED = 0x0A, + FAILED4_LIVENESSCHECK = 0x0C, + FAILED4_TIMEOUT = 0x0D, + FAILED4_AUTHORIZATION = 0x0E, + FAILED4_READ_FILE = 0x13, + FAILED4_WRITE_FILE = 0x14, + FAILED4_NO_ENCRYPT = 0x15, + FAILED4_NO_RGBIMAGE = 0x17, + FAILED4_JPGPHOTO_LARGE = 0x18, + FAILED4_JPGPHOTO_SMALL = 0x19, +}; + +enum HlkFm22xFaceDirection { + FACE_DIRECTION_UNDEFINED = 0x00, + FACE_DIRECTION_MIDDLE = 0x01, + FACE_DIRECTION_RIGHT = 0x02, + FACE_DIRECTION_LEFT = 0x04, + FACE_DIRECTION_DOWN = 0x08, + FACE_DIRECTION_UP = 0x10, +}; + +class HlkFm22xComponent : public PollingComponent, public uart::UARTDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + void set_face_count_sensor(sensor::Sensor *face_count_sensor) { this->face_count_sensor_ = face_count_sensor; } + void set_status_sensor(sensor::Sensor *status_sensor) { this->status_sensor_ = status_sensor; } + void set_last_face_id_sensor(sensor::Sensor *last_face_id_sensor) { + this->last_face_id_sensor_ = last_face_id_sensor; + } + void set_last_face_name_text_sensor(text_sensor::TextSensor *last_face_name_text_sensor) { + this->last_face_name_text_sensor_ = last_face_name_text_sensor; + } + void set_enrolling_binary_sensor(binary_sensor::BinarySensor *enrolling_binary_sensor) { + this->enrolling_binary_sensor_ = enrolling_binary_sensor; + } + void set_version_text_sensor(text_sensor::TextSensor *version_text_sensor) { + this->version_text_sensor_ = version_text_sensor; + } + void add_on_face_scan_matched_callback(std::function callback) { + this->face_scan_matched_callback_.add(std::move(callback)); + } + void add_on_face_scan_unmatched_callback(std::function callback) { + this->face_scan_unmatched_callback_.add(std::move(callback)); + } + void add_on_face_scan_invalid_callback(std::function callback) { + this->face_scan_invalid_callback_.add(std::move(callback)); + } + void add_on_face_info_callback( + std::function callback) { + this->face_info_callback_.add(std::move(callback)); + } + void add_on_enrollment_done_callback(std::function callback) { + this->enrollment_done_callback_.add(std::move(callback)); + } + void add_on_enrollment_failed_callback(std::function callback) { + this->enrollment_failed_callback_.add(std::move(callback)); + } + + void enroll_face(const std::string &name, HlkFm22xFaceDirection direction); + void scan_face(); + void delete_face(int16_t face_id); + void delete_all_faces(); + void reset(); + + protected: + void get_face_count_(); + void send_command_(HlkFm22xCommand command, const uint8_t *data = nullptr, size_t size = 0); + void recv_command_(); + void handle_note_(const std::vector &data); + void handle_reply_(const std::vector &data); + void set_enrolling_(bool enrolling); + + HlkFm22xCommand active_command_ = HlkFm22xCommand::NONE; + uint16_t wait_cycles_ = 0; + sensor::Sensor *face_count_sensor_{nullptr}; + sensor::Sensor *status_sensor_{nullptr}; + sensor::Sensor *last_face_id_sensor_{nullptr}; + binary_sensor::BinarySensor *enrolling_binary_sensor_{nullptr}; + text_sensor::TextSensor *last_face_name_text_sensor_{nullptr}; + text_sensor::TextSensor *version_text_sensor_{nullptr}; + CallbackManager face_scan_invalid_callback_; + CallbackManager face_scan_matched_callback_; + CallbackManager face_scan_unmatched_callback_; + CallbackManager face_info_callback_; + CallbackManager enrollment_done_callback_; + CallbackManager enrollment_failed_callback_; +}; + +class FaceScanMatchedTrigger : public Trigger { + public: + explicit FaceScanMatchedTrigger(HlkFm22xComponent *parent) { + parent->add_on_face_scan_matched_callback( + [this](int16_t face_id, const std::string &name) { this->trigger(face_id, name); }); + } +}; + +class FaceScanUnmatchedTrigger : public Trigger<> { + public: + explicit FaceScanUnmatchedTrigger(HlkFm22xComponent *parent) { + parent->add_on_face_scan_unmatched_callback([this]() { this->trigger(); }); + } +}; + +class FaceScanInvalidTrigger : public Trigger { + public: + explicit FaceScanInvalidTrigger(HlkFm22xComponent *parent) { + parent->add_on_face_scan_invalid_callback([this](uint8_t error) { this->trigger(error); }); + } +}; + +class FaceInfoTrigger : public Trigger { + public: + explicit FaceInfoTrigger(HlkFm22xComponent *parent) { + parent->add_on_face_info_callback( + [this](int16_t status, int16_t left, int16_t top, int16_t right, int16_t bottom, int16_t yaw, int16_t pitch, + int16_t roll) { this->trigger(status, left, top, right, bottom, yaw, pitch, roll); }); + } +}; + +class EnrollmentDoneTrigger : public Trigger { + public: + explicit EnrollmentDoneTrigger(HlkFm22xComponent *parent) { + parent->add_on_enrollment_done_callback( + [this](int16_t face_id, uint8_t direction) { this->trigger(face_id, direction); }); + } +}; + +class EnrollmentFailedTrigger : public Trigger { + public: + explicit EnrollmentFailedTrigger(HlkFm22xComponent *parent) { + parent->add_on_enrollment_failed_callback([this](uint8_t error) { this->trigger(error); }); + } +}; + +template class EnrollmentAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(std::string, name) + TEMPLATABLE_VALUE(uint8_t, direction) + + void play(const Ts &...x) override { + auto name = this->name_.value(x...); + auto direction = (HlkFm22xFaceDirection) this->direction_.value(x...); + this->parent_->enroll_face(name, direction); + } +}; + +template class DeleteAction : public Action, public Parented { + public: + TEMPLATABLE_VALUE(int16_t, face_id) + + void play(const Ts &...x) override { + auto face_id = this->face_id_.value(x...); + this->parent_->delete_face(face_id); + } +}; + +template class DeleteAllAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->delete_all_faces(); } +}; + +template class ScanAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->scan_face(); } +}; + +template class ResetAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->reset(); } +}; + +} // namespace esphome::hlk_fm22x diff --git a/esphome/components/hlk_fm22x/sensor.py b/esphome/components/hlk_fm22x/sensor.py new file mode 100644 index 0000000000..e14b45599f --- /dev/null +++ b/esphome/components/hlk_fm22x/sensor.py @@ -0,0 +1,47 @@ +import esphome.codegen as cg +from esphome.components import sensor +import esphome.config_validation as cv +from esphome.const import CONF_STATUS, ENTITY_CATEGORY_DIAGNOSTIC, ICON_ACCOUNT + +from . import CONF_HLK_FM22X_ID, HlkFm22xComponent + +DEPENDENCIES = ["hlk_fm22x"] + +CONF_FACE_COUNT = "face_count" +CONF_LAST_FACE_ID = "last_face_id" +ICON_FACE = "mdi:face-recognition" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HLK_FM22X_ID): cv.use_id(HlkFm22xComponent), + cv.Optional(CONF_FACE_COUNT): sensor.sensor_schema( + icon=ICON_FACE, + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_STATUS): sensor.sensor_schema( + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_LAST_FACE_ID): sensor.sensor_schema( + icon=ICON_ACCOUNT, + accuracy_decimals=0, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_HLK_FM22X_ID]) + + for key in [ + CONF_FACE_COUNT, + CONF_STATUS, + CONF_LAST_FACE_ID, + ]: + if key not in config: + continue + conf = config[key] + sens = await sensor.new_sensor(conf) + cg.add(getattr(hub, f"set_{key}_sensor")(sens)) diff --git a/esphome/components/hlk_fm22x/text_sensor.py b/esphome/components/hlk_fm22x/text_sensor.py new file mode 100644 index 0000000000..06da61c8b3 --- /dev/null +++ b/esphome/components/hlk_fm22x/text_sensor.py @@ -0,0 +1,42 @@ +import esphome.codegen as cg +from esphome.components import text_sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_VERSION, + ENTITY_CATEGORY_DIAGNOSTIC, + ICON_ACCOUNT, + ICON_RESTART, +) + +from . import CONF_HLK_FM22X_ID, HlkFm22xComponent + +DEPENDENCIES = ["hlk_fm22x"] + +CONF_LAST_FACE_NAME = "last_face_name" + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(CONF_HLK_FM22X_ID): cv.use_id(HlkFm22xComponent), + cv.Optional(CONF_VERSION): text_sensor.text_sensor_schema( + icon=ICON_RESTART, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_LAST_FACE_NAME): text_sensor.text_sensor_schema( + icon=ICON_ACCOUNT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } +) + + +async def to_code(config): + hub = await cg.get_variable(config[CONF_HLK_FM22X_ID]) + for key in [ + CONF_VERSION, + CONF_LAST_FACE_NAME, + ]: + if key not in config: + continue + conf = config[key] + sens = await text_sensor.new_text_sensor(conf) + cg.add(getattr(hub, f"set_{key}_text_sensor")(sens)) diff --git a/esphome/components/homeassistant/time/homeassistant_time.cpp b/esphome/components/homeassistant/time/homeassistant_time.cpp index 0a91a2f63d..e72c5a21f5 100644 --- a/esphome/components/homeassistant/time/homeassistant_time.cpp +++ b/esphome/components/homeassistant/time/homeassistant_time.cpp @@ -7,10 +7,8 @@ namespace homeassistant { static const char *const TAG = "homeassistant.time"; void HomeassistantTime::dump_config() { - ESP_LOGCONFIG(TAG, - "Home Assistant Time:\n" - " Timezone: '%s'", - this->timezone_.c_str()); + ESP_LOGCONFIG(TAG, "Home Assistant Time"); + RealTimeClock::dump_config(); } float HomeassistantTime::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 4d9e868c74..4552fcc9df 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -7,7 +7,6 @@ #include "esphome/components/md5/md5.h" #include "esphome/components/watchdog/watchdog.h" #include "esphome/components/ota/ota_backend.h" -#include "esphome/components/ota/ota_backend_arduino_esp32.h" #include "esphome/components/ota/ota_backend_arduino_esp8266.h" #include "esphome/components/ota/ota_backend_arduino_rp2040.h" #include "esphome/components/ota/ota_backend_esp_idf.h" diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index 06aa6da6a4..c91b0eba73 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -29,7 +29,7 @@ void HttpRequestUpdate::setup() { this->publish_state(); } else if (state == ota::OTAState::OTA_ABORT || state == ota::OTAState::OTA_ERROR) { this->state_ = update::UPDATE_STATE_AVAILABLE; - this->status_set_error("Failed to install firmware"); + this->status_set_error(LOG_STR("Failed to install firmware")); this->publish_state(); } }); @@ -49,18 +49,19 @@ void HttpRequestUpdate::update_task(void *params) { auto container = this_update->request_parent_->get(this_update->source_url_); if (container == nullptr || container->status_code != HTTP_STATUS_OK) { - std::string msg = str_sprintf("Failed to fetch manifest from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to fetch manifest from %s", this_update->source_url_.c_str()); // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update, msg]() { this_update->status_set_error(msg.c_str()); }); + this_update->defer([this_update]() { this_update->status_set_error(LOG_STR("Failed to fetch manifest")); }); UPDATE_RETURN; } RAMAllocator allocator; uint8_t *data = allocator.allocate(container->content_length); if (data == nullptr) { - std::string msg = str_sprintf("Failed to allocate %zu bytes for manifest", container->content_length); + ESP_LOGE(TAG, "Failed to allocate %zu bytes for manifest", container->content_length); // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update, msg]() { this_update->status_set_error(msg.c_str()); }); + this_update->defer( + [this_update]() { this_update->status_set_error(LOG_STR("Failed to allocate memory for manifest")); }); container->end(); UPDATE_RETURN; } @@ -121,9 +122,9 @@ void HttpRequestUpdate::update_task(void *params) { } if (!valid) { - std::string msg = str_sprintf("Failed to parse JSON from %s", this_update->source_url_.c_str()); + ESP_LOGE(TAG, "Failed to parse JSON from %s", this_update->source_url_.c_str()); // Defer to main loop to avoid race condition on component_state_ read-modify-write - this_update->defer([this_update, msg]() { this_update->status_set_error(msg.c_str()); }); + this_update->defer([this_update]() { this_update->status_set_error(LOG_STR("Failed to parse manifest JSON")); }); UPDATE_RETURN; } diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 6308923759..738568cd3c 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -47,18 +47,20 @@ MULTI_CONF = True def _bus_declare_type(value): + if CORE.is_esp32: + return cv.declare_id(IDFI2CBus)(value) if CORE.using_arduino: return cv.declare_id(ArduinoI2CBus)(value) - if CORE.using_esp_idf: - return cv.declare_id(IDFI2CBus)(value) if CORE.using_zephyr: return cv.declare_id(ZephyrI2CBus)(value) raise NotImplementedError def validate_config(config): - if CORE.using_esp_idf: - return cv.require_framework_version(esp_idf=cv.Version(5, 4, 2))(config) + if CORE.is_esp32: + return cv.require_framework_version( + esp_idf=cv.Version(5, 4, 2), esp32_arduino=cv.Version(3, 2, 1) + )(config) return config @@ -67,12 +69,12 @@ CONFIG_SCHEMA = cv.All( { cv.GenerateID(): _bus_declare_type, cv.Optional(CONF_SDA, default="SDA"): pins.internal_gpio_pin_number, - cv.SplitDefault(CONF_SDA_PULLUP_ENABLED, esp32_idf=True): cv.All( - cv.only_with_esp_idf, cv.boolean + 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_PULLUP_ENABLED, esp32_idf=True): cv.All( - cv.only_with_esp_idf, cv.boolean + cv.SplitDefault(CONF_SCL_PULLUP_ENABLED, esp32=True): cv.All( + cv.only_on_esp32, cv.boolean ), cv.SplitDefault( CONF_FREQUENCY, @@ -151,7 +153,7 @@ async def to_code(config): 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: + if CORE.using_arduino and not CORE.is_esp32: cg.add_library("Wire", None) @@ -248,14 +250,16 @@ def final_validate_device_schema( FILTER_SOURCE_FILES = filter_source_files_from_platform( { "i2c_bus_arduino.cpp": { - PlatformFramework.ESP32_ARDUINO, PlatformFramework.ESP8266_ARDUINO, PlatformFramework.RP2040_ARDUINO, PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, }, - "i2c_bus_esp_idf.cpp": {PlatformFramework.ESP32_IDF}, + "i2c_bus_esp_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, "i2c_bus_zephyr.cpp": {PlatformFramework.NRF52_ZEPHYR}, } ) diff --git a/esphome/components/i2c/i2c_bus_arduino.cpp b/esphome/components/i2c/i2c_bus_arduino.cpp index 221423418b..1579020c9b 100644 --- a/esphome/components/i2c/i2c_bus_arduino.cpp +++ b/esphome/components/i2c/i2c_bus_arduino.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_ESP32) #include "i2c_bus_arduino.h" #include @@ -15,16 +15,7 @@ static const char *const TAG = "i2c.arduino"; void ArduinoI2CBus::setup() { recover_(); -#if defined(USE_ESP32) - static uint8_t next_bus_num = 0; - if (next_bus_num == 0) { - wire_ = &Wire; - } else { - wire_ = new TwoWire(next_bus_num); // NOLINT(cppcoreguidelines-owning-memory) - } - this->port_ = next_bus_num; - next_bus_num++; -#elif defined(USE_ESP8266) +#if defined(USE_ESP8266) wire_ = new TwoWire(); // NOLINT(cppcoreguidelines-owning-memory) #elif defined(USE_RP2040) static bool first = true; @@ -54,10 +45,7 @@ void ArduinoI2CBus::set_pins_and_clock_() { wire_->begin(static_cast(sda_pin_), static_cast(scl_pin_)); #endif if (timeout_ > 0) { // if timeout specified in yaml -#if defined(USE_ESP32) - // https://github.com/espressif/arduino-esp32/blob/master/libraries/Wire/src/Wire.cpp - wire_->setTimeOut(timeout_ / 1000); // unit: ms -#elif defined(USE_ESP8266) +#if defined(USE_ESP8266) // https://github.com/esp8266/Arduino/blob/master/libraries/Wire/Wire.h wire_->setClockStretchLimit(timeout_); // unit: us #elif defined(USE_RP2040) @@ -76,9 +64,7 @@ void ArduinoI2CBus::dump_config() { " Frequency: %u Hz", this->sda_pin_, this->scl_pin_, this->frequency_); if (timeout_ > 0) { -#if defined(USE_ESP32) - ESP_LOGCONFIG(TAG, " Timeout: %u ms", this->timeout_ / 1000); -#elif defined(USE_ESP8266) +#if defined(USE_ESP8266) ESP_LOGCONFIG(TAG, " Timeout: %u us", this->timeout_); #elif defined(USE_RP2040) ESP_LOGCONFIG(TAG, " Timeout: %u ms", this->timeout_ / 1000); @@ -275,4 +261,4 @@ void ArduinoI2CBus::recover_() { } // namespace i2c } // namespace esphome -#endif // USE_ESP_IDF +#endif // defined(USE_ARDUINO) && !defined(USE_ESP32) diff --git a/esphome/components/i2c/i2c_bus_arduino.h b/esphome/components/i2c/i2c_bus_arduino.h index b441828353..2d69e7684c 100644 --- a/esphome/components/i2c/i2c_bus_arduino.h +++ b/esphome/components/i2c/i2c_bus_arduino.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ARDUINO +#if defined(USE_ARDUINO) && !defined(USE_ESP32) #include #include "esphome/core/component.h" @@ -29,7 +29,7 @@ class ArduinoI2CBus : public InternalI2CBus, public Component { void set_frequency(uint32_t frequency) { frequency_ = frequency; } void set_timeout(uint32_t timeout) { timeout_ = timeout; } - int get_port() const override { return this->port_; } + int get_port() const override { return 0; } private: void recover_(); @@ -37,7 +37,6 @@ class ArduinoI2CBus : public InternalI2CBus, public Component { RecoveryCode recovery_result_; protected: - int8_t port_{-1}; TwoWire *wire_; uint8_t sda_pin_; uint8_t scl_pin_; @@ -49,4 +48,4 @@ class ArduinoI2CBus : public InternalI2CBus, public Component { } // namespace i2c } // namespace esphome -#endif // USE_ARDUINO +#endif // defined(USE_ARDUINO) && !defined(USE_ESP32) diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index bf50ea0586..c22db51c68 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "i2c_bus_esp_idf.h" @@ -299,4 +299,4 @@ void IDFI2CBus::recover_() { } // namespace i2c } // namespace esphome -#endif // USE_ESP_IDF +#endif // USE_ESP32 diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index f565be4535..63fe8b701c 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -1,6 +1,6 @@ #pragma once -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "esphome/core/component.h" #include "i2c_bus.h" @@ -53,4 +53,4 @@ class IDFI2CBus : public InternalI2CBus, public Component { } // namespace i2c } // namespace esphome -#endif // USE_ESP_IDF +#endif // USE_ESP32 diff --git a/esphome/components/i2c/i2c_bus_zephyr.cpp b/esphome/components/i2c/i2c_bus_zephyr.cpp index 658dcee35c..1eb9944dcb 100644 --- a/esphome/components/i2c/i2c_bus_zephyr.cpp +++ b/esphome/components/i2c/i2c_bus_zephyr.cpp @@ -8,6 +8,22 @@ namespace esphome::i2c { static const char *const TAG = "i2c.zephyr"; +static const char *get_speed(uint32_t dev_config) { + switch (I2C_SPEED_GET(dev_config)) { + case I2C_SPEED_STANDARD: + return "100 kHz"; + case I2C_SPEED_FAST: + return "400 kHz"; + case I2C_SPEED_FAST_PLUS: + return "1 MHz"; + case I2C_SPEED_HIGH: + return "3.4 MHz"; + case I2C_SPEED_ULTRA: + return "5 MHz"; + } + return "unknown"; +} + void ZephyrI2CBus::setup() { if (!device_is_ready(this->i2c_dev_)) { ESP_LOGE(TAG, "I2C dev is not ready."); @@ -31,21 +47,6 @@ void ZephyrI2CBus::setup() { } void ZephyrI2CBus::dump_config() { - auto get_speed = [](uint32_t dev_config) { - switch (I2C_SPEED_GET(dev_config)) { - case I2C_SPEED_STANDARD: - return "100 kHz"; - case I2C_SPEED_FAST: - return "400 kHz"; - case I2C_SPEED_FAST_PLUS: - return "1 MHz"; - case I2C_SPEED_HIGH: - return "3.4 MHz"; - case I2C_SPEED_ULTRA: - return "5 MHz"; - } - return "unknown"; - }; ESP_LOGCONFIG(TAG, "I2C Bus:\n" " SDA Pin: GPIO%u\n" diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 9d080ea98e..70260eeab3 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -231,7 +231,7 @@ bool ImprovSerialComponent::parse_improv_payload_(improv::ImprovCommand &command this->connecting_sta_ = sta; wifi::global_wifi_component->set_sta(sta); - wifi::global_wifi_component->start_connecting(sta, false); + wifi::global_wifi_component->start_connecting(sta); this->set_state_(improv::STATE_PROVISIONING); ESP_LOGD(TAG, "Received settings: SSID=%s, password=" LOG_SECRET("%s"), command.ssid.c_str(), command.password.c_str()); diff --git a/esphome/components/inkplate/display.py b/esphome/components/inkplate/display.py index 89518dcfab..47c8c898e5 100644 --- a/esphome/components/inkplate/display.py +++ b/esphome/components/inkplate/display.py @@ -6,10 +6,12 @@ import esphome.config_validation as cv from esphome.const import ( CONF_FULL_UPDATE_EVERY, CONF_ID, + CONF_IGNORE_STRAPPING_WARNING, CONF_LAMBDA, CONF_MIRROR_X, CONF_MIRROR_Y, CONF_MODEL, + CONF_NUMBER, CONF_OE_PIN, CONF_PAGES, CONF_TRANSFORM, @@ -101,14 +103,21 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_SPV_PIN): pins.gpio_output_pin_schema, cv.Required(CONF_VCOM_PIN): pins.gpio_output_pin_schema, cv.Required(CONF_WAKEUP_PIN): pins.gpio_output_pin_schema, - cv.Optional(CONF_CL_PIN, default=0): pins.internal_gpio_output_pin_schema, - cv.Optional(CONF_LE_PIN, default=2): pins.internal_gpio_output_pin_schema, + cv.Optional( + CONF_CL_PIN, + default={CONF_NUMBER: 0, CONF_IGNORE_STRAPPING_WARNING: True}, + ): pins.internal_gpio_output_pin_schema, + cv.Optional( + CONF_LE_PIN, + default={CONF_NUMBER: 2, CONF_IGNORE_STRAPPING_WARNING: True}, + ): pins.internal_gpio_output_pin_schema, # Data pins cv.Optional( CONF_DISPLAY_DATA_0_PIN, default=4 ): pins.internal_gpio_output_pin_schema, cv.Optional( - CONF_DISPLAY_DATA_1_PIN, default=5 + CONF_DISPLAY_DATA_1_PIN, + default={CONF_NUMBER: 5, CONF_IGNORE_STRAPPING_WARNING: True}, ): pins.internal_gpio_output_pin_schema, cv.Optional( CONF_DISPLAY_DATA_2_PIN, default=18 diff --git a/esphome/components/jsn_sr04t/jsn_sr04t.cpp b/esphome/components/jsn_sr04t/jsn_sr04t.cpp index 077d4e58ea..84181dac48 100644 --- a/esphome/components/jsn_sr04t/jsn_sr04t.cpp +++ b/esphome/components/jsn_sr04t/jsn_sr04t.cpp @@ -10,7 +10,7 @@ namespace jsn_sr04t { static const char *const TAG = "jsn_sr04t.sensor"; void Jsnsr04tComponent::update() { - this->write_byte(0x55); + this->write_byte((this->model_ == AJ_SR04M) ? 0x01 : 0x55); ESP_LOGV(TAG, "Request read out from sensor"); } @@ -31,19 +31,10 @@ void Jsnsr04tComponent::loop() { } void Jsnsr04tComponent::check_buffer_() { - uint8_t checksum = 0; - switch (this->model_) { - case JSN_SR04T: - checksum = this->buffer_[0] + this->buffer_[1] + this->buffer_[2]; - break; - case AJ_SR04M: - checksum = this->buffer_[1] + this->buffer_[2]; - break; - } - + uint8_t checksum = this->buffer_[0] + this->buffer_[1] + this->buffer_[2]; if (this->buffer_[3] == checksum) { uint16_t distance = encode_uint16(this->buffer_[1], this->buffer_[2]); - if (distance > 250) { + if (distance > ((this->model_ == AJ_SR04M) ? 200 : 250)) { float meters = distance / 1000.0f; ESP_LOGV(TAG, "Distance from sensor: %umm, %.3fm", distance, meters); this->publish_state(meters); diff --git a/esphome/components/ld2410/automation.h b/esphome/components/ld2410/automation.h index f4f1c197b2..614453b575 100644 --- a/esphome/components/ld2410/automation.h +++ b/esphome/components/ld2410/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { template class BluetoothPasswordSetAction : public Action { public: @@ -18,5 +17,4 @@ template class BluetoothPasswordSetAction : public Action LD2410Component *ld2410_comp_; }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/button/factory_reset_button.cpp b/esphome/components/ld2410/button/factory_reset_button.cpp index a848b02a9d..0223df7086 100644 --- a/esphome/components/ld2410/button/factory_reset_button.cpp +++ b/esphome/components/ld2410/button/factory_reset_button.cpp @@ -1,9 +1,7 @@ #include "factory_reset_button.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { void FactoryResetButton::press_action() { this->parent_->factory_reset(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/button/factory_reset_button.h b/esphome/components/ld2410/button/factory_reset_button.h index 45bf979033..715a8c4056 100644 --- a/esphome/components/ld2410/button/factory_reset_button.h +++ b/esphome/components/ld2410/button/factory_reset_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class FactoryResetButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class FactoryResetButton : public button::Button, public Parentedparent_->read_all_info(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/button/query_button.h b/esphome/components/ld2410/button/query_button.h index c7a47e32d8..7a786901ae 100644 --- a/esphome/components/ld2410/button/query_button.h +++ b/esphome/components/ld2410/button/query_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class QueryButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class QueryButton : public button::Button, public Parented { void press_action() override; }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/button/restart_button.cpp b/esphome/components/ld2410/button/restart_button.cpp index de0d36c1ef..0d5002d3c6 100644 --- a/esphome/components/ld2410/button/restart_button.cpp +++ b/esphome/components/ld2410/button/restart_button.cpp @@ -1,9 +1,7 @@ #include "restart_button.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { void RestartButton::press_action() { this->parent_->restart_and_read_all_info(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/button/restart_button.h b/esphome/components/ld2410/button/restart_button.h index d00dc05a53..9bf8639a8c 100644 --- a/esphome/components/ld2410/button/restart_button.h +++ b/esphome/components/ld2410/button/restart_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class RestartButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class RestartButton : public button::Button, public Parented { void press_action() override; }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/ld2410.cpp b/esphome/components/ld2410/ld2410.cpp index 608882565f..bb2e4e2f4c 100644 --- a/esphome/components/ld2410/ld2410.cpp +++ b/esphome/components/ld2410/ld2410.cpp @@ -9,12 +9,9 @@ #include "esphome/core/application.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { static const char *const TAG = "ld2410"; -static const char *const UNKNOWN_MAC = "unknown"; -static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, @@ -181,15 +178,15 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2410Component::dump_config() { - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); + char mac_s[18]; + char version_s[20]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ld24xx::format_version_str(this->version_, version_s); ESP_LOGCONFIG(TAG, "LD2410:\n" " Firmware version: %s\n" " MAC address: %s", - version.c_str(), mac_str.c_str()); + version_s, mac_str); #ifdef USE_BINARY_SENSOR ESP_LOGCONFIG(TAG, "Binary Sensors:"); LOG_BINARY_SENSOR(" ", "Target", this->target_binary_sensor_); @@ -448,12 +445,12 @@ bool LD2410Component::handle_ack_data_() { case CMD_QUERY_VERSION: { std::memcpy(this->version_, &this->buffer_data_[12], sizeof(this->version_)); - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); - ESP_LOGV(TAG, "Firmware version: %s", version.c_str()); + char version_s[20]; + ld24xx::format_version_str(this->version_, version_s); + ESP_LOGV(TAG, "Firmware version: %s", version_s); #ifdef USE_TEXT_SENSOR if (this->version_text_sensor_ != nullptr) { - this->version_text_sensor_->publish_state(version); + this->version_text_sensor_->publish_state(version_s); } #endif break; @@ -506,9 +503,9 @@ bool LD2410Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - ESP_LOGV(TAG, "MAC address: %s", mac_str.c_str()); + char mac_s[18]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR if (this->mac_text_sensor_ != nullptr) { this->mac_text_sensor_->publish_state(mac_str); @@ -784,5 +781,4 @@ void LD2410Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) { } #endif -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/ld2410.h b/esphome/components/ld2410/ld2410.h index 52cf76b5b6..efe585fb76 100644 --- a/esphome/components/ld2410/ld2410.h +++ b/esphome/components/ld2410/ld2410.h @@ -29,8 +29,7 @@ #include -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { using namespace ld24xx; @@ -133,5 +132,4 @@ class LD2410Component : public Component, public uart::UARTDevice { #endif }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/number/gate_threshold_number.cpp b/esphome/components/ld2410/number/gate_threshold_number.cpp index 5d040554d7..65e864a4d7 100644 --- a/esphome/components/ld2410/number/gate_threshold_number.cpp +++ b/esphome/components/ld2410/number/gate_threshold_number.cpp @@ -1,7 +1,6 @@ #include "gate_threshold_number.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { GateThresholdNumber::GateThresholdNumber(uint8_t gate) : gate_(gate) {} @@ -10,5 +9,4 @@ void GateThresholdNumber::control(float value) { this->parent_->set_gate_threshold(this->gate_); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/number/gate_threshold_number.h b/esphome/components/ld2410/number/gate_threshold_number.h index 2806ecce63..63491f18d3 100644 --- a/esphome/components/ld2410/number/gate_threshold_number.h +++ b/esphome/components/ld2410/number/gate_threshold_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class GateThresholdNumber : public number::Number, public Parented { public: @@ -15,5 +14,4 @@ class GateThresholdNumber : public number::Number, public Parentedpublish_state(value); this->parent_->set_light_out_control(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/number/light_threshold_number.h b/esphome/components/ld2410/number/light_threshold_number.h index 8f014373c0..3c5e433416 100644 --- a/esphome/components/ld2410/number/light_threshold_number.h +++ b/esphome/components/ld2410/number/light_threshold_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class LightThresholdNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class LightThresholdNumber : public number::Number, public Parentedpublish_state(value); this->parent_->set_max_distances_timeout(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/number/max_distance_timeout_number.h b/esphome/components/ld2410/number/max_distance_timeout_number.h index 7d91b4b5fe..35f4cbbfae 100644 --- a/esphome/components/ld2410/number/max_distance_timeout_number.h +++ b/esphome/components/ld2410/number/max_distance_timeout_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class MaxDistanceTimeoutNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class MaxDistanceTimeoutNumber : public number::Number, public Parentedpublish_state(index); this->parent_->set_baud_rate(this->option_at(index)); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/select/baud_rate_select.h b/esphome/components/ld2410/select/baud_rate_select.h index 9385c8cf7e..fb1d016b1f 100644 --- a/esphome/components/ld2410/select/baud_rate_select.h +++ b/esphome/components/ld2410/select/baud_rate_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class BaudRateSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class BaudRateSelect : public select::Select, public Parented { void control(size_t index) override; }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/select/distance_resolution_select.cpp b/esphome/components/ld2410/select/distance_resolution_select.cpp index 4fc4c5af02..635bf206d3 100644 --- a/esphome/components/ld2410/select/distance_resolution_select.cpp +++ b/esphome/components/ld2410/select/distance_resolution_select.cpp @@ -1,12 +1,10 @@ #include "distance_resolution_select.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { void DistanceResolutionSelect::control(size_t index) { this->publish_state(index); this->parent_->set_distance_resolution(this->option_at(index)); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/select/distance_resolution_select.h b/esphome/components/ld2410/select/distance_resolution_select.h index 1a04f843a6..be2389d36e 100644 --- a/esphome/components/ld2410/select/distance_resolution_select.h +++ b/esphome/components/ld2410/select/distance_resolution_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class DistanceResolutionSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class DistanceResolutionSelect : public select::Select, public Parentedpublish_state(index); this->parent_->set_light_out_control(); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/select/light_out_control_select.h b/esphome/components/ld2410/select/light_out_control_select.h index e8cd8f1d6a..608c311af4 100644 --- a/esphome/components/ld2410/select/light_out_control_select.h +++ b/esphome/components/ld2410/select/light_out_control_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class LightOutControlSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class LightOutControlSelect : public select::Select, public Parentedpublish_state(state); this->parent_->set_bluetooth(state); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/switch/bluetooth_switch.h b/esphome/components/ld2410/switch/bluetooth_switch.h index 35ae1ec0c9..07804e2292 100644 --- a/esphome/components/ld2410/switch/bluetooth_switch.h +++ b/esphome/components/ld2410/switch/bluetooth_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class BluetoothSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class BluetoothSwitch : public switch_::Switch, public Parented void write_state(bool state) override; }; -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/switch/engineering_mode_switch.cpp b/esphome/components/ld2410/switch/engineering_mode_switch.cpp index 967c87c887..4f2f08b03e 100644 --- a/esphome/components/ld2410/switch/engineering_mode_switch.cpp +++ b/esphome/components/ld2410/switch/engineering_mode_switch.cpp @@ -1,12 +1,10 @@ #include "engineering_mode_switch.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { void EngineeringModeSwitch::write_state(bool state) { this->publish_state(state); this->parent_->set_engineering_mode(state); } -} // namespace ld2410 -} // namespace esphome +} // namespace esphome::ld2410 diff --git a/esphome/components/ld2410/switch/engineering_mode_switch.h b/esphome/components/ld2410/switch/engineering_mode_switch.h index e521200cd6..4dd8e16653 100644 --- a/esphome/components/ld2410/switch/engineering_mode_switch.h +++ b/esphome/components/ld2410/switch/engineering_mode_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2410.h" -namespace esphome { -namespace ld2410 { +namespace esphome::ld2410 { class EngineeringModeSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class EngineeringModeSwitch : public switch_::Switch, public Parentedparent_->factory_reset(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/factory_reset_button.h b/esphome/components/ld2412/button/factory_reset_button.h index 36a3fffcd5..1ef6b23b80 100644 --- a/esphome/components/ld2412/button/factory_reset_button.h +++ b/esphome/components/ld2412/button/factory_reset_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class FactoryResetButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class FactoryResetButton : public button::Button, public Parentedparent_->read_all_info(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/query_button.h b/esphome/components/ld2412/button/query_button.h index 595ef6d1e9..373e135802 100644 --- a/esphome/components/ld2412/button/query_button.h +++ b/esphome/components/ld2412/button/query_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class QueryButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class QueryButton : public button::Button, public Parented { void press_action() override; }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/restart_button.cpp b/esphome/components/ld2412/button/restart_button.cpp index aca0d17841..430f6c998f 100644 --- a/esphome/components/ld2412/button/restart_button.cpp +++ b/esphome/components/ld2412/button/restart_button.cpp @@ -1,9 +1,7 @@ #include "restart_button.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { void RestartButton::press_action() { this->parent_->restart_and_read_all_info(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/restart_button.h b/esphome/components/ld2412/button/restart_button.h index 5cd582e2a3..80c79f5e7d 100644 --- a/esphome/components/ld2412/button/restart_button.h +++ b/esphome/components/ld2412/button/restart_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class RestartButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class RestartButton : public button::Button, public Parented { void press_action() override; }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp b/esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp index 9b37243b82..8ba41a03fb 100644 --- a/esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp +++ b/esphome/components/ld2412/button/start_dynamic_background_correction_button.cpp @@ -2,10 +2,8 @@ #include "restart_button.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { void StartDynamicBackgroundCorrectionButton::press_action() { this->parent_->start_dynamic_background_correction(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h index 3af0a8a149..b1f2127896 100644 --- a/esphome/components/ld2412/button/start_dynamic_background_correction_button.h +++ b/esphome/components/ld2412/button/start_dynamic_background_correction_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class StartDynamicBackgroundCorrectionButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class StartDynamicBackgroundCorrectionButton : public button::Button, public Par void press_action() override; }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/ld2412.cpp b/esphome/components/ld2412/ld2412.cpp index 5323a9a658..0f6fe62d30 100644 --- a/esphome/components/ld2412/ld2412.cpp +++ b/esphome/components/ld2412/ld2412.cpp @@ -10,12 +10,9 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { static const char *const TAG = "ld2412"; -static const char *const UNKNOWN_MAC = "unknown"; -static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, @@ -200,15 +197,15 @@ static inline bool validate_header_footer(const uint8_t *header_footer, const ui } void LD2412Component::dump_config() { - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); + char mac_s[18]; + char version_s[20]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ld24xx::format_version_str(this->version_, version_s); ESP_LOGCONFIG(TAG, "LD2412:\n" " Firmware version: %s\n" " MAC address: %s", - version.c_str(), mac_str.c_str()); + version_s, mac_str); #ifdef USE_BINARY_SENSOR ESP_LOGCONFIG(TAG, "Binary Sensors:"); LOG_BINARY_SENSOR(" ", "DynamicBackgroundCorrectionStatus", @@ -492,12 +489,12 @@ bool LD2412Component::handle_ack_data_() { case CMD_QUERY_VERSION: { std::memcpy(this->version_, &this->buffer_data_[12], sizeof(this->version_)); - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); - ESP_LOGV(TAG, "Firmware version: %s", version.c_str()); + char version_s[20]; + ld24xx::format_version_str(this->version_, version_s); + ESP_LOGV(TAG, "Firmware version: %s", version_s); #ifdef USE_TEXT_SENSOR if (this->version_text_sensor_ != nullptr) { - this->version_text_sensor_->publish_state(version); + this->version_text_sensor_->publish_state(version_s); } #endif break; @@ -544,9 +541,9 @@ bool LD2412Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - ESP_LOGV(TAG, "MAC address: %s", mac_str.c_str()); + char mac_s[18]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR if (this->mac_text_sensor_ != nullptr) { this->mac_text_sensor_->publish_state(mac_str); @@ -857,5 +854,4 @@ void LD2412Component::set_gate_still_sensor(uint8_t gate, sensor::Sensor *s) { } #endif -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/ld2412.h b/esphome/components/ld2412/ld2412.h index 2bed34bdd8..5dd5e7bcde 100644 --- a/esphome/components/ld2412/ld2412.h +++ b/esphome/components/ld2412/ld2412.h @@ -29,8 +29,7 @@ #include -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { using namespace ld24xx; @@ -137,5 +136,4 @@ class LD2412Component : public Component, public uart::UARTDevice { #endif }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/number/gate_threshold_number.cpp b/esphome/components/ld2412/number/gate_threshold_number.cpp index 47f8cd9107..8d12bad115 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.cpp +++ b/esphome/components/ld2412/number/gate_threshold_number.cpp @@ -1,7 +1,6 @@ #include "gate_threshold_number.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { GateThresholdNumber::GateThresholdNumber(uint8_t gate) : gate_(gate) {} @@ -10,5 +9,4 @@ void GateThresholdNumber::control(float value) { this->parent_->set_gate_threshold(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/number/gate_threshold_number.h b/esphome/components/ld2412/number/gate_threshold_number.h index 61d9945a0a..78c2e54d82 100644 --- a/esphome/components/ld2412/number/gate_threshold_number.h +++ b/esphome/components/ld2412/number/gate_threshold_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class GateThresholdNumber : public number::Number, public Parented { public: @@ -15,5 +14,4 @@ class GateThresholdNumber : public number::Number, public Parentedpublish_state(value); this->parent_->set_light_out_control(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/number/light_threshold_number.h b/esphome/components/ld2412/number/light_threshold_number.h index d8727d3c98..81fd73111c 100644 --- a/esphome/components/ld2412/number/light_threshold_number.h +++ b/esphome/components/ld2412/number/light_threshold_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class LightThresholdNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class LightThresholdNumber : public number::Number, public Parentedpublish_state(value); this->parent_->set_basic_config(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/number/max_distance_timeout_number.h b/esphome/components/ld2412/number/max_distance_timeout_number.h index af0dcf68c5..c1e947fa19 100644 --- a/esphome/components/ld2412/number/max_distance_timeout_number.h +++ b/esphome/components/ld2412/number/max_distance_timeout_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class MaxDistanceTimeoutNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class MaxDistanceTimeoutNumber : public number::Number, public Parentedpublish_state(index); this->parent_->set_baud_rate(this->option_at(index)); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/select/baud_rate_select.h b/esphome/components/ld2412/select/baud_rate_select.h index ffe0329341..4666dd2fa0 100644 --- a/esphome/components/ld2412/select/baud_rate_select.h +++ b/esphome/components/ld2412/select/baud_rate_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class BaudRateSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class BaudRateSelect : public select::Select, public Parented { void control(size_t index) override; }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/select/distance_resolution_select.cpp b/esphome/components/ld2412/select/distance_resolution_select.cpp index 5a6f46a071..95b80f87fb 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.cpp +++ b/esphome/components/ld2412/select/distance_resolution_select.cpp @@ -1,12 +1,10 @@ #include "distance_resolution_select.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { void DistanceResolutionSelect::control(size_t index) { this->publish_state(index); this->parent_->set_distance_resolution(this->option_at(index)); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/select/distance_resolution_select.h b/esphome/components/ld2412/select/distance_resolution_select.h index 842f63b7b1..d3b7fad2f9 100644 --- a/esphome/components/ld2412/select/distance_resolution_select.h +++ b/esphome/components/ld2412/select/distance_resolution_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class DistanceResolutionSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class DistanceResolutionSelect : public select::Select, public Parentedpublish_state(index); this->parent_->set_light_out_control(); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/select/light_out_control_select.h b/esphome/components/ld2412/select/light_out_control_select.h index 7a50970d0d..9f86189878 100644 --- a/esphome/components/ld2412/select/light_out_control_select.h +++ b/esphome/components/ld2412/select/light_out_control_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class LightOutControlSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class LightOutControlSelect : public select::Select, public Parentedpublish_state(state); this->parent_->set_bluetooth(state); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/switch/bluetooth_switch.h b/esphome/components/ld2412/switch/bluetooth_switch.h index 730d338d87..0c0d1fa550 100644 --- a/esphome/components/ld2412/switch/bluetooth_switch.h +++ b/esphome/components/ld2412/switch/bluetooth_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class BluetoothSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class BluetoothSwitch : public switch_::Switch, public Parented void write_state(bool state) override; }; -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.cpp b/esphome/components/ld2412/switch/engineering_mode_switch.cpp index 29ca0c22a8..28b4e5d9e6 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.cpp +++ b/esphome/components/ld2412/switch/engineering_mode_switch.cpp @@ -1,12 +1,10 @@ #include "engineering_mode_switch.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { void EngineeringModeSwitch::write_state(bool state) { this->publish_state(state); this->parent_->set_engineering_mode(state); } -} // namespace ld2412 -} // namespace esphome +} // namespace esphome::ld2412 diff --git a/esphome/components/ld2412/switch/engineering_mode_switch.h b/esphome/components/ld2412/switch/engineering_mode_switch.h index aaa404c673..4e75a8a185 100644 --- a/esphome/components/ld2412/switch/engineering_mode_switch.h +++ b/esphome/components/ld2412/switch/engineering_mode_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2412.h" -namespace esphome { -namespace ld2412 { +namespace esphome::ld2412 { class EngineeringModeSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class EngineeringModeSwitch : public switch_::Switch, public Parentedpresence_bsensor_); } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h index ee06439090..ec52312f92 100644 --- a/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h +++ b/esphome/components/ld2420/binary_sensor/ld2420_binary_sensor.h @@ -3,8 +3,7 @@ #include "../ld2420.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420BinarySensor : public LD2420Listener, public Component, binary_sensor::BinarySensor { public: @@ -21,5 +20,4 @@ class LD2420BinarySensor : public LD2420Listener, public Component, binary_senso binary_sensor::BinarySensor *presence_bsensor_{nullptr}; }; -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/button/reconfig_buttons.cpp b/esphome/components/ld2420/button/reconfig_buttons.cpp index fb8ec2b5a6..1e748e59b8 100644 --- a/esphome/components/ld2420/button/reconfig_buttons.cpp +++ b/esphome/components/ld2420/button/reconfig_buttons.cpp @@ -4,13 +4,11 @@ static const char *const TAG = "ld2420.button"; -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { void LD2420ApplyConfigButton::press_action() { this->parent_->apply_config_action(); } void LD2420RevertConfigButton::press_action() { this->parent_->revert_config_action(); } void LD2420RestartModuleButton::press_action() { this->parent_->restart_module_action(); } void LD2420FactoryResetButton::press_action() { this->parent_->factory_reset_action(); } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/button/reconfig_buttons.h b/esphome/components/ld2420/button/reconfig_buttons.h index 4e9e7a3692..72171ef386 100644 --- a/esphome/components/ld2420/button/reconfig_buttons.h +++ b/esphome/components/ld2420/button/reconfig_buttons.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2420.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420ApplyConfigButton : public button::Button, public Parented { public: @@ -38,5 +37,4 @@ class LD2420FactoryResetButton : public button::Button, public Parented listeners_{}; }; -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/number/gate_config_number.cpp b/esphome/components/ld2420/number/gate_config_number.cpp index a373753770..998eed2188 100644 --- a/esphome/components/ld2420/number/gate_config_number.cpp +++ b/esphome/components/ld2420/number/gate_config_number.cpp @@ -4,8 +4,7 @@ static const char *const TAG = "ld2420.number"; -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { void LD2420TimeoutNumber::control(float timeout) { this->publish_state(timeout); @@ -69,5 +68,4 @@ void LD2420StillThresholdNumbers::control(float still_threshold) { } } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/number/gate_config_number.h b/esphome/components/ld2420/number/gate_config_number.h index 459a8026e3..8a8b9c61b1 100644 --- a/esphome/components/ld2420/number/gate_config_number.h +++ b/esphome/components/ld2420/number/gate_config_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2420.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420TimeoutNumber : public number::Number, public Parented { public: @@ -74,5 +73,4 @@ class LD2420MoveThresholdNumbers : public number::Number, public Parentedparent_->set_operating_mode(this->option_at(index)); } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/select/operating_mode_select.h b/esphome/components/ld2420/select/operating_mode_select.h index f59eb33432..c1b8e0b11b 100644 --- a/esphome/components/ld2420/select/operating_mode_select.h +++ b/esphome/components/ld2420/select/operating_mode_select.h @@ -3,8 +3,7 @@ #include "../ld2420.h" #include "esphome/components/select/select.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420Select : public Component, public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class LD2420Select : public Component, public select::Select, public Parenteddistance_sensor_); } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/sensor/ld2420_sensor.h b/esphome/components/ld2420/sensor/ld2420_sensor.h index 82730d60e3..4849cfa047 100644 --- a/esphome/components/ld2420/sensor/ld2420_sensor.h +++ b/esphome/components/ld2420/sensor/ld2420_sensor.h @@ -3,8 +3,7 @@ #include "../ld2420.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420Sensor : public LD2420Listener, public Component, sensor::Sensor { public: @@ -30,5 +29,4 @@ class LD2420Sensor : public LD2420Listener, public Component, sensor::Sensor { std::vector energy_sensors_ = std::vector(TOTAL_GATES); }; -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp index f647a36936..f7b016c9d9 100644 --- a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp +++ b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { static const char *const TAG = "ld2420.text_sensor"; @@ -12,5 +11,4 @@ void LD2420TextSensor::dump_config() { LOG_TEXT_SENSOR(" ", "Firmware", this->fw_version_text_sensor_); } -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h index 073ddd5d0f..1932eaaf69 100644 --- a/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h +++ b/esphome/components/ld2420/text_sensor/ld2420_text_sensor.h @@ -3,8 +3,7 @@ #include "../ld2420.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace ld2420 { +namespace esphome::ld2420 { class LD2420TextSensor : public LD2420Listener, public Component, text_sensor::TextSensor { public: @@ -20,5 +19,4 @@ class LD2420TextSensor : public LD2420Listener, public Component, text_sensor::T text_sensor::TextSensor *fw_version_text_sensor_{nullptr}; }; -} // namespace ld2420 -} // namespace esphome +} // namespace esphome::ld2420 diff --git a/esphome/components/ld2450/button/factory_reset_button.cpp b/esphome/components/ld2450/button/factory_reset_button.cpp index bcac7ada2f..7a8eb5b0dd 100644 --- a/esphome/components/ld2450/button/factory_reset_button.cpp +++ b/esphome/components/ld2450/button/factory_reset_button.cpp @@ -1,9 +1,7 @@ #include "factory_reset_button.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { void FactoryResetButton::press_action() { this->parent_->factory_reset(); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/button/factory_reset_button.h b/esphome/components/ld2450/button/factory_reset_button.h index 8e80347119..392fc67ffd 100644 --- a/esphome/components/ld2450/button/factory_reset_button.h +++ b/esphome/components/ld2450/button/factory_reset_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class FactoryResetButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class FactoryResetButton : public button::Button, public Parentedparent_->restart_and_read_all_info(); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/button/restart_button.h b/esphome/components/ld2450/button/restart_button.h index a44ae5a4d2..9219011f8b 100644 --- a/esphome/components/ld2450/button/restart_button.h +++ b/esphome/components/ld2450/button/restart_button.h @@ -3,8 +3,7 @@ #include "esphome/components/button/button.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class RestartButton : public button::Button, public Parented { public: @@ -14,5 +13,4 @@ class RestartButton : public button::Button, public Parented { void press_action() override; }; -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/ld2450.cpp b/esphome/components/ld2450/ld2450.cpp index c9d4da47a4..e69ef31d4f 100644 --- a/esphome/components/ld2450/ld2450.cpp +++ b/esphome/components/ld2450/ld2450.cpp @@ -13,12 +13,9 @@ #include #include -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { static const char *const TAG = "ld2450"; -static const char *const UNKNOWN_MAC = "unknown"; -static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; enum BaudRate : uint8_t { BAUD_RATE_9600 = 1, @@ -192,15 +189,15 @@ void LD2450Component::setup() { } void LD2450Component::dump_config() { - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); + char mac_s[18]; + char version_s[20]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ld24xx::format_version_str(this->version_, version_s); ESP_LOGCONFIG(TAG, "LD2450:\n" " Firmware version: %s\n" " MAC address: %s", - version.c_str(), mac_str.c_str()); + version_s, mac_str); #ifdef USE_BINARY_SENSOR ESP_LOGCONFIG(TAG, "Binary Sensors:"); LOG_BINARY_SENSOR(" ", "MovingTarget", this->moving_target_binary_sensor_); @@ -642,12 +639,12 @@ bool LD2450Component::handle_ack_data_() { case CMD_QUERY_VERSION: { std::memcpy(this->version_, &this->buffer_data_[12], sizeof(this->version_)); - std::string version = str_sprintf(VERSION_FMT, this->version_[1], this->version_[0], this->version_[5], - this->version_[4], this->version_[3], this->version_[2]); - ESP_LOGV(TAG, "Firmware version: %s", version.c_str()); + char version_s[20]; + ld24xx::format_version_str(this->version_, version_s); + ESP_LOGV(TAG, "Firmware version: %s", version_s); #ifdef USE_TEXT_SENSOR if (this->version_text_sensor_ != nullptr) { - this->version_text_sensor_->publish_state(version); + this->version_text_sensor_->publish_state(version_s); } #endif break; @@ -663,9 +660,9 @@ bool LD2450Component::handle_ack_data_() { std::memcpy(this->mac_address_, &this->buffer_data_[10], sizeof(this->mac_address_)); } - std::string mac_str = - mac_address_is_valid(this->mac_address_) ? format_mac_address_pretty(this->mac_address_) : UNKNOWN_MAC; - ESP_LOGV(TAG, "MAC address: %s", mac_str.c_str()); + char mac_s[18]; + const char *mac_str = ld24xx::format_mac_str(this->mac_address_, mac_s); + ESP_LOGV(TAG, "MAC address: %s", mac_str); #ifdef USE_TEXT_SENSOR if (this->mac_text_sensor_ != nullptr) { this->mac_text_sensor_->publish_state(mac_str); @@ -941,5 +938,4 @@ float LD2450Component::restore_from_flash_() { } #endif -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/ld2450.h b/esphome/components/ld2450/ld2450.h index 44b63be444..b94c3cac37 100644 --- a/esphome/components/ld2450/ld2450.h +++ b/esphome/components/ld2450/ld2450.h @@ -31,8 +31,7 @@ #include -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { using namespace ld24xx; @@ -193,5 +192,4 @@ class LD2450Component : public Component, public uart::UARTDevice { #endif }; -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/number/presence_timeout_number.cpp b/esphome/components/ld2450/number/presence_timeout_number.cpp index ecfe71f484..19a1ada0d7 100644 --- a/esphome/components/ld2450/number/presence_timeout_number.cpp +++ b/esphome/components/ld2450/number/presence_timeout_number.cpp @@ -1,12 +1,10 @@ #include "presence_timeout_number.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { void PresenceTimeoutNumber::control(float value) { this->publish_state(value); this->parent_->set_presence_timeout(); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/number/presence_timeout_number.h b/esphome/components/ld2450/number/presence_timeout_number.h index b18699792f..09c8afca55 100644 --- a/esphome/components/ld2450/number/presence_timeout_number.h +++ b/esphome/components/ld2450/number/presence_timeout_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class PresenceTimeoutNumber : public number::Number, public Parented { public: @@ -14,5 +13,4 @@ class PresenceTimeoutNumber : public number::Number, public Parentedparent_->set_zone_coordinate(this->zone_); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/number/zone_coordinate_number.h b/esphome/components/ld2450/number/zone_coordinate_number.h index 72b83889c4..f5a389d712 100644 --- a/esphome/components/ld2450/number/zone_coordinate_number.h +++ b/esphome/components/ld2450/number/zone_coordinate_number.h @@ -3,8 +3,7 @@ #include "esphome/components/number/number.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class ZoneCoordinateNumber : public number::Number, public Parented { public: @@ -15,5 +14,4 @@ class ZoneCoordinateNumber : public number::Number, public Parentedpublish_state(index); this->parent_->set_baud_rate(this->option_at(index)); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/select/baud_rate_select.h b/esphome/components/ld2450/select/baud_rate_select.h index 22810d5f13..cb53118170 100644 --- a/esphome/components/ld2450/select/baud_rate_select.h +++ b/esphome/components/ld2450/select/baud_rate_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class BaudRateSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class BaudRateSelect : public select::Select, public Parented { void control(size_t index) override; }; -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/select/zone_type_select.cpp b/esphome/components/ld2450/select/zone_type_select.cpp index 1111428c7c..39642b99ad 100644 --- a/esphome/components/ld2450/select/zone_type_select.cpp +++ b/esphome/components/ld2450/select/zone_type_select.cpp @@ -1,12 +1,10 @@ #include "zone_type_select.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { void ZoneTypeSelect::control(size_t index) { this->publish_state(index); this->parent_->set_zone_type(this->option_at(index)); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/select/zone_type_select.h b/esphome/components/ld2450/select/zone_type_select.h index fc95ec1021..566346eb48 100644 --- a/esphome/components/ld2450/select/zone_type_select.h +++ b/esphome/components/ld2450/select/zone_type_select.h @@ -3,8 +3,7 @@ #include "esphome/components/select/select.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class ZoneTypeSelect : public select::Select, public Parented { public: @@ -14,5 +13,4 @@ class ZoneTypeSelect : public select::Select, public Parented { void control(size_t index) override; }; -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/switch/bluetooth_switch.cpp b/esphome/components/ld2450/switch/bluetooth_switch.cpp index fa0d4fb06a..0e19a3e6c6 100644 --- a/esphome/components/ld2450/switch/bluetooth_switch.cpp +++ b/esphome/components/ld2450/switch/bluetooth_switch.cpp @@ -1,12 +1,10 @@ #include "bluetooth_switch.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { void BluetoothSwitch::write_state(bool state) { this->publish_state(state); this->parent_->set_bluetooth(state); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/switch/bluetooth_switch.h b/esphome/components/ld2450/switch/bluetooth_switch.h index 3c1c4f755c..3d48a89b57 100644 --- a/esphome/components/ld2450/switch/bluetooth_switch.h +++ b/esphome/components/ld2450/switch/bluetooth_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class BluetoothSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class BluetoothSwitch : public switch_::Switch, public Parented void write_state(bool state) override; }; -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/switch/multi_target_switch.cpp b/esphome/components/ld2450/switch/multi_target_switch.cpp index a163e29fc5..0b1cb04a68 100644 --- a/esphome/components/ld2450/switch/multi_target_switch.cpp +++ b/esphome/components/ld2450/switch/multi_target_switch.cpp @@ -1,12 +1,10 @@ #include "multi_target_switch.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { void MultiTargetSwitch::write_state(bool state) { this->publish_state(state); this->parent_->set_multi_target(state); } -} // namespace ld2450 -} // namespace esphome +} // namespace esphome::ld2450 diff --git a/esphome/components/ld2450/switch/multi_target_switch.h b/esphome/components/ld2450/switch/multi_target_switch.h index ca6253588d..739f308cce 100644 --- a/esphome/components/ld2450/switch/multi_target_switch.h +++ b/esphome/components/ld2450/switch/multi_target_switch.h @@ -3,8 +3,7 @@ #include "esphome/components/switch/switch.h" #include "../ld2450.h" -namespace esphome { -namespace ld2450 { +namespace esphome::ld2450 { class MultiTargetSwitch : public switch_::Switch, public Parented { public: @@ -14,5 +13,4 @@ class MultiTargetSwitch : public switch_::Switch, public Parented +#include #ifdef USE_SENSOR -#include "esphome/core/helpers.h" #include "esphome/components/sensor/sensor.h" #define SUB_SENSOR_WITH_DEDUP(name, dedup_type) \ @@ -36,8 +37,28 @@ #define highbyte(val) (uint8_t)((val) >> 8) #define lowbyte(val) (uint8_t)((val) &0xff) -namespace esphome { -namespace ld24xx { +namespace esphome::ld24xx { + +static const char *const UNKNOWN_MAC = "unknown"; +static const char *const VERSION_FMT = "%u.%02X.%02X%02X%02X%02X"; + +// Helper function to format MAC address with stack allocation +// Returns pointer to UNKNOWN_MAC constant or formatted buffer +// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator) +inline const char *format_mac_str(const uint8_t *mac_address, std::span buffer) { + if (mac_address_is_valid(mac_address)) { + format_mac_addr_upper(mac_address, buffer.data()); + return buffer.data(); + } + return UNKNOWN_MAC; +} + +// Helper function to format firmware version with stack allocation +// Buffer must be exactly 20 bytes (format: "x.xxXXXXXX" fits in 11 + null terminator, 20 for safety) +inline void format_version_str(const uint8_t *version, std::span buffer) { + snprintf(buffer.data(), buffer.size(), VERSION_FMT, version[1], version[0], version[5], version[4], version[3], + version[2]); +} #ifdef USE_SENSOR // Helper class to store a sensor with a deduplicator & publish state only when the value changes @@ -61,5 +82,4 @@ template class SensorWithDedup { Deduplicator publish_dedup; }; #endif -} // namespace ld24xx -} // namespace esphome +} // namespace esphome::ld24xx diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index c63d6d7faa..93b66888da 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -261,6 +261,10 @@ async def component_to_code(config): cg.add_build_flag(f"-DUSE_LIBRETINY_VARIANT_{config[CONF_FAMILY]}") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) cg.add_define("ESPHOME_VARIANT", FAMILY_FRIENDLY[config[CONF_FAMILY]]) + # LibreTiny uses MULTI_NO_ATOMICS because platforms like BK7231N (ARM968E-S) lack + # exclusive load/store (no LDREX/STREX). std::atomic RMW operations require libatomic, + # which is not linked to save flash (4-8KB). Even if linked, libatomic would use locks + # (ATOMIC_INT_LOCK_FREE=1), so explicit FreeRTOS mutexes are simpler and equivalent. cg.add_define(ThreadModel.MULTI_NO_ATOMICS) # force using arduino framework diff --git a/esphome/components/light/addressable_light.cpp b/esphome/components/light/addressable_light.cpp index 5cbdcb0e86..2f6ffc9a38 100644 --- a/esphome/components/light/addressable_light.cpp +++ b/esphome/components/light/addressable_light.cpp @@ -1,8 +1,7 @@ #include "addressable_light.h" #include "esphome/core/log.h" -namespace esphome { -namespace light { +namespace esphome::light { static const char *const TAG = "light.addressable"; @@ -112,5 +111,4 @@ optional AddressableLightTransformer::apply() { return {}; } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 393cc679bc..2e4b984ce4 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -14,8 +14,7 @@ #include "esphome/components/power_supply/power_supply.h" #endif -namespace esphome { -namespace light { +namespace esphome::light { /// Convert the color information from a `LightColorValues` object to a `Color` object (does not apply brightness). Color color_from_light_color_values(LightColorValues val); @@ -116,5 +115,4 @@ class AddressableLightTransformer : public LightTransformer { Color target_color_{}; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/addressable_light_effect.h b/esphome/components/light/addressable_light_effect.h index 0847db3770..a85ea4661d 100644 --- a/esphome/components/light/addressable_light_effect.h +++ b/esphome/components/light/addressable_light_effect.h @@ -7,8 +7,7 @@ #include "esphome/components/light/light_state.h" #include "esphome/components/light/addressable_light.h" -namespace esphome { -namespace light { +namespace esphome::light { inline static int16_t sin16_c(uint16_t theta) { static const uint16_t BASE[] = {0, 6393, 12539, 18204, 23170, 27245, 30273, 32137}; @@ -371,5 +370,4 @@ class AddressableFlickerEffect : public AddressableLightEffect { uint8_t intensity_{13}; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/addressable_light_wrapper.h b/esphome/components/light/addressable_light_wrapper.h index d358502430..8665e62a79 100644 --- a/esphome/components/light/addressable_light_wrapper.h +++ b/esphome/components/light/addressable_light_wrapper.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "addressable_light.h" -namespace esphome { -namespace light { +namespace esphome::light { class AddressableLightWrapper : public light::AddressableLight { public: @@ -123,5 +122,4 @@ class AddressableLightWrapper : public light::AddressableLight { ColorMode color_mode_{ColorMode::UNKNOWN}; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/automation.cpp b/esphome/components/light/automation.cpp index 8c1785f061..ddac2f9341 100644 --- a/esphome/components/light/automation.cpp +++ b/esphome/components/light/automation.cpp @@ -1,8 +1,7 @@ #include "automation.h" #include "esphome/core/log.h" -namespace esphome { -namespace light { +namespace esphome::light { static const char *const TAG = "light.automation"; @@ -11,5 +10,4 @@ void addressableset_warn_about_scale(const char *field) { field); } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/automation.h b/esphome/components/light/automation.h index 8899db8bba..c90d71c5df 100644 --- a/esphome/components/light/automation.h +++ b/esphome/components/light/automation.h @@ -4,8 +4,7 @@ #include "light_state.h" #include "addressable_light.h" -namespace esphome { -namespace light { +namespace esphome::light { enum class LimitMode { CLAMP, DO_NOTHING }; @@ -121,46 +120,54 @@ template class LightIsOffCondition : public Condition { LightState *state_; }; -class LightTurnOnTrigger : public Trigger<> { +class LightTurnOnTrigger : public Trigger<>, public LightRemoteValuesListener { public: - LightTurnOnTrigger(LightState *a_light) { - a_light->add_new_remote_values_callback([this, a_light]() { - // using the remote value because of transitions we need to trigger as early as possible - auto is_on = a_light->remote_values.is_on(); - // only trigger when going from off to on - auto should_trigger = is_on && !this->last_on_; - // Set new state immediately so that trigger() doesn't devolve - // into infinite loop - this->last_on_ = is_on; - if (should_trigger) { - this->trigger(); - } - }); + explicit LightTurnOnTrigger(LightState *a_light) : light_(a_light) { + a_light->add_remote_values_listener(this); this->last_on_ = a_light->current_values.is_on(); } + void on_light_remote_values_update() override { + // using the remote value because of transitions we need to trigger as early as possible + auto is_on = this->light_->remote_values.is_on(); + // only trigger when going from off to on + auto should_trigger = is_on && !this->last_on_; + // Set new state immediately so that trigger() doesn't devolve + // into infinite loop + this->last_on_ = is_on; + if (should_trigger) { + this->trigger(); + } + } + protected: + LightState *light_; bool last_on_; }; -class LightTurnOffTrigger : public Trigger<> { +class LightTurnOffTrigger : public Trigger<>, public LightTargetStateReachedListener { public: - LightTurnOffTrigger(LightState *a_light) { - a_light->add_new_target_state_reached_callback([this, a_light]() { - auto is_on = a_light->current_values.is_on(); - // only trigger when going from on to off - if (!is_on) { - this->trigger(); - } - }); + explicit LightTurnOffTrigger(LightState *a_light) : light_(a_light) { + a_light->add_target_state_reached_listener(this); } + + void on_light_target_state_reached() override { + auto is_on = this->light_->current_values.is_on(); + // only trigger when going from on to off + if (!is_on) { + this->trigger(); + } + } + + protected: + LightState *light_; }; -class LightStateTrigger : public Trigger<> { +class LightStateTrigger : public Trigger<>, public LightRemoteValuesListener { public: - LightStateTrigger(LightState *a_light) { - a_light->add_new_remote_values_callback([this]() { this->trigger(); }); - } + explicit LightStateTrigger(LightState *a_light) { a_light->add_remote_values_listener(this); } + + void on_light_remote_values_update() override { this->trigger(); } }; // This is slightly ugly, but we can't log in headers, and can't make this a static method on AddressableSet @@ -216,5 +223,4 @@ template class AddressableSet : public Action { } }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/base_light_effects.h b/esphome/components/light/base_light_effects.h index 515afc5c59..2eeae574e7 100644 --- a/esphome/components/light/base_light_effects.h +++ b/esphome/components/light/base_light_effects.h @@ -6,8 +6,7 @@ #include "esphome/core/helpers.h" #include "light_effect.h" -namespace esphome { -namespace light { +namespace esphome::light { inline static float random_cubic_float() { const float r = random_float() * 2.0f - 1.0f; @@ -235,5 +234,4 @@ class FlickerLightEffect : public LightEffect { float alpha_{}; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/color_mode.h b/esphome/components/light/color_mode.h index aa3448c145..0750ae250d 100644 --- a/esphome/components/light/color_mode.h +++ b/esphome/components/light/color_mode.h @@ -3,8 +3,7 @@ #include #include "esphome/core/finite_set_mask.h" -namespace esphome { -namespace light { +namespace esphome::light { /// Color capabilities are the various outputs that a light has and that can be independently controlled by the user. enum class ColorCapability : uint8_t { @@ -210,5 +209,4 @@ inline bool has_capability(const ColorModeMask &mask, ColorCapability capability return (mask.get_mask() & CAPABILITY_BITMASKS[capability_to_index(capability)]) != 0; } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_color_correction.cpp b/esphome/components/light/esp_color_correction.cpp index e5e68264cc..1b511a94b2 100644 --- a/esphome/components/light/esp_color_correction.cpp +++ b/esphome/components/light/esp_color_correction.cpp @@ -2,8 +2,7 @@ #include "light_color_values.h" #include "esphome/core/log.h" -namespace esphome { -namespace light { +namespace esphome::light { void ESPColorCorrection::calculate_gamma_table(float gamma) { for (uint16_t i = 0; i < 256; i++) { @@ -23,5 +22,4 @@ void ESPColorCorrection::calculate_gamma_table(float gamma) { } } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_color_correction.h b/esphome/components/light/esp_color_correction.h index 14c065058c..d275e045b7 100644 --- a/esphome/components/light/esp_color_correction.h +++ b/esphome/components/light/esp_color_correction.h @@ -2,8 +2,7 @@ #include "esphome/core/color.h" -namespace esphome { -namespace light { +namespace esphome::light { class ESPColorCorrection { public: @@ -73,5 +72,4 @@ class ESPColorCorrection { uint8_t local_brightness_{255}; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_color_view.h b/esphome/components/light/esp_color_view.h index 35117e7dd8..440a23e9c9 100644 --- a/esphome/components/light/esp_color_view.h +++ b/esphome/components/light/esp_color_view.h @@ -4,8 +4,7 @@ #include "esp_hsv_color.h" #include "esp_color_correction.h" -namespace esphome { -namespace light { +namespace esphome::light { class ESPColorSettable { public: @@ -106,5 +105,4 @@ class ESPColorView : public ESPColorSettable { const ESPColorCorrection *color_correction_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_hsv_color.cpp b/esphome/components/light/esp_hsv_color.cpp index 450c2e11ce..07205ea6d0 100644 --- a/esphome/components/light/esp_hsv_color.cpp +++ b/esphome/components/light/esp_hsv_color.cpp @@ -1,7 +1,6 @@ #include "esp_hsv_color.h" -namespace esphome { -namespace light { +namespace esphome::light { Color ESPHSVColor::to_rgb() const { // based on FastLED's hsv rainbow to rgb @@ -70,5 +69,4 @@ Color ESPHSVColor::to_rgb() const { return rgb; } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_hsv_color.h b/esphome/components/light/esp_hsv_color.h index cdde91c71c..4b54039258 100644 --- a/esphome/components/light/esp_hsv_color.h +++ b/esphome/components/light/esp_hsv_color.h @@ -3,8 +3,7 @@ #include "esphome/core/color.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace light { +namespace esphome::light { struct ESPHSVColor { union { @@ -32,5 +31,4 @@ struct ESPHSVColor { Color to_rgb() const; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_range_view.cpp b/esphome/components/light/esp_range_view.cpp index e1f0a507bd..58d552031a 100644 --- a/esphome/components/light/esp_range_view.cpp +++ b/esphome/components/light/esp_range_view.cpp @@ -1,8 +1,7 @@ #include "esp_range_view.h" #include "addressable_light.h" -namespace esphome { -namespace light { +namespace esphome::light { int32_t HOT interpret_index(int32_t index, int32_t size) { if (index < 0) @@ -92,5 +91,4 @@ ESPRangeView &ESPRangeView::operator=(const ESPRangeView &rhs) { // NOLINT ESPColorView ESPRangeIterator::operator*() const { return this->range_.parent_->get(this->i_); } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/esp_range_view.h b/esphome/components/light/esp_range_view.h index 07d18af79f..f5e4ebb83f 100644 --- a/esphome/components/light/esp_range_view.h +++ b/esphome/components/light/esp_range_view.h @@ -3,8 +3,7 @@ #include "esp_color_view.h" #include "esp_hsv_color.h" -namespace esphome { -namespace light { +namespace esphome::light { int32_t interpret_index(int32_t index, int32_t size); @@ -76,5 +75,4 @@ class ESPRangeIterator { int32_t i_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index df17f53adc..dca5861734 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include "esphome/core/optional.h" -namespace esphome { -namespace light { +namespace esphome::light { static const char *const TAG = "light"; @@ -52,8 +51,10 @@ static void log_invalid_parameter(const char *name, const LogString *message) { } static const LogString *color_mode_to_human(ColorMode color_mode) { - if (color_mode == ColorMode::UNKNOWN) - return LOG_STR("Unknown"); + if (color_mode == ColorMode::ON_OFF) + return LOG_STR("On/Off"); + if (color_mode == ColorMode::BRIGHTNESS) + return LOG_STR("Brightness"); if (color_mode == ColorMode::WHITE) return LOG_STR("White"); if (color_mode == ColorMode::COLOR_TEMPERATURE) @@ -68,16 +69,16 @@ static const LogString *color_mode_to_human(ColorMode color_mode) { return LOG_STR("RGB + cold/warm white"); if (color_mode == ColorMode::RGB_COLOR_TEMPERATURE) return LOG_STR("RGB + color temperature"); - return LOG_STR(""); + return LOG_STR("Unknown"); } // Helper to log percentage values #if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG -static void log_percent(const char *name, const char *param, float value) { - ESP_LOGD(TAG, " %s: %.0f%%", param, value * 100.0f); +static void log_percent(const LogString *param, float value) { + ESP_LOGD(TAG, " %s: %.0f%%", LOG_STR_ARG(param), value * 100.0f); } #else -#define log_percent(name, param, value) +#define log_percent(param, value) #endif void LightCall::perform() { @@ -103,11 +104,11 @@ void LightCall::perform() { } if (this->has_brightness()) { - log_percent(name, "Brightness", v.get_brightness()); + log_percent(LOG_STR("Brightness"), v.get_brightness()); } if (this->has_color_brightness()) { - log_percent(name, "Color brightness", v.get_color_brightness()); + log_percent(LOG_STR("Color brightness"), v.get_color_brightness()); } if (this->has_red() || this->has_green() || this->has_blue()) { ESP_LOGD(TAG, " Red: %.0f%%, Green: %.0f%%, Blue: %.0f%%", v.get_red() * 100.0f, v.get_green() * 100.0f, @@ -115,7 +116,7 @@ void LightCall::perform() { } if (this->has_white()) { - log_percent(name, "White", v.get_white()); + log_percent(LOG_STR("White"), v.get_white()); } if (this->has_color_temperature()) { ESP_LOGD(TAG, " Color temperature: %.1f mireds", v.get_color_temperature()); @@ -173,8 +174,10 @@ void LightCall::perform() { this->parent_->set_immediately_(v, publish); } - if (!this->has_transition_()) { - this->parent_->target_state_reached_callback_.call(); + if (!this->has_transition_() && this->parent_->target_state_reached_listeners_) { + for (auto *listener : *this->parent_->target_state_reached_listeners_) { + listener->on_light_target_state_reached(); + } } if (publish) { this->parent_->publish_state(); @@ -406,7 +409,7 @@ void LightCall::transform_parameters_() { } } ColorMode LightCall::compute_color_mode_() { - const auto &supported_modes = this->parent_->get_traits().get_supported_color_modes(); + auto supported_modes = this->parent_->get_traits().get_supported_color_modes(); int supported_count = supported_modes.size(); // Some lights don't support any color modes (e.g. monochromatic light), leave it at unknown. @@ -645,5 +648,4 @@ LightCall &LightCall::set_rgbw(float red, float green, float blue, float white) return *this; } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_color_values.h b/esphome/components/light/light_color_values.h index 04d7d1e7d8..bedfad2c35 100644 --- a/esphome/components/light/light_color_values.h +++ b/esphome/components/light/light_color_values.h @@ -4,8 +4,7 @@ #include "color_mode.h" #include -namespace esphome { -namespace light { +namespace esphome::light { inline static uint8_t to_uint8_scale(float x) { return static_cast(roundf(x * 255.0f)); } @@ -310,5 +309,4 @@ class LightColorValues { ColorMode color_mode_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_effect.cpp b/esphome/components/light/light_effect.cpp index a210b48e5b..81b923f7f9 100644 --- a/esphome/components/light/light_effect.cpp +++ b/esphome/components/light/light_effect.cpp @@ -1,8 +1,7 @@ #include "light_effect.h" #include "light_state.h" -namespace esphome { -namespace light { +namespace esphome::light { uint32_t LightEffect::get_index() const { if (this->state_ == nullptr) { @@ -32,5 +31,4 @@ uint32_t LightEffect::get_index_in_parent_() const { return 0; // Not found } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_effect.h b/esphome/components/light/light_effect.h index d4c2dc3582..aa1f6f7899 100644 --- a/esphome/components/light/light_effect.h +++ b/esphome/components/light/light_effect.h @@ -2,8 +2,7 @@ #include "esphome/core/component.h" -namespace esphome { -namespace light { +namespace esphome::light { class LightState; @@ -55,5 +54,4 @@ class LightEffect { uint32_t get_index_in_parent_() const; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_json_schema.cpp b/esphome/components/light/light_json_schema.cpp index e754c453b5..3365d1f417 100644 --- a/esphome/components/light/light_json_schema.cpp +++ b/esphome/components/light/light_json_schema.cpp @@ -1,45 +1,44 @@ #include "light_json_schema.h" #include "light_output.h" +#include "esphome/core/progmem.h" #ifdef USE_JSON -namespace esphome { -namespace light { +namespace esphome::light { // See https://www.home-assistant.io/integrations/light.mqtt/#json-schema for documentation on the schema -// Lookup table for color mode strings -static constexpr const char *get_color_mode_json_str(ColorMode mode) { - switch (mode) { - case ColorMode::ON_OFF: - return "onoff"; - case ColorMode::BRIGHTNESS: - return "brightness"; - case ColorMode::WHITE: - return "white"; // not supported by HA in MQTT - case ColorMode::COLOR_TEMPERATURE: - return "color_temp"; - case ColorMode::COLD_WARM_WHITE: - return "cwww"; // not supported by HA - case ColorMode::RGB: - return "rgb"; - case ColorMode::RGB_WHITE: - return "rgbw"; - case ColorMode::RGB_COLOR_TEMPERATURE: - return "rgbct"; // not supported by HA - case ColorMode::RGB_COLD_WARM_WHITE: - return "rgbww"; - default: - return nullptr; +// Get JSON string for color mode using linear search (avoids large switch jump table) +static const char *get_color_mode_json_str(ColorMode mode) { + // Parallel arrays: mode values and their corresponding strings + // Uses less RAM than a switch jump table on sparse enum values + static constexpr ColorMode MODES[] = { + ColorMode::ON_OFF, + ColorMode::BRIGHTNESS, + ColorMode::WHITE, + ColorMode::COLOR_TEMPERATURE, + ColorMode::COLD_WARM_WHITE, + ColorMode::RGB, + ColorMode::RGB_WHITE, + ColorMode::RGB_COLOR_TEMPERATURE, + ColorMode::RGB_COLD_WARM_WHITE, + }; + static constexpr const char *STRINGS[] = { + "onoff", "brightness", "white", "color_temp", "cwww", "rgb", "rgbw", "rgbct", "rgbww", + }; + for (size_t i = 0; i < sizeof(MODES) / sizeof(MODES[0]); i++) { + if (MODES[i] == mode) + return STRINGS[i]; } + return nullptr; } void LightJSONSchema::dump_json(LightState &state, JsonObject root) { // NOLINTNEXTLINE(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson if (state.supports_effects()) { - root["effect"] = state.get_effect_name(); - root["effect_index"] = state.get_current_effect_index(); - root["effect_count"] = state.get_effect_count(); + root[ESPHOME_F("effect")] = state.get_effect_name(); + root[ESPHOME_F("effect_index")] = state.get_current_effect_index(); + root[ESPHOME_F("effect_count")] = state.get_effect_count(); } auto values = state.remote_values; @@ -47,39 +46,39 @@ void LightJSONSchema::dump_json(LightState &state, JsonObject root) { const auto color_mode = values.get_color_mode(); const char *mode_str = get_color_mode_json_str(color_mode); if (mode_str != nullptr) { - root["color_mode"] = mode_str; + root[ESPHOME_F("color_mode")] = mode_str; } if (color_mode & ColorCapability::ON_OFF) - root["state"] = (values.get_state() != 0.0f) ? "ON" : "OFF"; + root[ESPHOME_F("state")] = (values.get_state() != 0.0f) ? "ON" : "OFF"; if (color_mode & ColorCapability::BRIGHTNESS) - root["brightness"] = to_uint8_scale(values.get_brightness()); + root[ESPHOME_F("brightness")] = to_uint8_scale(values.get_brightness()); - JsonObject color = root["color"].to(); + JsonObject color = root[ESPHOME_F("color")].to(); if (color_mode & ColorCapability::RGB) { float color_brightness = values.get_color_brightness(); - color["r"] = to_uint8_scale(color_brightness * values.get_red()); - color["g"] = to_uint8_scale(color_brightness * values.get_green()); - color["b"] = to_uint8_scale(color_brightness * values.get_blue()); + color[ESPHOME_F("r")] = to_uint8_scale(color_brightness * values.get_red()); + color[ESPHOME_F("g")] = to_uint8_scale(color_brightness * values.get_green()); + color[ESPHOME_F("b")] = to_uint8_scale(color_brightness * values.get_blue()); } if (color_mode & ColorCapability::WHITE) { uint8_t white_val = to_uint8_scale(values.get_white()); - color["w"] = white_val; - root["white_value"] = white_val; // legacy API + color[ESPHOME_F("w")] = white_val; + root[ESPHOME_F("white_value")] = white_val; // legacy API } if (color_mode & ColorCapability::COLOR_TEMPERATURE) { // this one isn't under the color subkey for some reason - root["color_temp"] = uint32_t(values.get_color_temperature()); + root[ESPHOME_F("color_temp")] = uint32_t(values.get_color_temperature()); } if (color_mode & ColorCapability::COLD_WARM_WHITE) { - color["c"] = to_uint8_scale(values.get_cold_white()); - color["w"] = to_uint8_scale(values.get_warm_white()); + color[ESPHOME_F("c")] = to_uint8_scale(values.get_cold_white()); + color[ESPHOME_F("w")] = to_uint8_scale(values.get_warm_white()); } } void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonObject root) { - if (root["state"].is()) { - auto val = parse_on_off(root["state"]); + if (root[ESPHOME_F("state")].is()) { + auto val = parse_on_off(root[ESPHOME_F("state")]); switch (val) { case PARSE_ON: call.set_state(true); @@ -95,81 +94,81 @@ void LightJSONSchema::parse_color_json(LightState &state, LightCall &call, JsonO } } - if (root["brightness"].is()) { - call.set_brightness(float(root["brightness"]) / 255.0f); + if (root[ESPHOME_F("brightness")].is()) { + call.set_brightness(float(root[ESPHOME_F("brightness")]) / 255.0f); } - if (root["color"].is()) { - JsonObject color = root["color"]; + if (root[ESPHOME_F("color")].is()) { + JsonObject color = root[ESPHOME_F("color")]; // HA also encodes brightness information in the r, g, b values, so extract that and set it as color brightness. float max_rgb = 0.0f; - if (color["r"].is()) { - float r = float(color["r"]) / 255.0f; + if (color[ESPHOME_F("r")].is()) { + float r = float(color[ESPHOME_F("r")]) / 255.0f; max_rgb = fmaxf(max_rgb, r); call.set_red(r); } - if (color["g"].is()) { - float g = float(color["g"]) / 255.0f; + if (color[ESPHOME_F("g")].is()) { + float g = float(color[ESPHOME_F("g")]) / 255.0f; max_rgb = fmaxf(max_rgb, g); call.set_green(g); } - if (color["b"].is()) { - float b = float(color["b"]) / 255.0f; + if (color[ESPHOME_F("b")].is()) { + float b = float(color[ESPHOME_F("b")]) / 255.0f; max_rgb = fmaxf(max_rgb, b); call.set_blue(b); } - if (color["r"].is() || color["g"].is() || color["b"].is()) { + if (color[ESPHOME_F("r")].is() || color[ESPHOME_F("g")].is() || + color[ESPHOME_F("b")].is()) { call.set_color_brightness(max_rgb); } - if (color["c"].is()) { - call.set_cold_white(float(color["c"]) / 255.0f); + if (color[ESPHOME_F("c")].is()) { + call.set_cold_white(float(color[ESPHOME_F("c")]) / 255.0f); } - if (color["w"].is()) { + if (color[ESPHOME_F("w")].is()) { // the HA scheme is ambiguous here, the same key is used for white channel in RGBW and warm // white channel in RGBWW. - if (color["c"].is()) { - call.set_warm_white(float(color["w"]) / 255.0f); + if (color[ESPHOME_F("c")].is()) { + call.set_warm_white(float(color[ESPHOME_F("w")]) / 255.0f); } else { - call.set_white(float(color["w"]) / 255.0f); + call.set_white(float(color[ESPHOME_F("w")]) / 255.0f); } } } - if (root["white_value"].is()) { // legacy API - call.set_white(float(root["white_value"]) / 255.0f); + if (root[ESPHOME_F("white_value")].is()) { // legacy API + call.set_white(float(root[ESPHOME_F("white_value")]) / 255.0f); } - if (root["color_temp"].is()) { - call.set_color_temperature(float(root["color_temp"])); + if (root[ESPHOME_F("color_temp")].is()) { + call.set_color_temperature(float(root[ESPHOME_F("color_temp")])); } } void LightJSONSchema::parse_json(LightState &state, LightCall &call, JsonObject root) { LightJSONSchema::parse_color_json(state, call, root); - if (root["flash"].is()) { - auto length = uint32_t(float(root["flash"]) * 1000); + if (root[ESPHOME_F("flash")].is()) { + auto length = uint32_t(float(root[ESPHOME_F("flash")]) * 1000); call.set_flash_length(length); } - if (root["transition"].is()) { - auto length = uint32_t(float(root["transition"]) * 1000); + if (root[ESPHOME_F("transition")].is()) { + auto length = uint32_t(float(root[ESPHOME_F("transition")]) * 1000); call.set_transition_length(length); } - if (root["effect"].is()) { - const char *effect = root["effect"]; + if (root[ESPHOME_F("effect")].is()) { + const char *effect = root[ESPHOME_F("effect")]; call.set_effect(effect); } - if (root["effect_index"].is()) { - uint32_t effect_index = root["effect_index"]; + if (root[ESPHOME_F("effect_index")].is()) { + uint32_t effect_index = root[ESPHOME_F("effect_index")]; call.set_effect(effect_index); } } -} // namespace light -} // namespace esphome +} // namespace esphome::light #endif diff --git a/esphome/components/light/light_json_schema.h b/esphome/components/light/light_json_schema.h index c92dd7b655..dac81e32e3 100644 --- a/esphome/components/light/light_json_schema.h +++ b/esphome/components/light/light_json_schema.h @@ -8,8 +8,7 @@ #include "light_call.h" #include "light_state.h" -namespace esphome { -namespace light { +namespace esphome::light { class LightJSONSchema { public: @@ -22,7 +21,6 @@ class LightJSONSchema { static void parse_color_json(LightState &state, LightCall &call, JsonObject root); }; -} // namespace light -} // namespace esphome +} // namespace esphome::light #endif diff --git a/esphome/components/light/light_output.cpp b/esphome/components/light/light_output.cpp index e805a0b694..a86e8e5bf1 100644 --- a/esphome/components/light/light_output.cpp +++ b/esphome/components/light/light_output.cpp @@ -1,12 +1,10 @@ #include "light_output.h" #include "transformers.h" -namespace esphome { -namespace light { +namespace esphome::light { std::unique_ptr LightOutput::create_default_transition() { return make_unique(); } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_output.h b/esphome/components/light/light_output.h index 73ba0371cd..c82d270be8 100644 --- a/esphome/components/light/light_output.h +++ b/esphome/components/light/light_output.h @@ -5,8 +5,7 @@ #include "light_state.h" #include "light_transformer.h" -namespace esphome { -namespace light { +namespace esphome::light { /// Interface to write LightStates to hardware. class LightOutput { @@ -29,5 +28,4 @@ class LightOutput { virtual void write_state(LightState *state) = 0; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index 4c253ec5a8..af619a426a 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -5,8 +5,7 @@ #include "light_output.h" #include "transformers.h" -namespace esphome { -namespace light { +namespace esphome::light { static const char *const TAG = "light"; @@ -24,6 +23,9 @@ void LightState::setup() { effect->init_internal(this); } + // Start with loop disabled if idle - respects any effects/transitions set up during initialization + this->disable_loop_if_idle_(); + // When supported color temperature range is known, initialize color temperature setting within bounds. auto traits = this->get_traits(); float min_mireds = traits.get_min_mireds(); @@ -125,7 +127,14 @@ void LightState::loop() { this->transformer_->stop(); this->is_transformer_active_ = false; this->transformer_ = nullptr; - this->target_state_reached_callback_.call(); + if (this->target_state_reached_listeners_) { + for (auto *listener : *this->target_state_reached_listeners_) { + listener->on_light_target_state_reached(); + } + } + + // Disable loop if idle (no transformer and no effect) + this->disable_loop_if_idle_(); } } @@ -133,13 +142,19 @@ void LightState::loop() { if (this->next_write_) { this->next_write_ = false; this->output_->write_state(this); + // Disable loop if idle (no transformer and no effect) + this->disable_loop_if_idle_(); } } float LightState::get_setup_priority() const { return setup_priority::HARDWARE - 1.0f; } void LightState::publish_state() { - this->remote_values_callback_.call(); + if (this->remote_values_listeners_) { + for (auto *listener : *this->remote_values_listeners_) { + listener->on_light_remote_values_update(); + } + } #if defined(USE_LIGHT) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_light_update(this); #endif @@ -164,11 +179,17 @@ StringRef LightState::get_effect_name_ref() { return EFFECT_NONE_REF; } -void LightState::add_new_remote_values_callback(std::function &&send_callback) { - this->remote_values_callback_.add(std::move(send_callback)); +void LightState::add_remote_values_listener(LightRemoteValuesListener *listener) { + if (!this->remote_values_listeners_) { + this->remote_values_listeners_ = make_unique>(); + } + this->remote_values_listeners_->push_back(listener); } -void LightState::add_new_target_state_reached_callback(std::function &&send_callback) { - this->target_state_reached_callback_.add(std::move(send_callback)); +void LightState::add_target_state_reached_listener(LightTargetStateReachedListener *listener) { + if (!this->target_state_reached_listeners_) { + this->target_state_reached_listeners_ = make_unique>(); + } + this->target_state_reached_listeners_->push_back(listener); } void LightState::set_default_transition_length(uint32_t default_transition_length) { @@ -228,6 +249,8 @@ void LightState::start_effect_(uint32_t effect_index) { this->active_effect_index_ = effect_index; auto *effect = this->get_active_effect_(); effect->start_internal(); + // Enable loop while effect is active + this->enable_loop(); } LightEffect *LightState::get_active_effect_() { if (this->active_effect_index_ == 0) { @@ -242,6 +265,8 @@ void LightState::stop_effect_() { effect->stop(); } this->active_effect_index_ = 0; + // Disable loop if idle (no effect and no transformer) + this->disable_loop_if_idle_(); } void LightState::start_transition_(const LightColorValues &target, uint32_t length, bool set_remote_values) { @@ -251,6 +276,8 @@ void LightState::start_transition_(const LightColorValues &target, uint32_t leng if (set_remote_values) { this->remote_values = target; } + // Enable loop while transition is active + this->enable_loop(); } void LightState::start_flash_(const LightColorValues &target, uint32_t length, bool set_remote_values) { @@ -266,6 +293,8 @@ void LightState::start_flash_(const LightColorValues &target, uint32_t length, b if (set_remote_values) { this->remote_values = target; }; + // Enable loop while flash is active + this->enable_loop(); } void LightState::set_immediately_(const LightColorValues &target, bool set_remote_values) { @@ -277,6 +306,14 @@ void LightState::set_immediately_(const LightColorValues &target, bool set_remot } this->output_->update_state(this); this->next_write_ = true; + this->enable_loop(); +} + +void LightState::disable_loop_if_idle_() { + // Only disable loop if both transformer and effect are inactive, and no pending writes + if (this->transformer_ == nullptr && this->get_active_effect_() == nullptr && !this->next_write_) { + this->disable_loop(); + } } void LightState::save_remote_values_() { @@ -304,5 +341,4 @@ void LightState::save_remote_values_() { this->rtc_.save(&saved); } -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index bf63c0ec27..7ea72306f9 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -15,10 +15,32 @@ #include #include -namespace esphome { -namespace light { +namespace esphome::light { class LightOutput; +class LightState; + +/** Listener interface for light remote value changes. + * + * Components can implement this interface to receive notifications + * when the light's remote values change (state, brightness, color, etc.) + * without the overhead of std::function callbacks. + */ +class LightRemoteValuesListener { + public: + virtual void on_light_remote_values_update() = 0; +}; + +/** Listener interface for light target state reached. + * + * Components can implement this interface to receive notifications + * when the light finishes a transition and reaches its target state + * without the overhead of std::function callbacks. + */ +class LightTargetStateReachedListener { + public: + virtual void on_light_target_state_reached() = 0; +}; enum LightRestoreMode : uint8_t { LIGHT_RESTORE_DEFAULT_OFF, @@ -122,21 +144,17 @@ class LightState : public EntityBase, public Component { /// Return the name of the current effect as StringRef (for API usage) StringRef get_effect_name_ref(); - /** - * This lets front-end components subscribe to light change events. This callback is called once - * when the remote color values are changed. - * - * @param send_callback The callback. + /** Add a listener for remote values changes. + * Listener is notified when the light's remote values change (state, brightness, color, etc.) + * Lazily allocates the listener vector on first registration. */ - void add_new_remote_values_callback(std::function &&send_callback); + void add_remote_values_listener(LightRemoteValuesListener *listener); - /** - * The callback is called once the state of current_values and remote_values are equal (when the - * transition is finished). - * - * @param send_callback + /** Add a listener for target state reached. + * Listener is notified when the light finishes a transition and reaches its target state. + * Lazily allocates the listener vector on first registration. */ - void add_new_target_state_reached_callback(std::function &&send_callback); + void add_target_state_reached_listener(LightTargetStateReachedListener *listener); /// Set the default transition length, i.e. the transition length when no transition is provided. void set_default_transition_length(uint32_t default_transition_length); @@ -256,6 +274,9 @@ class LightState : public EntityBase, public Component { /// Internal method to save the current remote_values to the preferences void save_remote_values_(); + /// Disable loop if neither transformer nor effect is active + void disable_loop_if_idle_(); + /// Store the output to allow effects to have more access. LightOutput *output_; /// The currently active transformer for this light (transition/flash). @@ -277,19 +298,24 @@ class LightState : public EntityBase, public Component { // for effects, true if a transformer (transition) is active. bool is_transformer_active_ = false; - /** Callback to call when new values for the frontend are available. + /** Listeners for remote values changes. * * "Remote values" are light color values that are reported to the frontend and have a lower * publish frequency than the "real" color values. For example, during transitions the current * color value may change continuously, but the remote values will be reported as the target values * starting with the beginning of the transition. + * + * Lazily allocated - only created when a listener is actually registered. */ - CallbackManager remote_values_callback_{}; + std::unique_ptr> remote_values_listeners_; - /** Callback to call when the state of current_values and remote_values are equal - * This should be called once the state of current_values changed and equals the state of remote_values + /** Listeners for target state reached. + * Notified when the state of current_values and remote_values are equal + * (when the transition is finished). + * + * Lazily allocated - only created when a listener is actually registered. */ - CallbackManager target_state_reached_callback_{}; + std::unique_ptr> target_state_reached_listeners_; /// Initial state of the light. optional initial_state_{}; @@ -298,5 +324,4 @@ class LightState : public EntityBase, public Component { LightRestoreMode restore_mode_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/light_traits.h b/esphome/components/light/light_traits.h index 294b0cad1d..c3bb27a964 100644 --- a/esphome/components/light/light_traits.h +++ b/esphome/components/light/light_traits.h @@ -18,7 +18,8 @@ class LightTraits { public: LightTraits() = default; - const ColorModeMask &get_supported_color_modes() const { return this->supported_color_modes_; } + // Return by value to avoid dangling reference when get_traits() returns a temporary + ColorModeMask get_supported_color_modes() const { return this->supported_color_modes_; } void set_supported_color_modes(ColorModeMask supported_color_modes) { this->supported_color_modes_ = supported_color_modes; } diff --git a/esphome/components/light/light_transformer.h b/esphome/components/light/light_transformer.h index a84183c03c..079c2d2ae0 100644 --- a/esphome/components/light/light_transformer.h +++ b/esphome/components/light/light_transformer.h @@ -4,8 +4,7 @@ #include "esphome/core/helpers.h" #include "light_color_values.h" -namespace esphome { -namespace light { +namespace esphome::light { /// Base class for all light color transformers, such as transitions or flashes. class LightTransformer { @@ -59,5 +58,4 @@ class LightTransformer { LightColorValues target_values_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/light/transformers.h b/esphome/components/light/transformers.h index 71d41a66d3..a26713b723 100644 --- a/esphome/components/light/transformers.h +++ b/esphome/components/light/transformers.h @@ -6,8 +6,7 @@ #include "light_state.h" #include "light_transformer.h" -namespace esphome { -namespace light { +namespace esphome::light { class LightTransitionTransformer : public LightTransformer { public: @@ -118,5 +117,4 @@ class LightFlashTransformer : public LightTransformer { bool begun_lightstate_restore_; }; -} // namespace light -} // namespace esphome +} // namespace esphome::light diff --git a/esphome/components/lock/automation.h b/esphome/components/lock/automation.h index 0f596ef5e6..011c6cc6af 100644 --- a/esphome/components/lock/automation.h +++ b/esphome/components/lock/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -namespace esphome { -namespace lock { +namespace esphome::lock { template class LockAction : public Action { public: @@ -50,27 +49,18 @@ template class LockCondition : public Condition { bool state_; }; -class LockLockTrigger : public Trigger<> { +template class LockStateTrigger : public Trigger<> { public: - LockLockTrigger(Lock *a_lock) { + explicit LockStateTrigger(Lock *a_lock) { a_lock->add_on_state_callback([this, a_lock]() { - if (a_lock->state == LockState::LOCK_STATE_LOCKED) { + if (a_lock->state == State) { this->trigger(); } }); } }; -class LockUnlockTrigger : public Trigger<> { - public: - LockUnlockTrigger(Lock *a_lock) { - a_lock->add_on_state_callback([this, a_lock]() { - if (a_lock->state == LockState::LOCK_STATE_UNLOCKED) { - this->trigger(); - } - }); - } -}; +using LockLockTrigger = LockStateTrigger; +using LockUnlockTrigger = LockStateTrigger; -} // namespace lock -} // namespace esphome +} // namespace esphome::lock diff --git a/esphome/components/lock/lock.cpp b/esphome/components/lock/lock.cpp index 54fefe8745..018f5113e3 100644 --- a/esphome/components/lock/lock.cpp +++ b/esphome/components/lock/lock.cpp @@ -3,26 +3,25 @@ #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" -namespace esphome { -namespace lock { +namespace esphome::lock { static const char *const TAG = "lock"; -const char *lock_state_to_string(LockState state) { +const LogString *lock_state_to_string(LockState state) { switch (state) { case LOCK_STATE_LOCKED: - return "LOCKED"; + return LOG_STR("LOCKED"); case LOCK_STATE_UNLOCKED: - return "UNLOCKED"; + return LOG_STR("UNLOCKED"); case LOCK_STATE_JAMMED: - return "JAMMED"; + return LOG_STR("JAMMED"); case LOCK_STATE_LOCKING: - return "LOCKING"; + return LOG_STR("LOCKING"); case LOCK_STATE_UNLOCKING: - return "UNLOCKING"; + return LOG_STR("UNLOCKING"); case LOCK_STATE_NONE: default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -53,7 +52,7 @@ void Lock::publish_state(LockState state) { this->state = state; this->rtc_.save(&this->state); - ESP_LOGD(TAG, "'%s': Sending state %s", this->name_.c_str(), lock_state_to_string(state)); + ESP_LOGD(TAG, "'%s': Sending state %s", this->name_.c_str(), LOG_STR_ARG(lock_state_to_string(state))); this->state_callback_.call(); #if defined(USE_LOCK) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_lock_update(this); @@ -66,8 +65,7 @@ void LockCall::perform() { ESP_LOGD(TAG, "'%s' - Setting", this->parent_->get_name().c_str()); this->validate_(); if (this->state_.has_value()) { - const char *state_s = lock_state_to_string(*this->state_); - ESP_LOGD(TAG, " State: %s", state_s); + ESP_LOGD(TAG, " State: %s", LOG_STR_ARG(lock_state_to_string(*this->state_))); } this->parent_->control(*this); } @@ -75,7 +73,7 @@ void LockCall::validate_() { if (this->state_.has_value()) { auto state = *this->state_; if (!this->parent_->traits.supports_state(state)) { - ESP_LOGW(TAG, " State %s is not supported by this device!", lock_state_to_string(*this->state_)); + ESP_LOGW(TAG, " State %s is not supported by this device!", LOG_STR_ARG(lock_state_to_string(*this->state_))); this->state_.reset(); } } @@ -108,5 +106,4 @@ LockCall &LockCall::set_state(const std::string &state) { } const optional &LockCall::get_state() const { return this->state_; } -} // namespace lock -} // namespace esphome +} // namespace esphome::lock diff --git a/esphome/components/lock/lock.h b/esphome/components/lock/lock.h index 9737569921..4001a182b8 100644 --- a/esphome/components/lock/lock.h +++ b/esphome/components/lock/lock.h @@ -7,8 +7,7 @@ #include "esphome/core/preferences.h" #include -namespace esphome { -namespace lock { +namespace esphome::lock { class Lock; @@ -31,7 +30,10 @@ enum LockState : uint8_t { LOCK_STATE_LOCKING = 4, LOCK_STATE_UNLOCKING = 5 }; -const char *lock_state_to_string(LockState state); +const LogString *lock_state_to_string(LockState state); + +/// Maximum length of lock state string (including null terminator): "UNLOCKING" = 10 +static constexpr size_t LOCK_STATE_STR_SIZE = 10; class LockTraits { public: @@ -177,5 +179,4 @@ class Lock : public EntityBase { ESPPreferenceObject rtc_; }; -} // namespace lock -} // namespace esphome +} // namespace esphome::lock diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index cf78e6ae63..d9ca44d3c9 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -365,8 +365,10 @@ async def to_code(config): if CORE.is_esp32: if config[CONF_HARDWARE_UART] == USB_CDC: add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_CDC", True) + cg.add_define("USE_LOGGER_UART_SELECTION_USB_CDC") elif config[CONF_HARDWARE_UART] == USB_SERIAL_JTAG: add_idf_sdkconfig_option("CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG", True) + cg.add_define("USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG") try: uart_selection(USB_SERIAL_JTAG) cg.add_define("USE_LOGGER_USB_SERIAL_JTAG") @@ -404,6 +406,8 @@ async def to_code(config): conf, ) + CORE.add_job(final_step) + def validate_printf(value): # https://stackoverflow.com/questions/30011379/how-can-i-parse-a-c-format-string-in-python @@ -504,3 +508,24 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( }, } ) + +# Keys for CORE.data storage +DOMAIN = "logger" +KEY_LEVEL_LISTENERS = "level_listeners" + + +def request_logger_level_listeners() -> None: + """Request that logger level listeners be compiled in. + + Components that need to be notified about log level changes should call this + function during their code generation. This enables the add_level_listener() + method and compiles in the listener vector. + """ + CORE.data.setdefault(DOMAIN, {})[KEY_LEVEL_LISTENERS] = True + + +@coroutine_with_priority(CoroPriority.FINAL) +async def final_step(): + """Final code generation step to configure optional logger features.""" + if CORE.data.get(DOMAIN, {}).get(KEY_LEVEL_LISTENERS, False): + cg.add_define("USE_LOGGER_LEVEL_LISTENERS") diff --git a/esphome/components/logger/logger.cpp b/esphome/components/logger/logger.cpp index 9a9bf89fe3..21e2b44808 100644 --- a/esphome/components/logger/logger.cpp +++ b/esphome/components/logger/logger.cpp @@ -65,7 +65,9 @@ void HOT Logger::log_vprintf_(uint8_t level, const char *tag, int line, const ch uint16_t buffer_at = 0; // Initialize buffer position this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, console_buffer, &buffer_at, MAX_CONSOLE_LOG_MSG_SIZE); - this->write_msg_(console_buffer); + // Add newline if platform needs it (ESP32 doesn't add via write_msg_) + this->add_newline_to_buffer_if_needed_(console_buffer, &buffer_at, MAX_CONSOLE_LOG_MSG_SIZE); + this->write_msg_(console_buffer, buffer_at); } // Reset the recursion guard for this task @@ -131,17 +133,19 @@ void Logger::log_vprintf_(uint8_t level, const char *tag, int line, const __Flas // Save the offset before calling format_log_to_buffer_with_terminator_ // since it will increment tx_buffer_at_ to the end of the formatted string - uint32_t msg_start = this->tx_buffer_at_; + uint16_t msg_start = this->tx_buffer_at_; this->format_log_to_buffer_with_terminator_(level, tag, line, this->tx_buffer_, args, this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_); - // Write to console and send callback starting at the msg_start - if (this->baud_rate_ > 0) { - this->write_msg_(this->tx_buffer_ + msg_start); - } - size_t msg_length = + uint16_t msg_length = this->tx_buffer_at_ - msg_start; // Don't subtract 1 - tx_buffer_at_ is already at the null terminator position - this->log_callback_.call(level, tag, this->tx_buffer_ + msg_start, msg_length); + + // Listeners get message first (before console write) + for (auto *listener : this->log_listeners_) + listener->on_log(level, tag, this->tx_buffer_ + msg_start, msg_length); + + // Write to console starting at the msg_start + this->write_tx_buffer_to_console_(msg_start, &msg_length); global_recursion_guard_ = false; } @@ -200,7 +204,8 @@ void Logger::process_messages_() { this->write_footer_to_buffer_(this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_); this->tx_buffer_[this->tx_buffer_at_] = '\0'; size_t msg_len = this->tx_buffer_at_; // We already know the length from tx_buffer_at_ - this->log_callback_.call(message->level, message->tag, this->tx_buffer_, msg_len); + for (auto *listener : this->log_listeners_) + listener->on_log(message->level, message->tag, this->tx_buffer_, msg_len); // At this point all the data we need from message has been transferred to the tx_buffer // so we can release the message to allow other tasks to use it as soon as possible. this->log_buffer_->release_message_main_loop(received_token); @@ -209,9 +214,7 @@ void Logger::process_messages_() { // This ensures all log messages appear on the console in a clean, serialized manner // Note: Messages may appear slightly out of order due to async processing, but // this is preferred over corrupted/interleaved console output - if (this->baud_rate_ > 0) { - this->write_msg_(this->tx_buffer_); - } + this->write_tx_buffer_to_console_(); } } else { // No messages to process, disable loop if appropriate @@ -230,9 +233,6 @@ void Logger::set_log_level(const char *tag, uint8_t log_level) { this->log_level UARTSelection Logger::get_uart() const { return this->uart_; } #endif -void Logger::add_on_log_callback(std::function &&callback) { - this->log_callback_.add(std::move(callback)); -} float Logger::get_setup_priority() const { return setup_priority::BUS + 500.0f; } #ifdef USE_STORE_LOG_STR_IN_FLASH @@ -288,7 +288,10 @@ void Logger::set_log_level(uint8_t level) { ESP_LOGW(TAG, "Cannot set log level higher than pre-compiled %s", LOG_STR_ARG(LOG_LEVELS[ESPHOME_LOG_LEVEL])); } this->current_level_ = level; - this->level_callback_.call(level); +#ifdef USE_LOGGER_LEVEL_LISTENERS + for (auto *listener : this->level_listeners_) + listener->on_log_level_change(level); +#endif } Logger *global_logger = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) diff --git a/esphome/components/logger/logger.h b/esphome/components/logger/logger.h index dc8e06e0c9..8abc1196e1 100644 --- a/esphome/components/logger/logger.h +++ b/esphome/components/logger/logger.h @@ -36,6 +36,52 @@ struct device; namespace esphome::logger { +/** Interface for receiving log messages without std::function overhead. + * + * Components can implement this interface instead of using lambdas with std::function + * to reduce flash usage from std::function type erasure machinery. + * + * Usage: + * class MyComponent : public Component, public LogListener { + * public: + * void setup() override { + * if (logger::global_logger != nullptr) + * logger::global_logger->add_log_listener(this); + * } + * void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override { + * // Handle log message + * } + * }; + */ +class LogListener { + public: + virtual void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) = 0; +}; + +#ifdef USE_LOGGER_LEVEL_LISTENERS +/** Interface for receiving log level changes without std::function overhead. + * + * Components can implement this interface instead of using lambdas with std::function + * to reduce flash usage from std::function type erasure machinery. + * + * Usage: + * class MyComponent : public Component, public LoggerLevelListener { + * public: + * void setup() override { + * if (logger::global_logger != nullptr) + * logger::global_logger->add_logger_level_listener(this); + * } + * void on_log_level_change(uint8_t level) override { + * // Handle log level change + * } + * }; + */ +class LoggerLevelListener { + public: + virtual void on_log_level_change(uint8_t level) = 0; +}; +#endif + #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS // Comparison function for const char* keys in log_levels_ map struct CStrCompare { @@ -71,6 +117,17 @@ static constexpr uint16_t MAX_HEADER_SIZE = 128; // "0x" + 2 hex digits per byte + '\0' static constexpr size_t MAX_POINTER_REPRESENTATION = 2 + sizeof(void *) * 2 + 1; +// Platform-specific: does write_msg_ add its own newline? +// false: Caller must add newline to buffer before calling write_msg_ (ESP32, ESP8266, LibreTiny) +// Allows single write call with newline included for efficiency +// true: write_msg_ adds newline itself via puts()/println() (other platforms) +// Newline should NOT be added to buffer +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_LIBRETINY) +static constexpr bool WRITE_MSG_ADDS_NEWLINE = false; +#else +static constexpr bool WRITE_MSG_ADDS_NEWLINE = true; +#endif + #if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_ZEPHYR) /** Enum for logging UART selection * @@ -157,11 +214,13 @@ class Logger : public Component { inline uint8_t level_for(const char *tag); - /// Register a callback that will be called for every log message sent - void add_on_log_callback(std::function &&callback); + /// Register a log listener to receive log messages + void add_log_listener(LogListener *listener) { this->log_listeners_.push_back(listener); } - // add a listener for log level changes - void add_listener(std::function &&callback) { this->level_callback_.add(std::move(callback)); } +#ifdef USE_LOGGER_LEVEL_LISTENERS + /// Register a listener for log level changes + void add_level_listener(LoggerLevelListener *listener) { this->level_listeners_.push_back(listener); } +#endif float get_setup_priority() const override; @@ -173,7 +232,7 @@ class Logger : public Component { protected: void process_messages_(); - void write_msg_(const char *msg); + void write_msg_(const char *msg, size_t len); // Format a log message with printf-style arguments and write it to a buffer with header, footer, and null terminator // It's the caller's responsibility to initialize buffer_at (typically to 0) @@ -200,7 +259,36 @@ class Logger : public Component { } } - // Helper to format and send a log message to both console and callbacks + // Helper to add newline to buffer for platforms that need it + // Modifies buffer_at to include the newline + inline void HOT add_newline_to_buffer_if_needed_(char *buffer, uint16_t *buffer_at, uint16_t buffer_size) { + if constexpr (!WRITE_MSG_ADDS_NEWLINE) { + // Add newline - don't need to maintain null termination + // write_msg_ now always receives explicit length, so we can safely overwrite the null terminator + // This is safe because: + // 1. Callbacks already received the message (before we add newline) + // 2. write_msg_ receives the length explicitly (doesn't need null terminator) + if (*buffer_at < buffer_size) { + buffer[(*buffer_at)++] = '\n'; + } else if (buffer_size > 0) { + // Buffer was full - replace last char with newline to ensure it's visible + buffer[buffer_size - 1] = '\n'; + *buffer_at = buffer_size; + } + } + } + + // Helper to write tx_buffer_ to console if logging is enabled + // INTERNAL USE ONLY - offset > 0 requires length parameter to be non-null + inline void HOT write_tx_buffer_to_console_(uint16_t offset = 0, uint16_t *length = nullptr) { + if (this->baud_rate_ > 0) { + uint16_t *len_ptr = length ? length : &this->tx_buffer_at_; + this->add_newline_to_buffer_if_needed_(this->tx_buffer_ + offset, len_ptr, this->tx_buffer_size_ - offset); + this->write_msg_(this->tx_buffer_ + offset, *len_ptr); + } + } + + // Helper to format and send a log message to both console and listeners inline void HOT log_message_to_buffer_and_send_(uint8_t level, const char *tag, int line, const char *format, va_list args) { // Format to tx_buffer and prepare for output @@ -208,10 +296,12 @@ class Logger : public Component { this->format_log_to_buffer_with_terminator_(level, tag, line, format, args, this->tx_buffer_, &this->tx_buffer_at_, this->tx_buffer_size_); - if (this->baud_rate_ > 0) { - this->write_msg_(this->tx_buffer_); // If logging is enabled, write to console - } - this->log_callback_.call(level, tag, this->tx_buffer_, this->tx_buffer_at_); + // Listeners get message WITHOUT newline (for API/MQTT/syslog) + for (auto *listener : this->log_listeners_) + listener->on_log(level, tag, this->tx_buffer_, this->tx_buffer_at_); + + // Console gets message WITH newline (if platform needs it) + this->write_tx_buffer_to_console_(); } // Write the body of the log message to the buffer @@ -260,8 +350,10 @@ class Logger : public Component { #ifdef USE_LOGGER_RUNTIME_TAG_LEVELS std::map log_levels_{}; #endif - CallbackManager log_callback_{}; - CallbackManager level_callback_{}; + std::vector log_listeners_; // Log message listeners (API, MQTT, syslog, etc.) +#ifdef USE_LOGGER_LEVEL_LISTENERS + std::vector level_listeners_; // Log level change listeners +#endif #ifdef USE_ESPHOME_TASK_LOG_BUFFER std::unique_ptr log_buffer_; // Will be initialized with init_log_buffer #endif @@ -425,7 +517,9 @@ class Logger : public Component { } // Update buffer_at with the formatted length (handle truncation) - uint16_t formatted_len = (ret >= remaining) ? remaining : ret; + // When vsnprintf truncates (ret >= remaining), it writes (remaining - 1) chars + null terminator + // When it doesn't truncate (ret < remaining), it writes ret chars + null terminator + uint16_t formatted_len = (ret >= remaining) ? (remaining - 1) : ret; *buffer_at += formatted_len; // Remove all trailing newlines right after formatting @@ -453,15 +547,15 @@ class Logger : public Component { }; extern Logger *global_logger; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -class LoggerMessageTrigger : public Trigger { +class LoggerMessageTrigger : public Trigger, public LogListener { public: - explicit LoggerMessageTrigger(Logger *parent, uint8_t level) { - this->level_ = level; - parent->add_on_log_callback([this](uint8_t level, const char *tag, const char *message, size_t message_len) { - if (level <= this->level_) { - this->trigger(level, tag, message); - } - }); + explicit LoggerMessageTrigger(Logger *parent, uint8_t level) : level_(level) { parent->add_log_listener(this); } + + void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override { + (void) message_len; + if (level <= this->level_) { + this->trigger(level, tag, message); + } } protected: diff --git a/esphome/components/logger/logger_esp32.cpp b/esphome/components/logger/logger_esp32.cpp index 7fc79e6f54..32ef752462 100644 --- a/esphome/components/logger/logger_esp32.cpp +++ b/esphome/components/logger/logger_esp32.cpp @@ -121,25 +121,23 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg) { - if ( -#if defined(USE_LOGGER_USB_CDC) && !defined(USE_LOGGER_USB_SERIAL_JTAG) - this->uart_ == UART_SELECTION_USB_CDC -#elif defined(USE_LOGGER_USB_SERIAL_JTAG) && !defined(USE_LOGGER_USB_CDC) - this->uart_ == UART_SELECTION_USB_SERIAL_JTAG -#elif defined(USE_LOGGER_USB_CDC) && defined(USE_LOGGER_USB_SERIAL_JTAG) - this->uart_ == UART_SELECTION_USB_CDC || this->uart_ == UART_SELECTION_USB_SERIAL_JTAG +void HOT Logger::write_msg_(const char *msg, size_t len) { + // Length is now always passed explicitly - no strlen() fallback needed + +#if defined(USE_LOGGER_UART_SELECTION_USB_CDC) || defined(USE_LOGGER_UART_SELECTION_USB_SERIAL_JTAG) + // USB CDC/JTAG - single write including newline (already in buffer) + // Use fwrite to stdout which goes through VFS to USB console + // + // Note: These defines indicate the user's YAML configuration choice (hardware_uart: USB_CDC/USB_SERIAL_JTAG). + // They are ONLY defined when the user explicitly selects USB as the logger output in their config. + // This is compile-time selection, not runtime detection - if USB is configured, it's always used. + // There is no fallback to regular UART if "USB isn't connected" - that's the user's responsibility + // to configure correctly for their hardware. This approach eliminates runtime overhead. + fwrite(msg, 1, len, stdout); #else - /* DISABLES CODE */ (false) // NOLINT + // Regular UART - single write including newline (already in buffer) + uart_write_bytes(this->uart_num_, msg, len); #endif - ) { - puts(msg); - } else { - // Use tx_buffer_at_ if msg points to tx_buffer_, otherwise fall back to strlen - size_t len = (msg == this->tx_buffer_) ? this->tx_buffer_at_ : strlen(msg); - uart_write_bytes(this->uart_num_, msg, len); - uart_write_bytes(this->uart_num_, "\n", 1); - } } const LogString *Logger::get_uart_selection_() { diff --git a/esphome/components/logger/logger_esp8266.cpp b/esphome/components/logger/logger_esp8266.cpp index 5063d88b92..0fc73b747a 100644 --- a/esphome/components/logger/logger_esp8266.cpp +++ b/esphome/components/logger/logger_esp8266.cpp @@ -33,7 +33,10 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } +void HOT Logger::write_msg_(const char *msg, size_t len) { + // Single write with newline already in buffer (added by caller) + this->hw_serial_->write(msg, len); +} const LogString *Logger::get_uart_selection_() { switch (this->uart_) { diff --git a/esphome/components/logger/logger_host.cpp b/esphome/components/logger/logger_host.cpp index 4abe92286a..c5e1e6f865 100644 --- a/esphome/components/logger/logger_host.cpp +++ b/esphome/components/logger/logger_host.cpp @@ -3,7 +3,7 @@ namespace esphome::logger { -void HOT Logger::write_msg_(const char *msg) { +void HOT Logger::write_msg_(const char *msg, size_t) { time_t rawtime; struct tm *timeinfo; char buffer[80]; diff --git a/esphome/components/logger/logger_libretiny.cpp b/esphome/components/logger/logger_libretiny.cpp index 3edfa74480..cdf55e710c 100644 --- a/esphome/components/logger/logger_libretiny.cpp +++ b/esphome/components/logger/logger_libretiny.cpp @@ -49,7 +49,7 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } +void HOT Logger::write_msg_(const char *msg, size_t len) { this->hw_serial_->write(msg, len); } const LogString *Logger::get_uart_selection_() { switch (this->uart_) { diff --git a/esphome/components/logger/logger_rp2040.cpp b/esphome/components/logger/logger_rp2040.cpp index 63727c2cda..4a8535c8e4 100644 --- a/esphome/components/logger/logger_rp2040.cpp +++ b/esphome/components/logger/logger_rp2040.cpp @@ -27,7 +27,7 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg) { this->hw_serial_->println(msg); } +void HOT Logger::write_msg_(const char *msg, size_t) { this->hw_serial_->println(msg); } const LogString *Logger::get_uart_selection_() { switch (this->uart_) { diff --git a/esphome/components/logger/logger_zephyr.cpp b/esphome/components/logger/logger_zephyr.cpp index fb0c7dcca3..ec2ff3013c 100644 --- a/esphome/components/logger/logger_zephyr.cpp +++ b/esphome/components/logger/logger_zephyr.cpp @@ -62,7 +62,7 @@ void Logger::pre_setup() { ESP_LOGI(TAG, "Log initialized"); } -void HOT Logger::write_msg_(const char *msg) { +void HOT Logger::write_msg_(const char *msg, size_t) { #ifdef CONFIG_PRINTK printk("%s\n", msg); #endif diff --git a/esphome/components/logger/select/__init__.py b/esphome/components/logger/select/__init__.py index 2e83599eb4..6ce663978e 100644 --- a/esphome/components/logger/select/__init__.py +++ b/esphome/components/logger/select/__init__.py @@ -5,7 +5,13 @@ from esphome.const import CONF_LEVEL, CONF_LOGGER, ENTITY_CATEGORY_CONFIG, ICON_ from esphome.core import CORE from esphome.cpp_helpers import register_component, register_parented -from .. import CONF_LOGGER_ID, LOG_LEVELS, Logger, logger_ns +from .. import ( + CONF_LOGGER_ID, + LOG_LEVELS, + Logger, + logger_ns, + request_logger_level_listeners, +) CODEOWNERS = ["@clydebarrow"] @@ -21,6 +27,7 @@ CONFIG_SCHEMA = select.select_schema( async def to_code(config): + request_logger_level_listeners() parent = await cg.get_variable(config[CONF_LOGGER_ID]) levels = list(LOG_LEVELS) index = levels.index(CORE.data[CONF_LOGGER][CONF_LEVEL]) diff --git a/esphome/components/logger/select/logger_level_select.cpp b/esphome/components/logger/select/logger_level_select.cpp index e2ec28a390..3091ca1851 100644 --- a/esphome/components/logger/select/logger_level_select.cpp +++ b/esphome/components/logger/select/logger_level_select.cpp @@ -2,7 +2,7 @@ namespace esphome::logger { -void LoggerLevelSelect::publish_state(int level) { +void LoggerLevelSelect::on_log_level_change(uint8_t level) { auto index = level_to_index(level); if (!this->has_index(index)) return; @@ -10,8 +10,8 @@ void LoggerLevelSelect::publish_state(int level) { } void LoggerLevelSelect::setup() { - this->parent_->add_listener([this](int level) { this->publish_state(level); }); - this->publish_state(this->parent_->get_log_level()); + this->parent_->add_level_listener(this); + this->on_log_level_change(this->parent_->get_log_level()); } void LoggerLevelSelect::control(size_t index) { this->parent_->set_log_level(index_to_level(index)); } diff --git a/esphome/components/logger/select/logger_level_select.h b/esphome/components/logger/select/logger_level_select.h index 950edd29ac..6482114943 100644 --- a/esphome/components/logger/select/logger_level_select.h +++ b/esphome/components/logger/select/logger_level_select.h @@ -5,12 +5,17 @@ #include "esphome/components/logger/logger.h" namespace esphome::logger { -class LoggerLevelSelect : public Component, public select::Select, public Parented { +class LoggerLevelSelect final : public Component, + public select::Select, + public Parented, + public LoggerLevelListener { public: - void publish_state(int level); void setup() override; void control(size_t index) override; + // LoggerLevelListener interface + void on_log_level_change(uint8_t level) override; + protected: // Convert log level to option index (skip CONFIG at level 4) static uint8_t level_to_index(uint8_t level) { return (level > ESPHOME_LOG_LEVEL_CONFIG) ? level - 1 : level; } diff --git a/esphome/components/ltr390/ltr390.cpp b/esphome/components/ltr390/ltr390.cpp index c1885dcb6f..ba4a7ea5cb 100644 --- a/esphome/components/ltr390/ltr390.cpp +++ b/esphome/components/ltr390/ltr390.cpp @@ -104,12 +104,17 @@ void LTR390Component::read_uvs_() { } } -void LTR390Component::read_mode_(int mode_index) { - // Set mode - LTR390MODE mode = std::get<0>(this->mode_funcs_[mode_index]); - +void LTR390Component::standby_() { std::bitset<8> ctrl = this->reg(LTR390_MAIN_CTRL).get(); - ctrl[LTR390_CTRL_MODE] = mode; + ctrl[LTR390_CTRL_EN] = false; + this->reg(LTR390_MAIN_CTRL) = ctrl.to_ulong(); + this->reading_ = false; +} + +void LTR390Component::read_mode_(LTR390MODE mode) { + // Set mode + std::bitset<8> ctrl = this->reg(LTR390_MAIN_CTRL).get(); + ctrl[LTR390_CTRL_MODE] = (mode == LTR390_MODE_UVS); ctrl[LTR390_CTRL_EN] = true; this->reg(LTR390_MAIN_CTRL) = ctrl.to_ulong(); @@ -129,21 +134,18 @@ void LTR390Component::read_mode_(int mode_index) { } // After the sensor integration time do the following - this->set_timeout(int_time + LTR390_WAKEUP_TIME + LTR390_SETTLE_TIME, [this, mode_index]() { - // Read from the sensor - std::get<1>(this->mode_funcs_[mode_index])(); - - // If there are more modes to read then begin the next - // otherwise stop - if (mode_index + 1 < (int) this->mode_funcs_.size()) { - this->read_mode_(mode_index + 1); + this->set_timeout(int_time + LTR390_WAKEUP_TIME + LTR390_SETTLE_TIME, [this, mode]() { + // Read from the sensor and continue to next mode or standby + if (mode == LTR390_MODE_ALS) { + this->read_als_(); + if (this->enabled_modes_ & ENABLED_MODE_UVS) { + this->read_mode_(LTR390_MODE_UVS); + return; + } } else { - // put sensor in standby - std::bitset<8> ctrl = this->reg(LTR390_MAIN_CTRL).get(); - ctrl[LTR390_CTRL_EN] = false; - this->reg(LTR390_MAIN_CTRL) = ctrl.to_ulong(); - this->reading_ = false; + this->read_uvs_(); } + this->standby_(); }); } @@ -172,14 +174,12 @@ void LTR390Component::setup() { // Set sensor read state this->reading_ = false; - // If we need the light sensor then add to the list + // Determine which modes are enabled based on configured sensors if (this->light_sensor_ != nullptr || this->als_sensor_ != nullptr) { - this->mode_funcs_.emplace_back(LTR390_MODE_ALS, std::bind(<R390Component::read_als_, this)); + this->enabled_modes_ |= ENABLED_MODE_ALS; } - - // If we need the UV sensor then add to the list if (this->uvi_sensor_ != nullptr || this->uv_sensor_ != nullptr) { - this->mode_funcs_.emplace_back(LTR390_MODE_UVS, std::bind(<R390Component::read_uvs_, this)); + this->enabled_modes_ |= ENABLED_MODE_UVS; } } @@ -195,10 +195,11 @@ void LTR390Component::dump_config() { } void LTR390Component::update() { - if (!this->reading_ && !mode_funcs_.empty()) { - this->reading_ = true; - this->read_mode_(0); - } + if (this->reading_ || this->enabled_modes_ == 0) + return; + + this->reading_ = true; + this->read_mode_((this->enabled_modes_ & ENABLED_MODE_ALS) ? LTR390_MODE_ALS : LTR390_MODE_UVS); } } // namespace ltr390 diff --git a/esphome/components/ltr390/ltr390.h b/esphome/components/ltr390/ltr390.h index 7db73d68ff..47884b9166 100644 --- a/esphome/components/ltr390/ltr390.h +++ b/esphome/components/ltr390/ltr390.h @@ -1,7 +1,5 @@ #pragma once -#include -#include #include "esphome/components/i2c/i2c.h" #include "esphome/components/sensor/sensor.h" #include "esphome/core/component.h" @@ -60,17 +58,19 @@ class LTR390Component : public PollingComponent, public i2c::I2CDevice { void set_uv_sensor(sensor::Sensor *uv_sensor) { this->uv_sensor_ = uv_sensor; } protected: + static constexpr uint8_t ENABLED_MODE_ALS = 1 << 0; + static constexpr uint8_t ENABLED_MODE_UVS = 1 << 1; + optional read_sensor_data_(LTR390MODE mode); void read_als_(); void read_uvs_(); - void read_mode_(int mode_index); + void read_mode_(LTR390MODE mode); + void standby_(); - bool reading_; - - // a list of modes and corresponding read functions - std::vector>> mode_funcs_; + bool reading_{false}; + uint8_t enabled_modes_{0}; LTR390GAIN gain_als_; LTR390GAIN gain_uv_; diff --git a/esphome/components/ltr501/ltr501.cpp b/esphome/components/ltr501/ltr501.cpp index be5a4ddccf..04de91e362 100644 --- a/esphome/components/ltr501/ltr501.cpp +++ b/esphome/components/ltr501/ltr501.cpp @@ -174,7 +174,7 @@ void LTRAlsPs501Component::loop() { break; case State::WAITING_FOR_DATA: - if (this->is_als_data_ready_(this->als_readings_) == DataAvail::DATA_OK) { + if (this->is_als_data_ready_(this->als_readings_) == LtrDataAvail::LTR_DATA_OK) { tries = 0; ESP_LOGV(TAG, "Reading sensor data assuming gain = %.0fx, time = %d ms", get_gain_coeff(this->als_readings_.gain), get_itime_ms(this->als_readings_.integration_time)); @@ -379,18 +379,18 @@ void LTRAlsPs501Component::configure_integration_time_(IntegrationTime501 time) } } -DataAvail LTRAlsPs501Component::is_als_data_ready_(AlsReadings &data) { +LtrDataAvail LTRAlsPs501Component::is_als_data_ready_(AlsReadings &data) { AlsPsStatusRegister als_status{0}; als_status.raw = this->reg((uint8_t) CommandRegisters::ALS_PS_STATUS).get(); if (!als_status.als_new_data) - return DataAvail::NO_DATA; + return LtrDataAvail::LTR_NO_DATA; ESP_LOGV(TAG, "Data ready, reported gain is %.0fx", get_gain_coeff(als_status.gain)); if (data.gain != als_status.gain) { ESP_LOGW(TAG, "Actual gain differs from requested (%.0f)", get_gain_coeff(data.gain)); - return DataAvail::BAD_DATA; + return LtrDataAvail::LTR_BAD_DATA; } data.gain = als_status.gain; - return DataAvail::DATA_OK; + return LtrDataAvail::LTR_DATA_OK; } void LTRAlsPs501Component::read_sensor_data_(AlsReadings &data) { diff --git a/esphome/components/ltr501/ltr501.h b/esphome/components/ltr501/ltr501.h index 849ff6bc23..02c025da30 100644 --- a/esphome/components/ltr501/ltr501.h +++ b/esphome/components/ltr501/ltr501.h @@ -11,7 +11,7 @@ namespace esphome { namespace ltr501 { -enum DataAvail : uint8_t { NO_DATA, BAD_DATA, DATA_OK }; +enum LtrDataAvail : uint8_t { LTR_NO_DATA, LTR_BAD_DATA, LTR_DATA_OK }; enum LtrType : uint8_t { LTR_TYPE_UNKNOWN = 0, @@ -106,7 +106,7 @@ class LTRAlsPs501Component : public PollingComponent, public i2c::I2CDevice { void configure_als_(); void configure_integration_time_(IntegrationTime501 time); void configure_gain_(AlsGain501 gain); - DataAvail is_als_data_ready_(AlsReadings &data); + LtrDataAvail is_als_data_ready_(AlsReadings &data); void read_sensor_data_(AlsReadings &data); bool are_adjustments_required_(AlsReadings &data); void apply_lux_calculation_(AlsReadings &data); diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.cpp b/esphome/components/ltr_als_ps/ltr_als_ps.cpp index c3ea5848c8..f9c1474c85 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.cpp +++ b/esphome/components/ltr_als_ps/ltr_als_ps.cpp @@ -165,7 +165,7 @@ void LTRAlsPsComponent::loop() { break; case State::WAITING_FOR_DATA: - if (this->is_als_data_ready_(this->als_readings_) == DataAvail::DATA_OK) { + if (this->is_als_data_ready_(this->als_readings_) == LtrDataAvail::LTR_DATA_OK) { tries = 0; ESP_LOGV(TAG, "Reading sensor data having gain = %.0fx, time = %d ms", get_gain_coeff(this->als_readings_.gain), get_itime_ms(this->als_readings_.integration_time)); @@ -376,23 +376,23 @@ void LTRAlsPsComponent::configure_integration_time_(IntegrationTime time) { } } -DataAvail LTRAlsPsComponent::is_als_data_ready_(AlsReadings &data) { +LtrDataAvail LTRAlsPsComponent::is_als_data_ready_(AlsReadings &data) { AlsPsStatusRegister als_status{0}; als_status.raw = this->reg((uint8_t) CommandRegisters::ALS_PS_STATUS).get(); if (!als_status.als_new_data) - return DataAvail::NO_DATA; + return LtrDataAvail::LTR_NO_DATA; if (als_status.data_invalid) { ESP_LOGW(TAG, "Data available but not valid"); - return DataAvail::BAD_DATA; + return LtrDataAvail::LTR_BAD_DATA; } ESP_LOGV(TAG, "Data ready, reported gain is %.0f", get_gain_coeff(als_status.gain)); if (data.gain != als_status.gain) { ESP_LOGW(TAG, "Actual gain differs from requested (%.0f)", get_gain_coeff(data.gain)); - return DataAvail::BAD_DATA; + return LtrDataAvail::LTR_BAD_DATA; } - return DataAvail::DATA_OK; + return LtrDataAvail::LTR_DATA_OK; } void LTRAlsPsComponent::read_sensor_data_(AlsReadings &data) { diff --git a/esphome/components/ltr_als_ps/ltr_als_ps.h b/esphome/components/ltr_als_ps/ltr_als_ps.h index 2c768009ab..c6052300de 100644 --- a/esphome/components/ltr_als_ps/ltr_als_ps.h +++ b/esphome/components/ltr_als_ps/ltr_als_ps.h @@ -11,7 +11,7 @@ namespace esphome { namespace ltr_als_ps { -enum DataAvail : uint8_t { NO_DATA, BAD_DATA, DATA_OK }; +enum LtrDataAvail : uint8_t { LTR_NO_DATA, LTR_BAD_DATA, LTR_DATA_OK }; enum LtrType : uint8_t { LTR_TYPE_UNKNOWN = 0, @@ -106,7 +106,7 @@ class LTRAlsPsComponent : public PollingComponent, public i2c::I2CDevice { void configure_als_(); void configure_integration_time_(IntegrationTime time); void configure_gain_(AlsGain gain); - DataAvail is_als_data_ready_(AlsReadings &data); + LtrDataAvail is_als_data_ready_(AlsReadings &data); void read_sensor_data_(AlsReadings &data); bool are_adjustments_required_(AlsReadings &data); void apply_lux_calculation_(AlsReadings &data); diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 861999d0b7..040661495c 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,6 +1,8 @@ +import importlib import logging +import pkgutil -from esphome.automation import build_automation, register_action, validate_automation +from esphome.automation import build_automation, validate_automation import esphome.codegen as cg from esphome.components.const import CONF_COLOR_DEPTH, CONF_DRAW_ROUNDING from esphome.components.display import Display @@ -25,8 +27,8 @@ from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed -from . import defines as df, helpers, lv_validation as lvalid -from .automation import disp_update, focused_widgets, refreshed_widgets, update_to_code +from . import defines as df, helpers, lv_validation as lvalid, widgets +from .automation import disp_update, focused_widgets, refreshed_widgets from .defines import add_define from .encoders import ( ENCODERS_CONFIG, @@ -45,22 +47,12 @@ from .schemas import ( WIDGET_TYPES, any_widget_schema, container_schema, - create_modify_schema, obj_schema, ) from .styles import add_top_layer, styles_to_code, theme_to_code from .touchscreens import touchscreen_schema, touchscreens_to_code from .trigger import add_on_boot_triggers, generate_triggers -from .types import ( - FontEngine, - IdleTrigger, - ObjUpdateAction, - PlainTrigger, - lv_font_t, - lv_group_t, - lv_style_t, - lvgl_ns, -) +from .types import IdleTrigger, PlainTrigger, lv_font_t, lv_group_t, lv_style_t, lvgl_ns from .widgets import ( LvScrActType, Widget, @@ -69,33 +61,23 @@ from .widgets import ( set_obj_properties, styles_used, ) -from .widgets.animimg import animimg_spec -from .widgets.arc import arc_spec -from .widgets.button import button_spec -from .widgets.buttonmatrix import buttonmatrix_spec -from .widgets.canvas import canvas_spec -from .widgets.checkbox import checkbox_spec -from .widgets.container import container_spec -from .widgets.dropdown import dropdown_spec -from .widgets.img import img_spec -from .widgets.keyboard import keyboard_spec -from .widgets.label import label_spec -from .widgets.led import led_spec -from .widgets.line import line_spec -from .widgets.lv_bar import bar_spec -from .widgets.meter import meter_spec + +# Import only what we actually use directly in this file from .widgets.msgbox import MSGBOX_SCHEMA, msgboxes_to_code -from .widgets.obj import obj_spec -from .widgets.page import add_pages, generate_page_triggers, page_spec -from .widgets.qrcode import qr_code_spec -from .widgets.roller import roller_spec -from .widgets.slider import slider_spec -from .widgets.spinbox import spinbox_spec -from .widgets.spinner import spinner_spec -from .widgets.switch import switch_spec -from .widgets.tabview import tabview_spec -from .widgets.textarea import textarea_spec -from .widgets.tileview import tileview_spec +from .widgets.obj import obj_spec # Used in LVGL_SCHEMA +from .widgets.page import ( # page_spec used in LVGL_SCHEMA + add_pages, + generate_page_triggers, + page_spec, +) + +# Widget registration happens via WidgetType.__init__ in individual widget files +# The imports below trigger creation of the widget types +# Action registration (lvgl.{widget}.update) happens automatically +# in the WidgetType.__init__ method + +for module_info in pkgutil.iter_modules(widgets.__path__): + importlib.import_module(f".widgets.{module_info.name}", package=__package__) DOMAIN = "lvgl" DEPENDENCIES = ["display"] @@ -103,41 +85,6 @@ AUTO_LOAD = ["key_provider"] CODEOWNERS = ["@clydebarrow"] LOGGER = logging.getLogger(__name__) -for w_type in ( - label_spec, - obj_spec, - button_spec, - bar_spec, - slider_spec, - arc_spec, - line_spec, - spinner_spec, - led_spec, - animimg_spec, - checkbox_spec, - img_spec, - switch_spec, - tabview_spec, - buttonmatrix_spec, - meter_spec, - dropdown_spec, - roller_spec, - textarea_spec, - spinbox_spec, - keyboard_spec, - tileview_spec, - qr_code_spec, - canvas_spec, - container_spec, -): - WIDGET_TYPES[w_type.name] = w_type - -for w_type in WIDGET_TYPES.values(): - register_action( - f"lvgl.{w_type.name}.update", - ObjUpdateAction, - create_modify_schema(w_type), - )(update_to_code) SIMPLE_TRIGGERS = ( df.CONF_ON_PAUSE, @@ -161,7 +108,7 @@ LV_CONF_H_FORMAT = """\ def generate_lv_conf_h(): - definitions = [as_macro(m, v) for m, v in df.lv_defines.items()] + definitions = [as_macro(m, v) for m, v in df.get_data(df.KEY_LV_DEFINES).items()] definitions.sort() return LV_CONF_H_FORMAT.format("\n".join(definitions)) @@ -193,11 +140,11 @@ def multi_conf_validate(configs: list[dict]): ) -def final_validation(configs): - if len(configs) != 1: - multi_conf_validate(configs) +def final_validation(config_list): + if len(config_list) != 1: + multi_conf_validate(config_list) global_config = full_config.get() - for config in configs: + for config in config_list: 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]: @@ -243,6 +190,14 @@ def final_validation(configs): 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_data(df.KEY_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): @@ -289,7 +244,6 @@ async def to_code(configs): cg.add_global(lvgl_ns.using) for font in helpers.esphome_fonts_used: await cg.get_variable(font) - cg.new_Pvariable(ID(f"{font}_engine", True, type=FontEngine), MockObj(font)) default_font = config_0[df.CONF_DEFAULT_FONT] if not lvalid.is_lv_font(default_font): add_define( @@ -301,7 +255,8 @@ async def to_code(configs): type=lv_font_t.operator("ptr").operator("const"), ) cg.new_variable( - globfont_id, MockObj(await lvalid.lv_font.process(default_font)) + globfont_id, + MockObj(await lvalid.lv_font.process(default_font), "->").get_lv_font(), ) add_define("LV_FONT_DEFAULT", df.DEFAULT_ESPHOME_FONT) else: @@ -329,6 +284,7 @@ async def to_code(configs): config[df.CONF_FULL_REFRESH], config[CONF_DRAW_ROUNDING], config[df.CONF_RESUME_ON_INPUT], + config[df.CONF_UPDATE_WHEN_DISPLAY_IDLE], ) await cg.register_component(lv_component, config) Widget.create(config[CONF_ID], lv_component, LvScrActType(), config) @@ -376,7 +332,7 @@ async def to_code(configs): # This must be done after all widgets are created for comp in helpers.lvgl_components_required: cg.add_define(f"USE_LVGL_{comp.upper()}") - if "transform_angle" in styles_used: + if {"transform_angle", "transform_zoom"} & styles_used: add_define("LV_COLOR_SCREEN_TRANSP", "1") for use in helpers.lv_uses: add_define(f"LV_USE_{use.upper()}") @@ -402,6 +358,15 @@ def add_hello_world(config): return config +def _theme_schema(value): + return cv.Schema( + { + cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) + for name, w in WIDGET_TYPES.items() + } + )(value) + + FINAL_VALIDATE_SCHEMA = final_validation LVGL_SCHEMA = cv.All( @@ -417,6 +382,9 @@ LVGL_SCHEMA = cv.All( df.CONF_DEFAULT_FONT, default="montserrat_14" ): lvalid.lv_font, cv.Optional(df.CONF_FULL_REFRESH, default=False): cv.boolean, + cv.Optional( + df.CONF_UPDATE_WHEN_DISPLAY_IDLE, default=False + ): cv.boolean, cv.Optional(CONF_DRAW_ROUNDING, default=2): cv.positive_int, cv.Optional(CONF_BUFFER_SIZE, default=0): cv.percentage, cv.Optional(CONF_LOG_LEVEL, default="WARN"): cv.one_of( @@ -454,12 +422,7 @@ LVGL_SCHEMA = cv.All( cv.Optional( df.CONF_TRANSPARENCY_KEY, default=0x000400 ): lvalid.lv_color, - cv.Optional(df.CONF_THEME): cv.Schema( - { - cv.Optional(name): obj_schema(w).extend(FULL_STYLE_SCHEMA) - for name, w in WIDGET_TYPES.items() - } - ), + cv.Optional(df.CONF_THEME): _theme_schema, cv.Optional(df.CONF_GRADIENTS): GRADIENT_SCHEMA, cv.Optional(df.CONF_TOUCHSCREENS, default=None): touchscreen_schema, cv.Optional(df.CONF_ENCODERS, default=None): ENCODERS_CONFIG, diff --git a/esphome/components/lvgl/defines.py b/esphome/components/lvgl/defines.py index f2bcb6cc06..1d528b2f73 100644 --- a/esphome/components/lvgl/defines.py +++ b/esphome/components/lvgl/defines.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any from esphome import codegen as cg, config_validation as cv from esphome.const import CONF_ITEMS -from esphome.core import ID, Lambda +from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import LambdaExpression, MockObj from esphome.cpp_types import uint32 from esphome.schema_extractors import SCHEMA_EXTRACT, schema_extractor @@ -20,11 +20,27 @@ from .helpers import requires_component LOGGER = logging.getLogger(__name__) lvgl_ns = cg.esphome_ns.namespace("lvgl") -lv_defines = {} # Dict of #defines to provide as build flags +DOMAIN = "lvgl" +KEY_LV_DEFINES = "lv_defines" +KEY_UPDATED_WIDGETS = "updated_widgets" + + +def get_data(key, default=None): + """ + Get a data structure from the global data store by key + :param key: A key for the data + :param default: The default data - the default is an empty dict + :return: + """ + return CORE.data.setdefault(DOMAIN, {}).setdefault( + key, default if default is not None else {} + ) def add_define(macro, value="1"): - if macro in lv_defines and lv_defines[macro] != value: + lv_defines = get_data(KEY_LV_DEFINES) + value = str(value) + if lv_defines.setdefault(macro, value) != value: LOGGER.error( "Redefinition of %s - was %s now %s", macro, lv_defines[macro], value ) @@ -279,6 +295,8 @@ KEYBOARD_MODES = LvConstant( ) ROLLER_MODES = LvConstant("LV_ROLLER_MODE_", "NORMAL", "INFINITE") TILE_DIRECTIONS = DIRECTIONS.extend("HOR", "VER", "ALL") +SCROLL_DIRECTIONS = TILE_DIRECTIONS.extend("NONE") +SNAP_DIRECTIONS = LvConstant("LV_SCROLL_SNAP_", "NONE", "START", "END", "CENTER") CHILD_ALIGNMENTS = LvConstant( "LV_ALIGN_", "TOP_LEFT", @@ -511,6 +529,9 @@ CONF_ROLLOVER = "rollover" CONF_ROOT_BACK_BTN = "root_back_btn" CONF_SCALE_LINES = "scale_lines" CONF_SCROLLBAR_MODE = "scrollbar_mode" +CONF_SCROLL_DIR = "scroll_dir" +CONF_SCROLL_SNAP_X = "scroll_snap_x" +CONF_SCROLL_SNAP_Y = "scroll_snap_y" CONF_SELECTED_INDEX = "selected_index" CONF_SELECTED_TEXT = "selected_text" CONF_SHOW_SNOW = "show_snow" @@ -537,6 +558,7 @@ CONF_TOUCHSCREENS = "touchscreens" CONF_TRANSPARENCY_KEY = "transparency_key" CONF_THEME = "theme" CONF_UPDATE_ON_RELEASE = "update_on_release" +CONF_UPDATE_WHEN_DISPLAY_IDLE = "update_when_display_idle" CONF_VISIBLE_ROW_COUNT = "visible_row_count" CONF_WIDGET = "widget" CONF_WIDGETS = "widgets" diff --git a/esphome/components/lvgl/font.cpp b/esphome/components/lvgl/font.cpp deleted file mode 100644 index a0d5127570..0000000000 --- a/esphome/components/lvgl/font.cpp +++ /dev/null @@ -1,76 +0,0 @@ -#include "lvgl_esphome.h" - -#ifdef USE_LVGL_FONT -namespace esphome { -namespace lvgl { - -static const uint8_t *get_glyph_bitmap(const lv_font_t *font, uint32_t unicode_letter) { - auto *fe = (FontEngine *) font->dsc; - const auto *gd = fe->get_glyph_data(unicode_letter); - if (gd == nullptr) - return nullptr; - // esph_log_d(TAG, "Returning bitmap @ %X", (uint32_t)gd->data); - - return gd->data; -} - -static bool get_glyph_dsc_cb(const lv_font_t *font, lv_font_glyph_dsc_t *dsc, uint32_t unicode_letter, uint32_t next) { - auto *fe = (FontEngine *) font->dsc; - const auto *gd = fe->get_glyph_data(unicode_letter); - if (gd == nullptr) - return false; - dsc->adv_w = gd->advance; - dsc->ofs_x = gd->offset_x; - dsc->ofs_y = fe->height - gd->height - gd->offset_y - fe->baseline; - dsc->box_w = gd->width; - dsc->box_h = gd->height; - dsc->is_placeholder = 0; - dsc->bpp = fe->bpp; - return true; -} - -FontEngine::FontEngine(font::Font *esp_font) : font_(esp_font) { - this->bpp = esp_font->get_bpp(); - this->lv_font_.dsc = this; - this->lv_font_.line_height = this->height = esp_font->get_height(); - this->lv_font_.base_line = this->baseline = this->lv_font_.line_height - esp_font->get_baseline(); - this->lv_font_.get_glyph_dsc = get_glyph_dsc_cb; - this->lv_font_.get_glyph_bitmap = get_glyph_bitmap; - this->lv_font_.subpx = LV_FONT_SUBPX_NONE; - this->lv_font_.underline_position = -1; - this->lv_font_.underline_thickness = 1; -} - -const lv_font_t *FontEngine::get_lv_font() { return &this->lv_font_; } - -const font::GlyphData *FontEngine::get_glyph_data(uint32_t unicode_letter) { - if (unicode_letter == last_letter_) - return this->last_data_; - uint8_t unicode[5]; - memset(unicode, 0, sizeof unicode); - if (unicode_letter > 0xFFFF) { - unicode[0] = 0xF0 + ((unicode_letter >> 18) & 0x7); - unicode[1] = 0x80 + ((unicode_letter >> 12) & 0x3F); - unicode[2] = 0x80 + ((unicode_letter >> 6) & 0x3F); - unicode[3] = 0x80 + (unicode_letter & 0x3F); - } else if (unicode_letter > 0x7FF) { - unicode[0] = 0xE0 + ((unicode_letter >> 12) & 0xF); - unicode[1] = 0x80 + ((unicode_letter >> 6) & 0x3F); - unicode[2] = 0x80 + (unicode_letter & 0x3F); - } else if (unicode_letter > 0x7F) { - unicode[0] = 0xC0 + ((unicode_letter >> 6) & 0x1F); - unicode[1] = 0x80 + (unicode_letter & 0x3F); - } else { - unicode[0] = unicode_letter; - } - int match_length; - int glyph_n = this->font_->match_next_glyph(unicode, &match_length); - if (glyph_n < 0) - return nullptr; - this->last_data_ = this->font_->get_glyphs()[glyph_n].get_glyph_data(); - this->last_letter_ = unicode_letter; - return this->last_data_; -} -} // namespace lvgl -} // namespace esphome -#endif // USES_LVGL_FONT diff --git a/esphome/components/lvgl/layout.py b/esphome/components/lvgl/layout.py index a6aa816fda..b27a0b54a2 100644 --- a/esphome/components/lvgl/layout.py +++ b/esphome/components/lvgl/layout.py @@ -36,6 +36,8 @@ from .defines import ( ) from .lv_validation import padding, size +CONF_MULTIPLE_WIDGETS_PER_CELL = "multiple_widgets_per_cell" + cell_alignments = LV_CELL_ALIGNMENTS.one_of grid_alignments = LV_GRID_ALIGNMENTS.one_of flex_alignments = LV_FLEX_ALIGNMENTS.one_of @@ -170,10 +172,14 @@ class DirectionalLayout(FlexLayout): def validate(self, config): assert config[CONF_LAYOUT].lower() == self.direction - config[CONF_LAYOUT] = { + layout = { **FLEX_HV_STYLE, CONF_FLEX_FLOW: "LV_FLEX_FLOW_" + self.flow.upper(), } + if pad_all := config.get("pad_all"): + layout[CONF_PAD_ROW] = pad_all + layout[CONF_PAD_COLUMN] = pad_all + config[CONF_LAYOUT] = layout return config @@ -220,6 +226,7 @@ class GridLayout(Layout): cv.Optional(CONF_GRID_ROW_ALIGN): grid_alignments, cv.Optional(CONF_PAD_ROW): padding, cv.Optional(CONF_PAD_COLUMN): padding, + cv.Optional(CONF_MULTIPLE_WIDGETS_PER_CELL, default=False): cv.boolean, }, { cv.Optional(CONF_GRID_CELL_ROW_POS): cv.positive_int, @@ -263,6 +270,7 @@ class GridLayout(Layout): # should be guaranteed to be a dict at this point assert isinstance(layout, dict) assert layout.get(CONF_TYPE).lower() == TYPE_GRID + allow_multiple = layout.get(CONF_MULTIPLE_WIDGETS_PER_CELL, False) rows = len(layout[CONF_GRID_ROWS]) columns = len(layout[CONF_GRID_COLUMNS]) used_cells = [[None] * columns for _ in range(rows)] @@ -299,7 +307,10 @@ class GridLayout(Layout): f"exceeds grid size {rows}x{columns}", [CONF_WIDGETS, index], ) - if used_cells[row + i][column + j] is not None: + if ( + not allow_multiple + and used_cells[row + i][column + j] is not None + ): raise cv.Invalid( f"Cell span {row + i}/{column + j} already occupied by widget at index {used_cells[row + i][column + j]}", [CONF_WIDGETS, index], diff --git a/esphome/components/lvgl/lv_validation.py b/esphome/components/lvgl/lv_validation.py index 045258555c..9c1dd22085 100644 --- a/esphome/components/lvgl/lv_validation.py +++ b/esphome/components/lvgl/lv_validation.py @@ -40,7 +40,7 @@ from .helpers import ( lv_fonts_used, requires_component, ) -from .types import lv_font_t, lv_gradient_t +from .types import lv_gradient_t opacity_consts = LvConstant("LV_OPA_", "TRANSP", "COVER") @@ -493,16 +493,21 @@ class LvFont(LValidator): return LV_FONTS if is_lv_font(value): return lv_builtin_font(value) + add_lv_use("font") fontval = cv.use_id(Font)(value) esphome_fonts_used.add(fontval) return requires_component("font")(fontval) - super().__init__(validator, lv_font_t) + # Use font::Font* as return type for lambdas returning ESPHome fonts + # The inline overloads in lvgl_esphome.h handle conversion to lv_font_t* + super().__init__(validator, Font.operator("ptr")) async def process(self, value, args=()): if is_lv_font(value): return literal(f"&lv_font_{value}") - return literal(f"{value}_engine->get_lv_font()") + if isinstance(value, str): + return literal(f"{value}") + return await super().process(value, args) lv_font = LvFont() diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 05005b0217..18226a9f57 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -106,6 +106,7 @@ void LvglComponent::dump_config() { this->disp_drv_.hor_res, this->disp_drv_.ver_res, 100 / this->buffer_frac_, this->rotation, (int) this->draw_rounding); } + void LvglComponent::set_paused(bool paused, bool show_snow) { this->paused_ = paused; this->show_snow_ = show_snow; @@ -124,32 +125,38 @@ void LvglComponent::esphome_lvgl_init() { lv_update_event = static_cast(lv_event_register_id()); lv_api_event = static_cast(lv_event_register_id()); } + void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event) { lv_obj_add_event_cb(obj, callback, event, nullptr); } + void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2) { add_event_cb(obj, callback, event1); add_event_cb(obj, callback, event2); } + void LvglComponent::add_event_cb(lv_obj_t *obj, event_callback_t callback, lv_event_code_t event1, lv_event_code_t event2, lv_event_code_t event3) { add_event_cb(obj, callback, event1); add_event_cb(obj, callback, event2); add_event_cb(obj, callback, event3); } + void LvglComponent::add_page(LvPageType *page) { this->pages_.push_back(page); page->set_parent(this); lv_disp_set_default(this->disp_); page->setup(this->pages_.size() - 1); } + void LvglComponent::show_page(size_t index, lv_scr_load_anim_t anim, uint32_t time) { if (index >= this->pages_.size()) return; this->current_page_ = index; lv_scr_load_anim(this->pages_[this->current_page_]->obj, anim, time, 0, false); } + void LvglComponent::show_next_page(lv_scr_load_anim_t anim, uint32_t time) { if (this->pages_.empty() || (this->current_page_ == this->pages_.size() - 1 && !this->page_wrap_)) return; @@ -158,6 +165,7 @@ void LvglComponent::show_next_page(lv_scr_load_anim_t anim, uint32_t time) { } while (this->pages_[this->current_page_]->skip); // skip empty pages() this->show_page(this->current_page_, anim, time); } + void LvglComponent::show_prev_page(lv_scr_load_anim_t anim, uint32_t time) { if (this->pages_.empty() || (this->current_page_ == 0 && !this->page_wrap_)) return; @@ -166,8 +174,10 @@ void LvglComponent::show_prev_page(lv_scr_load_anim_t anim, uint32_t time) { } while (this->pages_[this->current_page_]->skip); // skip empty pages() this->show_page(this->current_page_, anim, time); } + size_t LvglComponent::get_current_page() const { return this->current_page_; } bool LvPageType::is_showing() const { return this->parent_->get_current_page() == this->index; } + void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_t *ptr) { auto width = lv_area_get_width(area); auto height = lv_area_get_height(area); @@ -222,7 +232,7 @@ void LvglComponent::draw_buffer_(const lv_area_t *area, lv_color_t *ptr) { } void LvglComponent::flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { - if (!this->paused_) { + if (!this->is_paused()) { auto now = millis(); this->draw_buffer_(area, color_p); ESP_LOGVV(TAG, "flush_cb, area=%d/%d, %d/%d took %dms", area->x1, area->y1, lv_area_get_width(area), @@ -230,6 +240,7 @@ void LvglComponent::flush_cb_(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv } lv_disp_flush_ready(disp_drv); } + IdleTrigger::IdleTrigger(LvglComponent *parent, TemplatableValue timeout) : timeout_(std::move(timeout)) { parent->add_on_idle_callback([this](uint32_t idle_time) { if (!this->is_idle_ && idle_time > this->timeout_.value()) { @@ -377,6 +388,27 @@ void LvKeyboardType::set_obj(lv_obj_t *lv_obj) { } #endif // USE_LVGL_KEYBOARD +void LvglComponent::draw_end_() { + if (this->draw_end_callback_ != nullptr) + this->draw_end_callback_->trigger(); + if (this->update_when_display_idle_) { + for (auto *disp : this->displays_) + disp->update(); + } +} + +bool LvglComponent::is_paused() const { + if (this->paused_) + return true; + if (this->update_when_display_idle_) { + for (auto *disp : this->displays_) { + if (!disp->is_idle()) + return true; + } + } + return false; +} + void LvglComponent::write_random_() { int iterations = 6 - lv_disp_get_inactive_time(this->disp_) / 60000; if (iterations <= 0) @@ -426,12 +458,13 @@ void LvglComponent::write_random_() { * presses a key or clicks on the screen. */ LvglComponent::LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, - int draw_rounding, bool resume_on_input) + int draw_rounding, bool resume_on_input, bool update_when_display_idle) : draw_rounding(draw_rounding), displays_(std::move(displays)), buffer_frac_(buffer_frac), full_refresh_(full_refresh), - resume_on_input_(resume_on_input) { + resume_on_input_(resume_on_input), + update_when_display_idle_(update_when_display_idle) { lv_disp_draw_buf_init(&this->draw_buf_, nullptr, nullptr, 0); lv_disp_drv_init(&this->disp_drv_); this->disp_drv_.draw_buf = &this->draw_buf_; @@ -466,7 +499,7 @@ void LvglComponent::setup() { buffer = lv_custom_mem_alloc(buf_bytes); // NOLINT } if (buffer == nullptr) { - this->status_set_error("Memory allocation failure"); + this->status_set_error(LOG_STR("Memory allocation failure")); this->mark_failed(); return; } @@ -479,7 +512,7 @@ void LvglComponent::setup() { if (this->rotation != display::DISPLAY_ROTATION_0_DEGREES) { this->rotate_buf_ = static_cast(lv_custom_mem_alloc(buf_bytes)); // NOLINT if (this->rotate_buf_ == nullptr) { - this->status_set_error("Memory allocation failure"); + this->status_set_error(LOG_STR("Memory allocation failure")); this->mark_failed(); return; } @@ -487,7 +520,7 @@ void LvglComponent::setup() { if (this->draw_start_callback_ != nullptr) { this->disp_drv_.render_start_cb = render_start_cb; } - if (this->draw_end_callback_ != nullptr) { + if (this->draw_end_callback_ != nullptr || this->update_when_display_idle_) { this->disp_drv_.monitor_cb = monitor_cb; } #if LV_USE_LOG @@ -509,14 +542,15 @@ void LvglComponent::setup() { void LvglComponent::update() { // update indicators - if (this->paused_) { + if (this->is_paused()) { return; } this->idle_callbacks_.call(lv_disp_get_inactive_time(this->disp_)); } + void LvglComponent::loop() { - if (this->paused_) { - if (this->show_snow_) + if (this->is_paused()) { + if (this->paused_ && this->show_snow_) this->write_random_(); } else { lv_timer_handler_run_in_period(5); diff --git a/esphome/components/lvgl/lvgl_esphome.h b/esphome/components/lvgl/lvgl_esphome.h index 1ae05f933f..9c82f3646b 100644 --- a/esphome/components/lvgl/lvgl_esphome.h +++ b/esphome/components/lvgl/lvgl_esphome.h @@ -50,6 +50,14 @@ static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BIT static const display::ColorBitness LV_BITNESS = display::ColorBitness::COLOR_BITNESS_332; #endif // LV_COLOR_DEPTH +#ifdef USE_LVGL_FONT +inline void lv_obj_set_style_text_font(lv_obj_t *obj, const font::Font *font, lv_style_selector_t part) { + lv_obj_set_style_text_font(obj, font->get_lv_font(), part); +} +inline void lv_style_set_text_font(lv_style_t *style, const font::Font *font) { + lv_style_set_text_font(style, font->get_lv_font()); +} +#endif #ifdef USE_LVGL_IMAGE // Shortcut / overload, so that the source of an image can easily be updated // from within a lambda. @@ -134,24 +142,6 @@ template class ObjUpdateAction : public Action { protected: std::function lamb_; }; -#ifdef USE_LVGL_FONT -class FontEngine { - public: - FontEngine(font::Font *esp_font); - const lv_font_t *get_lv_font(); - - const font::GlyphData *get_glyph_data(uint32_t unicode_letter); - uint16_t baseline{}; - uint16_t height{}; - uint8_t bpp{}; - - protected: - font::Font *font_{}; - uint32_t last_letter_{}; - const font::GlyphData *last_data_{}; - lv_font_t lv_font_{}; -}; -#endif // USE_LVGL_FONT #ifdef USE_LVGL_ANIMIMG void lv_animimg_stop(lv_obj_t *obj); #endif // USE_LVGL_ANIMIMG @@ -161,7 +151,7 @@ class LvglComponent : public PollingComponent { public: LvglComponent(std::vector displays, float buffer_frac, bool full_refresh, int draw_rounding, - bool resume_on_input); + bool resume_on_input, bool update_when_display_idle); static void static_flush_cb(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p); float get_setup_priority() const override { return setup_priority::PROCESSOR; } @@ -181,7 +171,9 @@ class LvglComponent : public PollingComponent { // @param paused If true, pause the display. If false, resume the display. // @param show_snow If true, show the snow effect when paused. void set_paused(bool paused, bool show_snow); - bool is_paused() const { return this->paused_; } + + // Returns true if the display is explicitly paused, or a blocking display update is in progress. + bool is_paused() const; // If the display is paused and we have resume_on_input_ set to true, resume the display. void maybe_wakeup() { if (this->paused_ && this->resume_on_input_) { @@ -220,10 +212,10 @@ class LvglComponent : public PollingComponent { void set_draw_end_trigger(Trigger<> *trigger) { this->draw_end_callback_ = trigger; } protected: - // these functions are never called unless the callbacks are non-null since the - // LVGL callbacks that call them are not set unless the start/end callbacks are non-null + void draw_end_(); + // Not checking for non-null callback since the + // LVGL callback that calls it is not set in that case void draw_start_() const { this->draw_start_callback_->trigger(); } - void draw_end_() const { this->draw_end_callback_->trigger(); } void write_random_(); void draw_buffer_(const lv_area_t *area, lv_color_t *ptr); @@ -232,6 +224,7 @@ class LvglComponent : public PollingComponent { size_t buffer_frac_{1}; bool full_refresh_{}; bool resume_on_input_{}; + bool update_when_display_idle_{}; lv_disp_draw_buf_t draw_buf_{}; lv_disp_drv_t disp_drv_{}; diff --git a/esphome/components/lvgl/schemas.py b/esphome/components/lvgl/schemas.py index 0dcf420f24..45d933c00e 100644 --- a/esphome/components/lvgl/schemas.py +++ b/esphome/components/lvgl/schemas.py @@ -1,6 +1,9 @@ +from collections.abc import Callable + from esphome import config_validation as cv from esphome.automation import Trigger, validate_automation from esphome.components.time import RealTimeClock +from esphome.config_validation import prepend_path from esphome.const import ( CONF_ARGS, CONF_FORMAT, @@ -19,7 +22,14 @@ from esphome.core import TimePeriod from esphome.core.config import StartupTrigger from . import defines as df, lv_validation as lvalid -from .defines import CONF_TIME_FORMAT, LV_GRAD_DIR +from .defines import ( + CONF_SCROLL_DIR, + CONF_SCROLL_SNAP_X, + CONF_SCROLL_SNAP_Y, + CONF_SCROLLBAR_MODE, + CONF_TIME_FORMAT, + LV_GRAD_DIR, +) from .helpers import CONF_IF_NAN, requires_component, validate_printf from .layout import ( FLEX_OBJ_SCHEMA, @@ -233,9 +243,19 @@ STYLE_SCHEMA = cv.Schema({cv.Optional(k): v for k, v in STYLE_PROPS.items()}).ex cv.Optional(df.CONF_SCROLLBAR_MODE): df.LvConstant( "LV_SCROLLBAR_MODE_", "OFF", "ON", "ACTIVE", "AUTO" ).one_of, + cv.Optional(CONF_SCROLL_DIR): df.SCROLL_DIRECTIONS.one_of, + cv.Optional(CONF_SCROLL_SNAP_X): df.SNAP_DIRECTIONS.one_of, + cv.Optional(CONF_SCROLL_SNAP_Y): df.SNAP_DIRECTIONS.one_of, } ) +OBJ_PROPERTIES = { + CONF_SCROLL_SNAP_X, + CONF_SCROLL_SNAP_Y, + CONF_SCROLL_DIR, + CONF_SCROLLBAR_MODE, +} + # Also allow widget specific properties for use in style definitions FULL_STYLE_SCHEMA = STYLE_SCHEMA.extend( { @@ -293,19 +313,36 @@ def automation_schema(typ: LvType): } -def base_update_schema(widget_type, parts): +def _update_widget(widget_type: WidgetType) -> Callable[[dict], dict]: """ - Create a schema for updating a widgets style properties, states and flags + During validation of update actions, create a map of action types to affected widgets + for use in final validation. + :param widget_type: + :return: + """ + + def validator(value: dict) -> dict: + df.get_data(df.KEY_UPDATED_WIDGETS).setdefault(widget_type, []).append(value) + return value + + return validator + + +def base_update_schema(widget_type: WidgetType | LvType, parts): + """ + Create a schema for updating a widget's style properties, states and flags. :param widget_type: The type of the ID :param parts: The allowable parts to specify :return: """ - return part_schema(parts).extend( + + w_type = widget_type.w_type if isinstance(widget_type, WidgetType) else widget_type + schema = part_schema(parts).extend( { cv.Required(CONF_ID): cv.ensure_list( cv.maybe_simple_value( { - cv.Required(CONF_ID): cv.use_id(widget_type), + cv.Required(CONF_ID): cv.use_id(w_type), }, key=CONF_ID, ) @@ -314,11 +351,9 @@ def base_update_schema(widget_type, parts): } ) - -def create_modify_schema(widget_type): - return base_update_schema(widget_type.w_type, widget_type.parts).extend( - widget_type.modify_schema - ) + if isinstance(widget_type, WidgetType): + schema.add_extra(_update_widget(widget_type)) + return schema def obj_schema(widget_type: WidgetType): @@ -411,6 +446,10 @@ def any_widget_schema(extras=None): Dynamically generate schemas for all possible LVGL widgets. This is what implements the ability to have a list of any kind of widget under the widgets: key. + This uses lazy evaluation - the schema is built when called during validation, + not at import time. This allows external components to register widgets + before schema validation begins. + :param extras: Additional schema to be applied to each generated one :return: A validator for the Widgets key """ @@ -418,7 +457,10 @@ def any_widget_schema(extras=None): def validator(value): if isinstance(value, dict): # Convert to list + is_dict = True value = [{k: v} for k, v in value.items()] + else: + is_dict = False if not isinstance(value, list): raise cv.Invalid("Expected a list of widgets") result = [] @@ -439,7 +481,9 @@ def any_widget_schema(extras=None): ) # Apply custom validation value = widget_type.validate(value or {}) - result.append({key: container_validator(value)}) + path = [key] if is_dict else [index, key] + with prepend_path(path): + result.append({key: container_validator(value)}) return result return validator diff --git a/esphome/components/lvgl/types.py b/esphome/components/lvgl/types.py index 8c33e13934..9c92ca7e98 100644 --- a/esphome/components/lvgl/types.py +++ b/esphome/components/lvgl/types.py @@ -1,8 +1,10 @@ import sys from esphome import automation, codegen as cg +from esphome.automation import register_action from esphome.config_validation import Schema from esphome.const import CONF_MAX_VALUE, CONF_MIN_VALUE, CONF_TEXT, CONF_VALUE +from esphome.core import EsphomeError from esphome.cpp_generator import MockObj, MockObjClass from esphome.cpp_types import esphome_ns @@ -43,7 +45,6 @@ lv_coord_t = cg.global_ns.namespace("lv_coord_t") lv_event_code_t = cg.global_ns.enum("lv_event_code_t") lv_indev_type_t = cg.global_ns.enum("lv_indev_type_t") lv_key_t = cg.global_ns.enum("lv_key_t") -FontEngine = lvgl_ns.class_("FontEngine") PlainTrigger = esphome_ns.class_("Trigger<>", automation.Trigger.template()) DrawEndTrigger = esphome_ns.class_( "Trigger", automation.Trigger.template(cg.uint32, cg.uint32) @@ -124,13 +125,16 @@ class WidgetType: schema=None, modify_schema=None, lv_name=None, + is_mock: bool = False, ): """ :param name: The widget name, e.g. "bar" :param w_type: The C type of the widget :param parts: What parts this widget supports :param schema: The config schema for defining a widget - :param modify_schema: A schema to update the widget + :param modify_schema: A schema to update the widget, defaults to the same as the schema + :param lv_name: The name of the LVGL widget in the LVGL library, if different from the name + :param is_mock: Whether this widget is a mock widget, i.e. not a real LVGL widget """ self.name = name self.lv_name = lv_name or name @@ -146,6 +150,22 @@ class WidgetType: self.modify_schema = modify_schema self.mock_obj = MockObj(f"lv_{self.lv_name}", "_") + # Local import to avoid circular import + from .automation import update_to_code + from .schemas import WIDGET_TYPES, base_update_schema + + if not is_mock: + if self.name in WIDGET_TYPES: + raise EsphomeError(f"Duplicate definition of widget type '{self.name}'") + WIDGET_TYPES[self.name] = self + + # Register the update action automatically, adding widget-specific properties + register_action( + f"lvgl.{self.name}.update", + ObjUpdateAction, + base_update_schema(self, self.parts).extend(self.modify_schema), + )(update_to_code) + @property def animated(self): return False @@ -162,7 +182,6 @@ class WidgetType: Generate code for a given widget :param w: The widget :param config: Its configuration - :return: Generated code as a list of text lines """ async def obj_creator(self, parent: MockObjClass, config: dict): @@ -208,6 +227,15 @@ class WidgetType: """ return value + def final_validate(self, widget, update_config, widget_config, path): + """ + Allow final validation for a given widget type update action + :param widget: A widget + :param update_config: The configuration for the update action + :param widget_config: The configuration for the widget itself + :param path: The path to the widget, for error reporting + """ + class NumberType(WidgetType): def get_max(self, config: dict): diff --git a/esphome/components/lvgl/widgets/__init__.py b/esphome/components/lvgl/widgets/__init__.py index 7d9f9cb7de..b1d157325b 100644 --- a/esphome/components/lvgl/widgets/__init__.py +++ b/esphome/components/lvgl/widgets/__init__.py @@ -21,7 +21,6 @@ from ..defines import ( CONF_MAIN, CONF_PAD_COLUMN, CONF_PAD_ROW, - CONF_SCROLLBAR_MODE, CONF_STYLES, CONF_WIDGETS, OBJ_FLAGS, @@ -45,7 +44,7 @@ from ..lvcode import ( lv_obj, lv_Pvariable, ) -from ..schemas import ALL_STYLES, STYLE_REMAP, WIDGET_TYPES +from ..schemas import ALL_STYLES, OBJ_PROPERTIES, STYLE_REMAP, WIDGET_TYPES from ..types import LV_STATE, LvType, WidgetType, lv_coord_t, lv_obj_t, lv_obj_t_ptr EVENT_LAMB = "event_lamb__" @@ -213,17 +212,14 @@ class LvScrActType(WidgetType): """ def __init__(self): - super().__init__("lv_scr_act()", lv_obj_t, ()) + super().__init__("lv_scr_act()", lv_obj_t, (), is_mock=True) async def to_code(self, w, config: dict): return [] -lv_scr_act_spec = LvScrActType() - - def get_scr_act(lv_comp: MockObj) -> Widget: - return Widget.create(None, lv_comp.get_scr_act(), lv_scr_act_spec, {}) + return Widget.create(None, lv_comp.get_scr_act(), LvScrActType(), {}) def get_widget_generator(wid): @@ -386,7 +382,7 @@ async def set_obj_properties(w: Widget, config): clrs = join_enums(flag_clr, "LV_OBJ_FLAG_") w.clear_flag(clrs) for key, value in lambs.items(): - lamb = await cg.process_lambda(value, [], return_type=cg.bool_) + lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) flag = f"LV_OBJ_FLAG_{key.upper()}" with LvConditional(call_lambda(lamb)) as cond: w.add_flag(flag) @@ -411,13 +407,14 @@ async def set_obj_properties(w: Widget, config): clears = join_enums(clears, "LV_STATE_") w.clear_state(clears) for key, value in lambs.items(): - lamb = await cg.process_lambda(value, [], return_type=cg.bool_) + lamb = await cg.process_lambda(value, [], capture="=", return_type=cg.bool_) state = f"LV_STATE_{key.upper()}" with LvConditional(call_lambda(lamb)) as cond: w.add_state(state) cond.else_() w.clear_state(state) - await w.set_property(CONF_SCROLLBAR_MODE, config, lv_name="obj") + for property in OBJ_PROPERTIES: + await w.set_property(property, config, lv_name="obj") async def add_widgets(parent: Widget, config: dict): diff --git a/esphome/components/lvgl/widgets/arc.py b/esphome/components/lvgl/widgets/arc.py index ef4da0d815..21530441f8 100644 --- a/esphome/components/lvgl/widgets/arc.py +++ b/esphome/components/lvgl/widgets/arc.py @@ -20,7 +20,13 @@ from ..defines import ( CONF_START_ANGLE, literal, ) -from ..lv_validation import get_start_value, lv_angle_degrees, lv_float, lv_int +from ..lv_validation import ( + get_start_value, + lv_angle_degrees, + lv_float, + lv_int, + lv_positive_int, +) from ..lvcode import lv, lv_expr, lv_obj from ..types import LvNumber, NumberType from . import Widget @@ -36,13 +42,20 @@ ARC_SCHEMA = cv.Schema( cv.Optional(CONF_ROTATION, default=0.0): lv_angle_degrees, cv.Optional(CONF_ADJUSTABLE, default=False): bool, cv.Optional(CONF_MODE, default="NORMAL"): ARC_MODES.one_of, - cv.Optional(CONF_CHANGE_RATE, default=720): cv.uint16_t, + cv.Optional(CONF_CHANGE_RATE, default=720): lv_positive_int, } ) ARC_MODIFY_SCHEMA = cv.Schema( { cv.Optional(CONF_VALUE): lv_float, + cv.Optional(CONF_MIN_VALUE): lv_int, + cv.Optional(CONF_MAX_VALUE): lv_int, + cv.Optional(CONF_START_ANGLE): lv_angle_degrees, + cv.Optional(CONF_END_ANGLE): lv_angle_degrees, + cv.Optional(CONF_ROTATION): lv_angle_degrees, + cv.Optional(CONF_MODE): ARC_MODES.one_of, + cv.Optional(CONF_CHANGE_RATE): lv_positive_int, } ) @@ -58,17 +71,34 @@ class ArcType(NumberType): ) async def to_code(self, w: Widget, config): - if CONF_MIN_VALUE in config: + if CONF_MIN_VALUE in config and CONF_MAX_VALUE in config: max_value = await lv_int.process(config[CONF_MAX_VALUE]) min_value = await lv_int.process(config[CONF_MIN_VALUE]) lv.arc_set_range(w.obj, min_value, max_value) - start = await lv_angle_degrees.process(config[CONF_START_ANGLE]) - end = await lv_angle_degrees.process(config[CONF_END_ANGLE]) - rotation = await lv_angle_degrees.process(config[CONF_ROTATION]) - lv.arc_set_bg_angles(w.obj, start, end) - lv.arc_set_rotation(w.obj, rotation) - lv.arc_set_mode(w.obj, literal(config[CONF_MODE])) - lv.arc_set_change_rate(w.obj, config[CONF_CHANGE_RATE]) + elif CONF_MIN_VALUE in config: + max_value = w.get_property(CONF_MAX_VALUE) + min_value = await lv_int.process(config[CONF_MIN_VALUE]) + lv.arc_set_range(w.obj, min_value, max_value) + elif CONF_MAX_VALUE in config: + max_value = await lv_int.process(config[CONF_MAX_VALUE]) + min_value = w.get_property(CONF_MIN_VALUE) + lv.arc_set_range(w.obj, min_value, max_value) + + await w.set_property( + CONF_START_ANGLE, + await lv_angle_degrees.process(config.get(CONF_START_ANGLE)), + ) + await w.set_property( + CONF_END_ANGLE, await lv_angle_degrees.process(config.get(CONF_END_ANGLE)) + ) + await w.set_property( + CONF_ROTATION, await lv_angle_degrees.process(config.get(CONF_ROTATION)) + ) + await w.set_property(CONF_MODE, config) + await w.set_property( + CONF_CHANGE_RATE, + await lv_positive_int.process(config.get(CONF_CHANGE_RATE)), + ) if CONF_ADJUSTABLE in config: if not config[CONF_ADJUSTABLE]: @@ -78,9 +108,7 @@ class ArcType(NumberType): # For some reason arc does not get automatically added to the default group lv.group_add_obj(lv_expr.group_get_default(), w.obj) - value = await get_start_value(config) - if value is not None: - lv.arc_set_value(w.obj, value) + await w.set_property(CONF_VALUE, await get_start_value(config)) arc_spec = ArcType() diff --git a/esphome/components/lvgl/widgets/button.py b/esphome/components/lvgl/widgets/button.py index b59884ee67..5f2910174f 100644 --- a/esphome/components/lvgl/widgets/button.py +++ b/esphome/components/lvgl/widgets/button.py @@ -1,20 +1,52 @@ -from esphome.const import CONF_BUTTON +from esphome import config_validation as cv +from esphome.const import CONF_BUTTON, CONF_TEXT +from esphome.cpp_generator import MockObj -from ..defines import CONF_MAIN +from ..defines import CONF_MAIN, CONF_WIDGETS +from ..helpers import add_lv_use +from ..lv_validation import lv_text +from ..lvcode import lv, lv_expr +from ..schemas import TEXT_SCHEMA from ..types import LvBoolean, WidgetType +from . import Widget +from .label import label_spec lv_button_t = LvBoolean("lv_btn_t") class ButtonType(WidgetType): def __init__(self): - super().__init__(CONF_BUTTON, lv_button_t, (CONF_MAIN,), lv_name="btn") + super().__init__( + CONF_BUTTON, lv_button_t, (CONF_MAIN,), schema=TEXT_SCHEMA, lv_name="btn" + ) + + def validate(self, value): + if CONF_TEXT in value: + if CONF_WIDGETS in value: + raise cv.Invalid("Cannot use both text and widgets in a button") + add_lv_use("label") + return value def get_uses(self): return ("btn",) - async def to_code(self, w, config): - return [] + def on_create(self, var: MockObj, config: dict): + if CONF_TEXT in config: + lv.label_create(var) + return var + + async def to_code(self, w: Widget, config): + if text := config.get(CONF_TEXT): + label_widget = Widget.create( + None, lv_expr.obj_get_child(w.obj, 0), label_spec + ) + await label_widget.set_property(CONF_TEXT, await lv_text.process(text)) + + def final_validate(self, widget, update_config, widget_config, path): + if CONF_TEXT in update_config and CONF_TEXT not in widget_config: + raise cv.Invalid( + "Button must have 'text:' configured to allow updating text", path + ) button_spec = ButtonType() diff --git a/esphome/components/lvgl/widgets/line.py b/esphome/components/lvgl/widgets/line.py index bd90edbefc..57cb965737 100644 --- a/esphome/components/lvgl/widgets/line.py +++ b/esphome/components/lvgl/widgets/line.py @@ -6,7 +6,7 @@ from esphome.core import Lambda from ..defines import CONF_MAIN, call_lambda from ..lvcode import lv_add from ..schemas import point_schema -from ..types import LvCompound, LvType +from ..types import LvCompound, LvType, lv_coord_t from . import Widget, WidgetType CONF_LINE = "line" @@ -23,9 +23,7 @@ LINE_SCHEMA = { async def process_coord(coord): if isinstance(coord, Lambda): - coord = call_lambda( - await cg.process_lambda(coord, [], return_type="lv_coord_t") - ) + coord = call_lambda(await cg.process_lambda(coord, [], return_type=lv_coord_t)) if not coord.endswith("()"): coord = f"static_cast({coord})" return cg.RawExpression(coord) diff --git a/esphome/components/lvgl/widgets/spinbox.py b/esphome/components/lvgl/widgets/spinbox.py index 26ad149c6f..c6f25e9587 100644 --- a/esphome/components/lvgl/widgets/spinbox.py +++ b/esphome/components/lvgl/widgets/spinbox.py @@ -1,8 +1,9 @@ from esphome import automation import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_RANGE_FROM, CONF_RANGE_TO, CONF_STEP, CONF_VALUE +from esphome.cpp_generator import MockObj -from ..automation import action_to_code, update_to_code +from ..automation import action_to_code from ..defines import ( CONF_CURSOR, CONF_DECIMAL_PLACES, @@ -114,7 +115,9 @@ class SpinboxType(WidgetType): w.obj, digits, digits - config[CONF_DECIMAL_PLACES] ) if (value := config.get(CONF_VALUE)) is not None: - lv.spinbox_set_value(w.obj, await lv_float.process(value)) + lv.spinbox_set_value( + w.obj, MockObj(await lv_float.process(value)) * w.get_scale() + ) def get_scale(self, config): return 10 ** config[CONF_DECIMAL_PLACES] @@ -171,17 +174,3 @@ async def spinbox_decrement(config, action_id, template_arg, args): lv.spinbox_decrement(w.obj) return await action_to_code(widgets, do_increment, action_id, template_arg, args) - - -@automation.register_action( - "lvgl.spinbox.update", - ObjUpdateAction, - cv.Schema( - { - cv.Required(CONF_ID): cv.use_id(lv_spinbox_t), - cv.Required(CONF_VALUE): lv_float, - } - ), -) -async def spinbox_update_to_code(config, action_id, template_arg, args): - return await update_to_code(config, action_id, template_arg, args) diff --git a/esphome/components/max17043/max17043.cpp b/esphome/components/max17043/max17043.cpp index f605fb1324..e8cf4d5ab1 100644 --- a/esphome/components/max17043/max17043.cpp +++ b/esphome/components/max17043/max17043.cpp @@ -57,14 +57,14 @@ void MAX17043Component::setup() { if (config_reg != MAX17043_CONFIG_POWER_UP_DEFAULT) { ESP_LOGE(TAG, "Device does not appear to be a MAX17043"); - this->status_set_error("unrecognised"); + this->status_set_error(LOG_STR("unrecognised")); this->mark_failed(); return; } // need to write back to config register to reset the sleep bit if (!this->write_byte_16(MAX17043_CONFIG, MAX17043_CONFIG_POWER_UP_DEFAULT)) { - this->status_set_error("sleep reset failed"); + this->status_set_error(LOG_STR("sleep reset failed")); this->mark_failed(); return; } diff --git a/esphome/components/mcp3204/mcp3204.cpp b/esphome/components/mcp3204/mcp3204.cpp index 4bb0cbed76..f0dd171a14 100644 --- a/esphome/components/mcp3204/mcp3204.cpp +++ b/esphome/components/mcp3204/mcp3204.cpp @@ -16,16 +16,21 @@ void MCP3204::dump_config() { ESP_LOGCONFIG(TAG, " Reference Voltage: %.2fV", this->reference_voltage_); } -float MCP3204::read_data(uint8_t pin) { - uint8_t adc_primary_config = 0b00000110 | (pin >> 2); - uint8_t adc_secondary_config = pin << 6; +float MCP3204::read_data(uint8_t pin, bool differential) { + uint8_t command, b0, b1; + + command = (1 << 6) | // start bit + ((differential ? 0 : 1) << 5) | // single or differential bit + ((pin & 0x07) << 2); // pin + this->enable(); - this->transfer_byte(adc_primary_config); - uint8_t adc_primary_byte = this->transfer_byte(adc_secondary_config); - uint8_t adc_secondary_byte = this->transfer_byte(0x00); + this->transfer_byte(command); + b0 = this->transfer_byte(0x00); + b1 = this->transfer_byte(0x00); this->disable(); - uint16_t digital_value = (adc_primary_byte << 8 | adc_secondary_byte) & 0b111111111111; - return float(digital_value) / 4096.000 * this->reference_voltage_; + + uint16_t digital_value = encode_uint16(b0, b1) >> 4; + return float(digital_value) / 4096.000 * this->reference_voltage_; // in V } } // namespace mcp3204 diff --git a/esphome/components/mcp3204/mcp3204.h b/esphome/components/mcp3204/mcp3204.h index 27261aa373..6287263a2a 100644 --- a/esphome/components/mcp3204/mcp3204.h +++ b/esphome/components/mcp3204/mcp3204.h @@ -18,7 +18,7 @@ class MCP3204 : public Component, void setup() override; void dump_config() override; float get_setup_priority() const override; - float read_data(uint8_t pin); + float read_data(uint8_t pin, bool differential); protected: float reference_voltage_; diff --git a/esphome/components/mcp3204/sensor/__init__.py b/esphome/components/mcp3204/sensor/__init__.py index a4b177cbcf..5f9aa9fdb6 100644 --- a/esphome/components/mcp3204/sensor/__init__.py +++ b/esphome/components/mcp3204/sensor/__init__.py @@ -13,6 +13,7 @@ MCP3204Sensor = mcp3204_ns.class_( "MCP3204Sensor", sensor.Sensor, cg.PollingComponent, voltage_sampler.VoltageSampler ) CONF_MCP3204_ID = "mcp3204_id" +CONF_DIFF_MODE = "diff_mode" CONFIG_SCHEMA = ( sensor.sensor_schema(MCP3204Sensor) @@ -20,6 +21,7 @@ CONFIG_SCHEMA = ( { cv.GenerateID(CONF_MCP3204_ID): cv.use_id(MCP3204), cv.Required(CONF_NUMBER): cv.int_range(min=0, max=7), + cv.Optional(CONF_DIFF_MODE, default=False): cv.boolean, } ) .extend(cv.polling_component_schema("60s")) @@ -30,6 +32,7 @@ async def to_code(config): var = cg.new_Pvariable( config[CONF_ID], config[CONF_NUMBER], + config[CONF_DIFF_MODE], ) await cg.register_parented(var, config[CONF_MCP3204_ID]) await cg.register_component(var, config) diff --git a/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp b/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp index ce0fd25462..4c4abef4a7 100644 --- a/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp +++ b/esphome/components/mcp3204/sensor/mcp3204_sensor.cpp @@ -7,16 +7,15 @@ namespace mcp3204 { static const char *const TAG = "mcp3204.sensor"; -MCP3204Sensor::MCP3204Sensor(uint8_t pin) : pin_(pin) {} - float MCP3204Sensor::get_setup_priority() const { return setup_priority::DATA; } void MCP3204Sensor::dump_config() { LOG_SENSOR("", "MCP3204 Sensor", this); ESP_LOGCONFIG(TAG, " Pin: %u", this->pin_); + ESP_LOGCONFIG(TAG, " Differential Mode: %s", YESNO(this->differential_mode_)); LOG_UPDATE_INTERVAL(this); } -float MCP3204Sensor::sample() { return this->parent_->read_data(this->pin_); } +float MCP3204Sensor::sample() { return this->parent_->read_data(this->pin_, this->differential_mode_); } void MCP3204Sensor::update() { this->publish_state(this->sample()); } } // namespace mcp3204 diff --git a/esphome/components/mcp3204/sensor/mcp3204_sensor.h b/esphome/components/mcp3204/sensor/mcp3204_sensor.h index 21c45590ab..5665b80b98 100644 --- a/esphome/components/mcp3204/sensor/mcp3204_sensor.h +++ b/esphome/components/mcp3204/sensor/mcp3204_sensor.h @@ -15,7 +15,7 @@ class MCP3204Sensor : public PollingComponent, public sensor::Sensor, public voltage_sampler::VoltageSampler { public: - MCP3204Sensor(uint8_t pin); + MCP3204Sensor(uint8_t pin, bool differential_mode) : pin_(pin), differential_mode_(differential_mode) {} void update() override; void dump_config() override; @@ -24,6 +24,7 @@ class MCP3204Sensor : public PollingComponent, protected: uint8_t pin_; + bool differential_mode_; }; } // namespace mcp3204 diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 4776bef22f..1daac93a2e 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -165,7 +165,7 @@ async def to_code(config): cg.add_library("LEAmDNS", None) if CORE.using_esp_idf: - add_idf_component(name="espressif/mdns", ref="1.8.2") + add_idf_component(name="espressif/mdns", ref="1.9.1") cg.add_define("USE_MDNS") diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 2c3150ff5d..4655907983 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -21,8 +21,7 @@ #include "esphome/components/dashboard_import/dashboard_import.h" #endif -namespace esphome { -namespace mdns { +namespace esphome::mdns { static const char *const TAG = "mdns"; @@ -119,7 +118,7 @@ void MDNSComponent::compile_records_(StaticVectorget_noise_ctx()->has_psk(); + bool has_psk = api::global_api_server->get_noise_ctx().has_psk(); const char *encryption_key = has_psk ? TXT_API_ENCRYPTION : TXT_API_ENCRYPTION_SUPPORTED; txt_records.push_back({MDNS_STR(encryption_key), MDNS_STR(NOISE_ENCRYPTION)}); #endif @@ -135,8 +134,7 @@ void MDNSComponent::compile_records_(StaticVector &services); }; -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index ecdc926cc9..5547a2524b 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -7,8 +7,7 @@ #include "esphome/core/log.h" #include "mdns_component.h" -namespace esphome { -namespace mdns { +namespace esphome::mdns { static const char *const TAG = "mdns"; @@ -56,7 +55,6 @@ void MDNSComponent::on_shutdown() { delay(40); // Allow the mdns packets announcing service removal to be sent } -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif // USE_ESP32 diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 9bbb406070..06f905884c 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -9,8 +9,7 @@ #include "esphome/core/log.h" #include "mdns_component.h" -namespace esphome { -namespace mdns { +namespace esphome::mdns { void MDNSComponent::setup() { #ifdef USE_MDNS_STORE_SERVICES @@ -52,7 +51,6 @@ void MDNSComponent::on_shutdown() { delay(10); } -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index f645d8d068..64b8c8f54b 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -6,8 +6,7 @@ #include "esphome/core/log.h" #include "mdns_component.h" -namespace esphome { -namespace mdns { +namespace esphome::mdns { void MDNSComponent::setup() { // Host platform doesn't have actual mDNS implementation @@ -15,7 +14,6 @@ void MDNSComponent::setup() { void MDNSComponent::on_shutdown() {} -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index fb2088f719..a049fe2109 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -9,8 +9,7 @@ #include -namespace esphome { -namespace mdns { +namespace esphome::mdns { void MDNSComponent::setup() { #ifdef USE_MDNS_STORE_SERVICES @@ -46,7 +45,6 @@ void MDNSComponent::setup() { void MDNSComponent::on_shutdown() {} -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index a9f5349f14..a102e0b6c3 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -9,8 +9,7 @@ #include -namespace esphome { -namespace mdns { +namespace esphome::mdns { void MDNSComponent::setup() { #ifdef USE_MDNS_STORE_SERVICES @@ -51,7 +50,6 @@ void MDNSComponent::on_shutdown() { delay(40); } -} // namespace mdns -} // namespace esphome +} // namespace esphome::mdns #endif diff --git a/esphome/components/micro_wake_word/micro_wake_word.cpp b/esphome/components/micro_wake_word/micro_wake_word.cpp index a0547b158e..ec8fa34da4 100644 --- a/esphome/components/micro_wake_word/micro_wake_word.cpp +++ b/esphome/components/micro_wake_word/micro_wake_word.cpp @@ -298,8 +298,7 @@ void MicroWakeWord::loop() { // uses floating point operations. if (!FrontendPopulateState(&this->frontend_config_, &this->frontend_state_, this->microphone_source_->get_audio_stream_info().get_sample_rate())) { - this->status_momentary_error( - "Failed to allocate buffers for spectrogram feature processor, attempting again in 1 second", 1000); + this->status_momentary_error("frontend_alloc", 1000); return; } @@ -308,7 +307,7 @@ void MicroWakeWord::loop() { if (this->inference_task_handle_ == nullptr) { FrontendFreeStateContents(&this->frontend_state_); // Deallocate frontend state - this->status_momentary_error("Task failed to start, attempting again in 1 second", 1000); + this->status_momentary_error("task_start", 1000); } } break; diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp index c1903fd878..147fef37bd 100644 --- a/esphome/components/micronova/button/micronova_button.cpp +++ b/esphome/components/micronova/button/micronova_button.cpp @@ -1,7 +1,6 @@ #include "micronova_button.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNovaButton::press_action() { switch (this->get_function()) { @@ -14,5 +13,4 @@ void MicroNovaButton::press_action() { this->micronova_->update(); } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h index 77649051d6..5c1d7d8455 100644 --- a/esphome/components/micronova/button/micronova_button.h +++ b/esphome/components/micronova/button/micronova_button.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/components/button/button.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { class MicroNovaButton : public Component, public button::Button, public MicroNovaButtonListener { public: @@ -19,5 +18,4 @@ class MicroNovaButton : public Component, public button::Button, public MicroNov void press_action() override; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index b96798ed12..52b719bff2 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -1,8 +1,7 @@ #include "micronova.h" #include "esphome/core/log.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNova::setup() { if (this->enable_rx_pin_ != nullptr) { @@ -144,5 +143,4 @@ void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) { } } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index fc68d941cf..acb85fad3c 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace micronova { +namespace esphome::micronova { static const char *const TAG = "micronova"; static const int STOVE_REPLY_DELAY = 60; @@ -160,5 +159,4 @@ class MicroNova : public PollingComponent, public uart::UARTDevice { MicroNovaSwitchListener *stove_switch_{nullptr}; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index 244eb7ee9f..b311c85b99 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -1,7 +1,6 @@ #include "micronova_number.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNovaNumber::process_value_from_stove(int value_from_stove) { float new_sensor_value = 0; @@ -41,5 +40,4 @@ void MicroNovaNumber::control(float value) { this->micronova_->update(); } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 49c6358255..79e59dbc28 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -3,8 +3,7 @@ #include "esphome/components/micronova/micronova.h" #include "esphome/components/number/number.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { class MicroNovaNumber : public number::Number, public MicroNovaSensorListener { public: @@ -24,5 +23,4 @@ class MicroNovaNumber : public number::Number, public MicroNovaSensorListener { uint8_t memory_write_location_ = 0; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/sensor/micronova_sensor.cpp b/esphome/components/micronova/sensor/micronova_sensor.cpp index 3f0c0feaf8..9fd8832f29 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.cpp +++ b/esphome/components/micronova/sensor/micronova_sensor.cpp @@ -1,7 +1,6 @@ #include "micronova_sensor.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNovaSensor::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { @@ -31,5 +30,4 @@ void MicroNovaSensor::process_value_from_stove(int value_from_stove) { this->publish_state(new_sensor_value); } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index 9d5ae96b87..081e68b09d 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -3,8 +3,7 @@ #include "esphome/components/micronova/micronova.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { class MicroNovaSensor : public sensor::Sensor, public MicroNovaSensorListener { public: @@ -23,5 +22,4 @@ class MicroNovaSensor : public sensor::Sensor, public MicroNovaSensorListener { int fan_speed_offset_ = 0; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index 28674acd96..81d36adccb 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -1,7 +1,6 @@ #include "micronova_switch.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNovaSwitch::write_state(bool state) { switch (this->get_function()) { @@ -31,5 +30,4 @@ void MicroNovaSwitch::write_state(bool state) { } } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index b0ca33b497..7019084355 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "esphome/components/switch/switch.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { class MicroNovaSwitch : public Component, public switch_::Switch, public MicroNovaSwitchListener { public: @@ -25,5 +24,4 @@ class MicroNovaSwitch : public Component, public switch_::Switch, public MicroNo void write_state(bool state) override; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index 03b192ffd1..b62fb1afce 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -1,7 +1,6 @@ #include "micronova_text_sensor.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { if (value_from_stove == -1) { @@ -27,5 +26,4 @@ void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { } } -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.h b/esphome/components/micronova/text_sensor/micronova_text_sensor.h index b4e5de9bb3..352f049654 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -3,8 +3,7 @@ #include "esphome/components/micronova/micronova.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace micronova { +namespace esphome::micronova { class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaSensorListener { public: @@ -16,5 +15,4 @@ class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaSens void process_value_from_stove(int value_from_stove) override; }; -} // namespace micronova -} // namespace esphome +} // namespace esphome::micronova diff --git a/esphome/components/mipi_dsi/mipi_dsi.cpp b/esphome/components/mipi_dsi/mipi_dsi.cpp index fbe251de41..cae8647398 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.cpp +++ b/esphome/components/mipi_dsi/mipi_dsi.cpp @@ -11,6 +11,12 @@ static bool notify_refresh_ready(esp_lcd_panel_handle_t panel, esp_lcd_dpi_panel xSemaphoreGiveFromISR(sem, &need_yield); return (need_yield == pdTRUE); } + +void MIPI_DSI::smark_failed(const LogString *message, esp_err_t err) { + ESP_LOGE(TAG, "%s: %s", LOG_STR_ARG(message), esp_err_to_name(err)); + this->mark_failed(message); +} + void MIPI_DSI::setup() { ESP_LOGCONFIG(TAG, "Running Setup"); @@ -31,7 +37,7 @@ void MIPI_DSI::setup() { }; auto err = esp_lcd_new_dsi_bus(&bus_config, &this->bus_handle_); if (err != ESP_OK) { - this->smark_failed("lcd_new_dsi_bus failed", err); + this->smark_failed(LOG_STR("lcd_new_dsi_bus failed"), err); return; } esp_lcd_dbi_io_config_t dbi_config = { @@ -41,7 +47,7 @@ void MIPI_DSI::setup() { }; err = esp_lcd_new_panel_io_dbi(this->bus_handle_, &dbi_config, &this->io_handle_); if (err != ESP_OK) { - this->smark_failed("new_panel_io_dbi failed", err); + this->smark_failed(LOG_STR("new_panel_io_dbi failed"), err); return; } auto pixel_format = LCD_COLOR_PIXEL_FORMAT_RGB565; @@ -69,7 +75,7 @@ void MIPI_DSI::setup() { }}; err = esp_lcd_new_panel_dpi(this->bus_handle_, &dpi_config, &this->handle_); if (err != ESP_OK) { - this->smark_failed("esp_lcd_new_panel_dpi failed", err); + this->smark_failed(LOG_STR("esp_lcd_new_panel_dpi failed"), err); return; } if (this->reset_pin_ != nullptr) { @@ -86,14 +92,14 @@ void MIPI_DSI::setup() { auto when = millis() + 120; err = esp_lcd_panel_init(this->handle_); if (err != ESP_OK) { - this->smark_failed("esp_lcd_init failed", err); + this->smark_failed(LOG_STR("esp_lcd_init failed"), err); return; } size_t index = 0; auto &vec = this->init_sequence_; while (index != vec.size()) { if (vec.size() - index < 2) { - this->mark_failed("Malformed init sequence"); + this->mark_failed(LOG_STR("Malformed init sequence")); return; } uint8_t cmd = vec[index++]; @@ -104,7 +110,7 @@ void MIPI_DSI::setup() { } else { uint8_t num_args = x & 0x7F; if (vec.size() - index < num_args) { - this->mark_failed("Malformed init sequence"); + this->mark_failed(LOG_STR("Malformed init sequence")); return; } if (cmd == SLEEP_OUT) { @@ -119,7 +125,7 @@ void MIPI_DSI::setup() { format_hex_pretty(ptr, num_args, '.', false).c_str()); err = esp_lcd_panel_io_tx_param(this->io_handle_, cmd, ptr, num_args); if (err != ESP_OK) { - this->smark_failed("lcd_panel_io_tx_param failed", err); + this->smark_failed(LOG_STR("lcd_panel_io_tx_param failed"), err); return; } index += num_args; @@ -134,7 +140,7 @@ void MIPI_DSI::setup() { err = (esp_lcd_dpi_panel_register_event_callbacks(this->handle_, &cbs, this->io_lock_)); if (err != ESP_OK) { - this->smark_failed("Failed to register callbacks", err); + this->smark_failed(LOG_STR("Failed to register callbacks"), err); return; } @@ -216,7 +222,7 @@ bool MIPI_DSI::check_buffer_() { RAMAllocator allocator; this->buffer_ = allocator.allocate(this->height_ * this->width_ * bytes_per_pixel); if (this->buffer_ == nullptr) { - this->mark_failed("Could not allocate buffer for display!"); + this->mark_failed(LOG_STR("Could not allocate buffer for display!")); return false; } return true; diff --git a/esphome/components/mipi_dsi/mipi_dsi.h b/esphome/components/mipi_dsi/mipi_dsi.h index ce8a2a2236..1cffe3b178 100644 --- a/esphome/components/mipi_dsi/mipi_dsi.h +++ b/esphome/components/mipi_dsi/mipi_dsi.h @@ -62,10 +62,7 @@ class MIPI_DSI : public display::Display { void set_lanes(uint8_t lanes) { this->lanes_ = lanes; } void set_madctl(uint8_t madctl) { this->madctl_ = madctl; } - void smark_failed(const char *message, esp_err_t err) { - auto str = str_sprintf("Setup failed: %s: %s", message, esp_err_to_name(err)); - this->mark_failed(str.c_str()); - } + void smark_failed(const LogString *message, esp_err_t err); void update() override; diff --git a/esphome/components/mipi_dsi/models/guition.py b/esphome/components/mipi_dsi/models/guition.py index 5f7db4ebda..cd566633f9 100644 --- a/esphome/components/mipi_dsi/models/guition.py +++ b/esphome/components/mipi_dsi/models/guition.py @@ -35,3 +35,70 @@ DriverChip( (0x10, 0x0C), (0x11, 0x0C), (0x12, 0x0C), (0x13, 0x0C), (0x30, 0x00), ], ) + + +# JC4880P443 Driver Configuration (ST7701) +# Using parameters from esp_lcd_st7701.h and the working full init sequence +# ---------------------------------------------------------------------------------------------------------------------- +# * Resolution: 480x800 +# * PCLK Frequency: 34 MHz +# * DSI Lane Bit Rate: 500 Mbps (using 2-Lane DSI configuration) +# * Horizontal Timing (hsync_pulse_width=12, hsync_back_porch=42, hsync_front_porch=42) +# * Vertical Timing (vsync_pulse_width=2, vsync_back_porch=8, vsync_front_porch=166) +# ---------------------------------------------------------------------------------------------------------------------- +DriverChip( + "JC4880P443", + width=480, + height=800, + hsync_back_porch=42, + hsync_pulse_width=12, + hsync_front_porch=42, + vsync_back_porch=8, + vsync_pulse_width=2, + vsync_front_porch=166, + pclk_frequency="34MHz", + lane_bit_rate="500Mbps", + swap_xy=cv.UNDEFINED, + color_order="RGB", + reset_pin=5, + initsequence=[ + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), + (0xEF, 0x08), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), + (0xC0, 0x63, 0x00), + (0xC1, 0x0D, 0x02), + (0xC2, 0x10, 0x08), + (0xCC, 0x10), + (0xB0, 0x80, 0x09, 0x53, 0x0C, 0xD0, 0x07, 0x0C, 0x09, 0x09, 0x28, 0x06, 0xD4, 0x13, 0x69, 0x2B, 0x71), + (0xB1, 0x80, 0x94, 0x5A, 0x10, 0xD3, 0x06, 0x0A, 0x08, 0x08, 0x25, 0x03, 0xD3, 0x12, 0x66, 0x6A, 0x0D), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x11), + (0xB0, 0x5D), + (0xB1, 0x58), + (0xB2, 0x87), + (0xB3, 0x80), + (0xB5, 0x4E), + (0xB7, 0x85), + (0xB8, 0x21), + (0xB9, 0x10, 0x1F), + (0xBB, 0x03), + (0xBC, 0x00), + (0xC1, 0x78), + (0xC2, 0x78), + (0xD0, 0x88), + (0xE0, 0x00, 0x3A, 0x02), + (0xE1, 0x04, 0xA0, 0x00, 0xA0, 0x05, 0xA0, 0x00, 0xA0, 0x00, 0x40, 0x40), + (0xE2, 0x30, 0x00, 0x40, 0x40, 0x32, 0xA0, 0x00, 0xA0, 0x00, 0xA0, 0x00, 0xA0, 0x00), + (0xE3, 0x00, 0x00, 0x33, 0x33), + (0xE4, 0x44, 0x44), + (0xE5, 0x09, 0x2E, 0xA0, 0xA0, 0x0B, 0x30, 0xA0, 0xA0, 0x05, 0x2A, 0xA0, 0xA0, 0x07, 0x2C, 0xA0, 0xA0), + (0xE6, 0x00, 0x00, 0x33, 0x33), + (0xE7, 0x44, 0x44), + (0xE8, 0x08, 0x2D, 0xA0, 0xA0, 0x0A, 0x2F, 0xA0, 0xA0, 0x04, 0x29, 0xA0, 0xA0, 0x06, 0x2B, 0xA0, 0xA0), + (0xEB, 0x00, 0x00, 0x4E, 0x4E, 0x00, 0x00, 0x00), + (0xEC, 0x08, 0x01), + (0xED, 0xB0, 0x2B, 0x98, 0xA4, 0x56, 0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xF7, 0x65, 0x4A, 0x89, 0xB2, 0x0B), + (0xEF, 0x08, 0x08, 0x08, 0x45, 0x3F, 0x54), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x00), + ] +) +# fmt: on diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 00c9c8cbff..74eedae4f4 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -73,7 +73,7 @@ void MipiRgbSpi::write_init_sequence_() { auto &vec = this->init_sequence_; while (index != vec.size()) { if (vec.size() - index < 2) { - this->mark_failed("Malformed init sequence"); + this->mark_failed(LOG_STR("Malformed init sequence")); return; } uint8_t cmd = vec[index++]; @@ -84,7 +84,7 @@ void MipiRgbSpi::write_init_sequence_() { } else { uint8_t num_args = x & 0x7F; if (vec.size() - index < num_args) { - this->mark_failed("Malformed init sequence"); + this->mark_failed(LOG_STR("Malformed init sequence")); return; } if (cmd == SLEEP_OUT) { @@ -164,8 +164,8 @@ void MipiRgb::common_setup_() { if (err == ESP_OK) err = esp_lcd_panel_init(this->handle_); if (err != ESP_OK) { - auto msg = str_sprintf("lcd setup failed: %s", esp_err_to_name(err)); - this->mark_failed(msg.c_str()); + ESP_LOGE(TAG, "lcd setup failed: %s", esp_err_to_name(err)); + this->mark_failed(LOG_STR("lcd setup failed")); } ESP_LOGCONFIG(TAG, "MipiRgb setup complete"); } @@ -249,7 +249,7 @@ bool MipiRgb::check_buffer_() { RAMAllocator allocator; this->buffer_ = allocator.allocate(this->height_ * this->width_); if (this->buffer_ == nullptr) { - this->mark_failed("Could not allocate buffer for display!"); + this->mark_failed(LOG_STR("Could not allocate buffer for display!")); return false; } return true; @@ -350,6 +350,7 @@ void MipiRgb::dump_config() { "\n Width: %u" "\n Height: %u" "\n Rotation: %d degrees" + "\n PCLK Inverted: %s" "\n HSync Pulse Width: %u" "\n HSync Back Porch: %u" "\n HSync Front Porch: %u" @@ -357,18 +358,18 @@ void MipiRgb::dump_config() { "\n VSync Back Porch: %u" "\n VSync Front Porch: %u" "\n Invert Colors: %s" - "\n Pixel Clock: %dMHz" + "\n Pixel Clock: %uMHz" "\n Reset Pin: %s" "\n DE Pin: %s" "\n PCLK Pin: %s" "\n HSYNC Pin: %s" "\n VSYNC Pin: %s", - this->model_, this->width_, this->height_, this->rotation_, this->hsync_pulse_width_, - this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_, this->vsync_back_porch_, - this->vsync_front_porch_, YESNO(this->invert_colors_), this->pclk_frequency_ / 1000000, - get_pin_name(this->reset_pin_).c_str(), get_pin_name(this->de_pin_).c_str(), - get_pin_name(this->pclk_pin_).c_str(), get_pin_name(this->hsync_pin_).c_str(), - get_pin_name(this->vsync_pin_).c_str()); + this->model_, this->width_, this->height_, this->rotation_, YESNO(this->pclk_inverted_), + this->hsync_pulse_width_, this->hsync_back_porch_, this->hsync_front_porch_, this->vsync_pulse_width_, + this->vsync_back_porch_, this->vsync_front_porch_, YESNO(this->invert_colors_), + (unsigned) (this->pclk_frequency_ / 1000000), get_pin_name(this->reset_pin_).c_str(), + get_pin_name(this->de_pin_).c_str(), get_pin_name(this->pclk_pin_).c_str(), + get_pin_name(this->hsync_pin_).c_str(), get_pin_name(this->vsync_pin_).c_str()); if (this->madctl_ & MADCTL_BGR) { this->dump_pins_(8, 13, "Blue", 0); diff --git a/esphome/components/mipi_rgb/models/guition.py b/esphome/components/mipi_rgb/models/guition.py index da433e686e..915b8beda0 100644 --- a/esphome/components/mipi_rgb/models/guition.py +++ b/esphome/components/mipi_rgb/models/guition.py @@ -11,6 +11,7 @@ st7701s.extend( vsync_pin=17, pclk_pin=21, pclk_frequency="12MHz", + pclk_inverted=False, pixel_mode="18bit", mirror_x=True, mirror_y=True, diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index bfd1c9aa3f..0b0a9548ca 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -24,6 +24,8 @@ class ST7701S(DriverChip): sdir = 0 if transform.get(CONF_MIRROR_X): sdir |= 0x04 + # XFLIP doesn't do anything in the ST7701S, + # it's set in the madctl byte just so it can be reported at runtime by logconfig madctl |= MADCTL_XFLIP sequence.append((SDIR_CMD, sdir)) return madctl diff --git a/esphome/components/mipi_spi/mipi_spi.h b/esphome/components/mipi_spi/mipi_spi.h index 7e597d1c61..1953aef035 100644 --- a/esphome/components/mipi_spi/mipi_spi.h +++ b/esphome/components/mipi_spi/mipi_spi.h @@ -478,7 +478,7 @@ class MipiSpiBuffer : public MipiSpi allocator{}; this->buffer_ = allocator.allocate(BUFFER_WIDTH * BUFFER_HEIGHT / FRACTION); if (this->buffer_ == nullptr) { - this->mark_failed("Buffer allocation failed"); + this->mark_failed(LOG_STR("Buffer allocation failed")); } } diff --git a/esphome/components/mipi_spi/models/jc.py b/esphome/components/mipi_spi/models/jc.py index 5dbf049ded..5b936fd956 100644 --- a/esphome/components/mipi_spi/models/jc.py +++ b/esphome/components/mipi_spi/models/jc.py @@ -484,4 +484,109 @@ DriverChip( ), ) +DriverChip( + "JC4827W543", + height=272, + width=480, + offset_height=0, + offset_width=0, + cs_pin={CONF_NUMBER: 45, CONF_IGNORE_STRAPPING_WARNING: True}, + invert_colors=True, + color_order=MODE_RGB, + bus_mode=TYPE_QUAD, + data_rate="20MHz", + initsequence=( + (0xFF, 0xA5), + (0x41, 0x03), + (0x44, 0x15), + (0x45, 0x15), + (0x7D, 0x03), + (0xC1, 0xBB), + (0xC2, 0x05), + (0xC3, 0x10), + (0xC6, 0x3E), + (0xC7, 0x25), + (0xC8, 0x11), + (0x7A, 0x5F), + (0x6F, 0x44), + (0x78, 0x70), + (0xC9, 0x00), + (0x67, 0x21), + (0x51, 0x0A), + (0x52, 0x76), + (0x53, 0x0A), + (0x54, 0x76), + (0x46, 0x0A), + (0x47, 0x2A), + (0x48, 0x0A), + (0x49, 0x1A), + (0x56, 0x43), + (0x57, 0x42), + (0x58, 0x3C), + (0x59, 0x64), + (0x5A, 0x41), + (0x5B, 0x3C), + (0x5C, 0x02), + (0x5D, 0x3C), + (0x5E, 0x1F), + (0x60, 0x80), + (0x61, 0x3F), + (0x62, 0x21), + (0x63, 0x07), + (0x64, 0xE0), + (0x65, 0x02), + (0xCA, 0x20), + (0xCB, 0x52), + (0xCC, 0x10), + (0xCD, 0x42), + (0xD0, 0x20), + (0xD1, 0x52), + (0xD2, 0x10), + (0xD3, 0x42), + (0xD4, 0x0A), + (0xD5, 0x32), + (0x80, 0x00), + (0xA0, 0x00), + (0x81, 0x07), + (0xA1, 0x06), + (0x82, 0x02), + (0xA2, 0x01), + (0x86, 0x11), + (0xA6, 0x10), + (0x87, 0x27), + (0xA7, 0x27), + (0x83, 0x37), + (0xA3, 0x37), + (0x84, 0x35), + (0xA4, 0x35), + (0x85, 0x3F), + (0xA5, 0x3F), + (0x88, 0x0B), + (0xA8, 0x0B), + (0x89, 0x14), + (0xA9, 0x14), + (0x8A, 0x1A), + (0xAA, 0x1A), + (0x8B, 0x0A), + (0xAB, 0x0A), + (0x8C, 0x14), + (0xAC, 0x08), + (0x8D, 0x17), + (0xAD, 0x07), + (0x8E, 0x16), + (0xAE, 0x06), + (0x8F, 0x1B), + (0xAF, 0x07), + (0x90, 0x04), + (0xB0, 0x04), + (0x91, 0x0A), + (0xB1, 0x0A), + (0x92, 0x16), + (0xB2, 0x15), + (0xFF, 0x00), + (0x11, 0x00), + (0x29, 0x00), + ), +) + models = {} diff --git a/esphome/components/mixer/speaker/mixer_speaker.cpp b/esphome/components/mixer/speaker/mixer_speaker.cpp index b0b64f5709..043b629cf1 100644 --- a/esphome/components/mixer/speaker/mixer_speaker.cpp +++ b/esphome/components/mixer/speaker/mixer_speaker.cpp @@ -78,19 +78,20 @@ void SourceSpeaker::loop() { } else { switch (err) { case ESP_ERR_NO_MEM: - this->status_set_error("Failed to start mixer: not enough memory"); + this->status_set_error(LOG_STR("Failed to start mixer: not enough memory")); break; case ESP_ERR_NOT_SUPPORTED: - this->status_set_error("Failed to start mixer: unsupported bits per sample"); + this->status_set_error(LOG_STR("Failed to start mixer: unsupported bits per sample")); break; case ESP_ERR_INVALID_ARG: - this->status_set_error("Failed to start mixer: audio stream isn't compatible with the other audio stream."); + this->status_set_error( + LOG_STR("Failed to start mixer: audio stream isn't compatible with the other audio stream.")); break; case ESP_ERR_INVALID_STATE: - this->status_set_error("Failed to start mixer: mixer task failed to start"); + this->status_set_error(LOG_STR("Failed to start mixer: mixer task failed to start")); break; default: - this->status_set_error("Failed to start mixer"); + this->status_set_error(LOG_STR("Failed to start mixer")); break; } @@ -317,7 +318,7 @@ void MixerSpeaker::loop() { xEventGroupClearBits(this->event_group_, MixerEventGroupBits::STATE_STARTING); } if (event_group_bits & MixerEventGroupBits::ERR_ESP_NO_MEM) { - this->status_set_error("Failed to allocate the mixer's internal buffer"); + this->status_set_error(LOG_STR("Failed to allocate the mixer's internal buffer")); xEventGroupClearBits(this->event_group_, MixerEventGroupBits::ERR_ESP_NO_MEM); } if (event_group_bits & MixerEventGroupBits::STATE_RUNNING) { diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp index 9527f09f59..42d61f81a3 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.cpp @@ -116,7 +116,7 @@ bool MopekaProCheck::parse_device(const esp32_ble_tracker::ESPBTDevice &device) // Get temperature of sensor if (this->temperature_ != nullptr) { - uint8_t temp_in_c = this->parse_temperature_(manu_data.data); + int8_t temp_in_c = this->parse_temperature_(manu_data.data); this->temperature_->publish_state(temp_in_c); } @@ -145,7 +145,7 @@ uint32_t MopekaProCheck::parse_distance_(const std::vector &message) { (MOPEKA_LPG_COEF[0] + MOPEKA_LPG_COEF[1] * raw_t + MOPEKA_LPG_COEF[2] * raw_t * raw_t)); } -uint8_t MopekaProCheck::parse_temperature_(const std::vector &message) { return (message[2] & 0x7F) - 40; } +int8_t MopekaProCheck::parse_temperature_(const std::vector &message) { return (message[2] & 0x7F) - 40; } SensorReadQuality MopekaProCheck::parse_read_quality_(const std::vector &message) { // Since a 8 bit value is being shifted and truncated to 2 bits all possible values are defined as enumeration diff --git a/esphome/components/mopeka_pro_check/mopeka_pro_check.h b/esphome/components/mopeka_pro_check/mopeka_pro_check.h index 4cbe8f2afe..41fb312152 100644 --- a/esphome/components/mopeka_pro_check/mopeka_pro_check.h +++ b/esphome/components/mopeka_pro_check/mopeka_pro_check.h @@ -61,7 +61,7 @@ class MopekaProCheck : public Component, public esp32_ble_tracker::ESPBTDeviceLi uint8_t parse_battery_level_(const std::vector &message); uint32_t parse_distance_(const std::vector &message); - uint8_t parse_temperature_(const std::vector &message); + int8_t parse_temperature_(const std::vector &message); SensorReadQuality parse_read_quality_(const std::vector &message); }; diff --git a/esphome/components/mqtt/__init__.py b/esphome/components/mqtt/__init__.py index 1fc0c30db1..237ed2ce38 100644 --- a/esphome/components/mqtt/__init__.py +++ b/esphome/components/mqtt/__init__.py @@ -55,6 +55,7 @@ from esphome.const import ( PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, + PLATFORM_RTL87XX, PlatformFramework, ) from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -316,7 +317,7 @@ CONFIG_SCHEMA = cv.All( } ), validate_config, - cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX]), + cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_BK72XX, PLATFORM_RTL87XX]), _consume_mqtt_sockets, ) diff --git a/esphome/components/mqtt/mqtt_client.cpp b/esphome/components/mqtt/mqtt_client.cpp index 9055b4421e..ba701b90a3 100644 --- a/esphome/components/mqtt/mqtt_client.cpp +++ b/esphome/components/mqtt/mqtt_client.cpp @@ -57,15 +57,7 @@ void MQTTClientComponent::setup() { }); #ifdef USE_LOGGER if (this->is_log_message_enabled() && logger::global_logger != nullptr) { - logger::global_logger->add_on_log_callback( - [this](int level, const char *tag, const char *message, size_t message_len) { - if (level <= this->log_level_ && this->is_connected()) { - this->publish({.topic = this->log_message_.topic, - .payload = std::string(message, message_len), - .qos = this->log_message_.qos, - .retain = this->log_message_.retain}); - } - }); + logger::global_logger->add_log_listener(this); } #endif @@ -140,7 +132,7 @@ void MQTTClientComponent::send_device_info_() { #endif #ifdef USE_API_NOISE - root[api::global_api_server->get_noise_ctx()->has_psk() ? "api_encryption" : "api_encryption_supported"] = + root[api::global_api_server->get_noise_ctx().has_psk() ? "api_encryption" : "api_encryption_supported"] = "Noise_NNpsk0_25519_ChaChaPoly_SHA256"; #endif }, @@ -148,6 +140,18 @@ void MQTTClientComponent::send_device_info_() { // NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) } +#ifdef USE_LOGGER +void MQTTClientComponent::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { + (void) tag; + if (level <= this->log_level_ && this->is_connected()) { + this->publish({.topic = this->log_message_.topic, + .payload = std::string(message, message_len), + .qos = this->log_message_.qos, + .retain = this->log_message_.retain}); + } +} +#endif + void MQTTClientComponent::dump_config() { ESP_LOGCONFIG(TAG, "MQTT:\n" diff --git a/esphome/components/mqtt/mqtt_client.h b/esphome/components/mqtt/mqtt_client.h index 79383ee857..8547fe337f 100644 --- a/esphome/components/mqtt/mqtt_client.h +++ b/esphome/components/mqtt/mqtt_client.h @@ -10,6 +10,9 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif #if defined(USE_ESP32) #include "mqtt_backend_esp32.h" #elif defined(USE_ESP8266) @@ -97,7 +100,12 @@ enum MQTTClientState { class MQTTComponent; -class MQTTClientComponent : public Component { +class MQTTClientComponent : public Component +#ifdef USE_LOGGER + , + public logger::LogListener +#endif +{ public: MQTTClientComponent(); @@ -238,6 +246,10 @@ class MQTTClientComponent : public Component { /// MQTT client setup priority float get_setup_priority() const override; +#ifdef USE_LOGGER + void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override; +#endif + void on_message(const std::string &topic, const std::string &payload); bool can_proceed() override; diff --git a/esphome/components/mqtt/mqtt_light.cpp b/esphome/components/mqtt/mqtt_light.cpp index 883b67ffc6..fe911bfba2 100644 --- a/esphome/components/mqtt/mqtt_light.cpp +++ b/esphome/components/mqtt/mqtt_light.cpp @@ -25,8 +25,11 @@ void MQTTJSONLightComponent::setup() { call.perform(); }); - auto f = std::bind(&MQTTJSONLightComponent::publish_state_, this); - this->state_->add_new_remote_values_callback([this, f]() { this->defer("send", f); }); + this->state_->add_remote_values_listener(this); +} + +void MQTTJSONLightComponent::on_light_remote_values_update() { + this->defer("send", [this]() { this->publish_state_(); }); } MQTTJSONLightComponent::MQTTJSONLightComponent(LightState *state) : state_(state) {} diff --git a/esphome/components/mqtt/mqtt_light.h b/esphome/components/mqtt/mqtt_light.h index 3d1e770d4d..a105f3d7b8 100644 --- a/esphome/components/mqtt/mqtt_light.h +++ b/esphome/components/mqtt/mqtt_light.h @@ -11,7 +11,7 @@ namespace esphome { namespace mqtt { -class MQTTJSONLightComponent : public mqtt::MQTTComponent { +class MQTTJSONLightComponent : public mqtt::MQTTComponent, public light::LightRemoteValuesListener { public: explicit MQTTJSONLightComponent(light::LightState *state); @@ -25,6 +25,9 @@ class MQTTJSONLightComponent : public mqtt::MQTTComponent { bool send_initial_state() override; + // LightRemoteValuesListener interface + void on_light_remote_values_update() override; + protected: std::string component_type() const override; const EntityBase *get_entity() const override; diff --git a/esphome/components/mqtt/mqtt_lock.cpp b/esphome/components/mqtt/mqtt_lock.cpp index 0e15377ba4..95efbf60e1 100644 --- a/esphome/components/mqtt/mqtt_lock.cpp +++ b/esphome/components/mqtt/mqtt_lock.cpp @@ -48,8 +48,14 @@ void MQTTLockComponent::send_discovery(JsonObject root, mqtt::SendDiscoveryConfi bool MQTTLockComponent::send_initial_state() { return this->publish_state(); } bool MQTTLockComponent::publish_state() { - std::string payload = lock_state_to_string(this->lock_->state); - return this->publish(this->get_state_topic_(), payload); +#ifdef USE_STORE_LOG_STR_IN_FLASH + char buf[LOCK_STATE_STR_SIZE]; + strncpy_P(buf, (PGM_P) lock_state_to_string(this->lock_->state), sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + return this->publish(this->get_state_topic_(), buf); +#else + return this->publish(this->get_state_topic_(), LOG_STR_ARG(lock_state_to_string(this->lock_->state))); +#endif } } // namespace mqtt diff --git a/esphome/components/nau7802/nau7802.cpp b/esphome/components/nau7802/nau7802.cpp index 6a31b754f7..11f63a9a33 100644 --- a/esphome/components/nau7802/nau7802.cpp +++ b/esphome/components/nau7802/nau7802.cpp @@ -278,7 +278,7 @@ void NAU7802Sensor::loop() { this->set_calibration_failure_(true); this->state_ = CalibrationState::INACTIVE; ESP_LOGE(TAG, "Failed to calibrate sensor"); - this->status_set_error("Calibration Failed"); + this->status_set_error(LOG_STR("Calibration Failed")); return; } diff --git a/esphome/components/network/__init__.py b/esphome/components/network/__init__.py index 1d62b661ca..d7a51fb0c6 100644 --- a/esphome/components/network/__init__.py +++ b/esphome/components/network/__init__.py @@ -1,7 +1,9 @@ import ipaddress +import logging import esphome.codegen as cg from esphome.components.esp32 import add_idf_sdkconfig_option +from esphome.components.psram import is_guaranteed as psram_is_guaranteed import esphome.config_validation as cv from esphome.const import CONF_ENABLE_IPV6, CONF_MIN_IPV6_ADDR_COUNT from esphome.core import CORE, CoroPriority, coroutine_with_priority @@ -9,6 +11,13 @@ from esphome.core import CORE, CoroPriority, coroutine_with_priority CODEOWNERS = ["@esphome/core"] AUTO_LOAD = ["mdns"] +_LOGGER = logging.getLogger(__name__) + +# High performance networking tracking infrastructure +# Components can request high performance networking and this configures lwip and WiFi settings +KEY_HIGH_PERFORMANCE_NETWORKING = "high_performance_networking" +CONF_ENABLE_HIGH_PERFORMANCE = "enable_high_performance" + network_ns = cg.esphome_ns.namespace("network") IPAddress = network_ns.class_("IPAddress") @@ -47,6 +56,55 @@ def ip_address_literal(ip: str | int | None) -> cg.MockObj: return IPAddress(str(ip)) +def require_high_performance_networking() -> None: + """Request high performance networking for network and WiFi. + + Call this from components that need optimized network performance for streaming + or high-throughput data transfer. This enables high performance mode which + configures both lwip TCP settings and WiFi driver settings for improved + network performance. + + Settings applied (ESP-IDF only): + - lwip: Larger TCP buffers, windows, and mailbox sizes + - WiFi: Increased RX/TX buffers, AMPDU aggregation, PSRAM allocation (set by wifi component) + + Configuration is PSRAM-aware: + - With PSRAM guaranteed: Aggressive settings (512 RX buffers, 512KB TCP windows) + - Without PSRAM: Conservative optimized settings (64 buffers, 65KB TCP windows) + + Example: + from esphome.components import network + + def _request_high_performance_networking(config): + network.require_high_performance_networking() + return config + + CONFIG_SCHEMA = cv.All( + ..., + _request_high_performance_networking, + ) + """ + # Only set up once (idempotent - multiple components can call this) + if not CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False): + CORE.data[KEY_HIGH_PERFORMANCE_NETWORKING] = True + + +def has_high_performance_networking() -> bool: + """Check if high performance networking mode is enabled. + + Returns True when high performance networking has been requested by a + component or explicitly enabled in the network configuration. This indicates + that lwip and WiFi will use optimized buffer sizes and settings. + + This function should be called during code generation (to_code phase) by + components that need to apply performance-related settings. + + Returns: + bool: True if high performance networking is enabled, False otherwise + """ + return CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False) + + CONFIG_SCHEMA = cv.Schema( { cv.SplitDefault( @@ -71,6 +129,7 @@ CONFIG_SCHEMA = cv.Schema( ), ), cv.Optional(CONF_MIN_IPV6_ADDR_COUNT, default=0): cv.positive_int, + cv.Optional(CONF_ENABLE_HIGH_PERFORMANCE): cv.All(cv.boolean, cv.only_on_esp32), } ) @@ -80,6 +139,70 @@ async def to_code(config): cg.add_define("USE_NETWORK") if CORE.using_arduino and CORE.is_esp32: cg.add_library("Networking", None) + + # Apply high performance networking settings + # Config can explicitly enable/disable, or default to component-driven behavior + enable_high_perf = config.get(CONF_ENABLE_HIGH_PERFORMANCE) + component_requested = CORE.data.get(KEY_HIGH_PERFORMANCE_NETWORKING, False) + + # Explicit config overrides component request + should_enable = ( + enable_high_perf if enable_high_perf is not None else component_requested + ) + + # Log when user explicitly disables but a component requested it + if enable_high_perf is False and component_requested: + _LOGGER.info( + "High performance networking disabled by user configuration (overriding component request)" + ) + + if CORE.is_esp32 and CORE.using_esp_idf and should_enable: + # Check if PSRAM is guaranteed (set by psram component during final validation) + psram_guaranteed = psram_is_guaranteed() + + if psram_guaranteed: + _LOGGER.info( + "Applying high-performance lwip settings (PSRAM guaranteed): 512KB TCP windows, 512 mailbox sizes" + ) + # PSRAM is guaranteed - use aggressive settings + # Higher maximum values are allowed because CONFIG_LWIP_WND_SCALE is set to true + # CONFIG_LWIP_WND_SCALE can only be enabled if CONFIG_SPIRAM_IGNORE_NOTFOUND isn't set + # Based on https://github.com/espressif/esp-adf/issues/297#issuecomment-783811702 + + # Enable window scaling for much larger TCP windows + add_idf_sdkconfig_option("CONFIG_LWIP_WND_SCALE", True) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RCV_SCALE", 3) + + # Large TCP buffers and windows (requires PSRAM) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT", 65534) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", 512000) + + # Large mailboxes for high throughput + add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 512) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 512) + + # TCP connection limits + add_idf_sdkconfig_option("CONFIG_LWIP_MAX_ACTIVE_TCP", 16) + add_idf_sdkconfig_option("CONFIG_LWIP_MAX_LISTENING_TCP", 16) + + # TCP optimizations + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_MAXRTX", 12) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_SYNMAXRTX", 6) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_MSS", 1436) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_MSL", 60000) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_OVERSIZE_MSS", True) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_QUEUE_OOSEQ", True) + else: + _LOGGER.info( + "Applying optimized lwip settings: 65KB TCP windows, 64 mailbox sizes" + ) + # PSRAM not guaranteed - use more conservative, but still optimized settings + # Based on https://github.com/espressif/esp-idf/blob/release/v5.4/examples/wifi/iperf/sdkconfig.defaults.esp32 + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT", 65534) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", 65534) + add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) + add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64) + if (enable_ipv6 := config.get(CONF_ENABLE_IPV6, None)) is not None: cg.add_define("USE_NETWORK_IPV6", enable_ipv6) if enable_ipv6: diff --git a/esphome/components/network/ip_address.h b/esphome/components/network/ip_address.h index 5e6b0dbd96..3d8b062d0b 100644 --- a/esphome/components/network/ip_address.h +++ b/esphome/components/network/ip_address.h @@ -81,7 +81,12 @@ struct IPAddress { ip_addr_.type = IPADDR_TYPE_V6; } #endif /* LWIP_IPV6 */ - IPAddress(esp_ip4_addr_t *other_ip) { memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(esp_ip4_addr_t)); } + IPAddress(esp_ip4_addr_t *other_ip) { + memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(esp_ip4_addr_t)); +#if LWIP_IPV6 + ip_addr_.type = IPADDR_TYPE_V4; +#endif + } IPAddress(esp_ip_addr_t *other_ip) { #if LWIP_IPV6 memcpy((void *) &ip_addr_, (void *) other_ip, sizeof(ip_addr_)); @@ -118,10 +123,10 @@ struct IPAddress { operator arduino_ns::IPAddress() const { return ip_addr_get_ip4_u32(&ip_addr_); } #endif - bool is_set() { return !ip_addr_isany(&ip_addr_); } // NOLINT(readability-simplify-boolean-expr) - bool is_ip4() { return IP_IS_V4(&ip_addr_); } - bool is_ip6() { return IP_IS_V6(&ip_addr_); } - bool is_multicast() { return ip_addr_ismulticast(&ip_addr_); } + bool is_set() const { return !ip_addr_isany(&ip_addr_); } // NOLINT(readability-simplify-boolean-expr) + bool is_ip4() const { return IP_IS_V4(&ip_addr_); } + bool is_ip6() const { return IP_IS_V6(&ip_addr_); } + bool is_multicast() const { return ip_addr_ismulticast(&ip_addr_); } std::string str() const { return str_lower_case(ipaddr_ntoa(&ip_addr_)); } bool operator==(const IPAddress &other) const { return ip_addr_cmp(&ip_addr_, &other.ip_addr_); } bool operator!=(const IPAddress &other) const { return !ip_addr_cmp(&ip_addr_, &other.ip_addr_); } diff --git a/esphome/components/nextion/nextion_upload_arduino.cpp b/esphome/components/nextion/nextion_upload_arduino.cpp index b4d217d7aa..baea938729 100644 --- a/esphome/components/nextion/nextion_upload_arduino.cpp +++ b/esphome/components/nextion/nextion_upload_arduino.cpp @@ -174,6 +174,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Check if baud rate is supported this->original_baud_rate_ = this->parent_->get_baud_rate(); + if (baud_rate <= 0) { + baud_rate = this->original_baud_rate_; + } ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client diff --git a/esphome/components/nextion/nextion_upload_idf.cpp b/esphome/components/nextion/nextion_upload_idf.cpp index 3b0d65643d..942e3dd6c3 100644 --- a/esphome/components/nextion/nextion_upload_idf.cpp +++ b/esphome/components/nextion/nextion_upload_idf.cpp @@ -177,6 +177,9 @@ bool Nextion::upload_tft(uint32_t baud_rate, bool exit_reparse) { // Check if baud rate is supported this->original_baud_rate_ = this->parent_->get_baud_rate(); + if (baud_rate <= 0) { + baud_rate = this->original_baud_rate_; + } ESP_LOGD(TAG, "Baud rate: %" PRIu32, baud_rate); // Define the configuration for the HTTP client diff --git a/esphome/components/nrf52/__init__.py b/esphome/components/nrf52/__init__.py index ace324c1f5..03927e8ea2 100644 --- a/esphome/components/nrf52/__init__.py +++ b/esphome/components/nrf52/__init__.py @@ -25,6 +25,7 @@ from esphome.const import ( CONF_FRAMEWORK, CONF_ID, CONF_RESET_PIN, + CONF_VOLTAGE, KEY_CORE, KEY_FRAMEWORK_VERSION, KEY_TARGET_FRAMEWORK, @@ -102,6 +103,11 @@ nrf52_ns = cg.esphome_ns.namespace("nrf52") DeviceFirmwareUpdate = nrf52_ns.class_("DeviceFirmwareUpdate", cg.Component) CONF_DFU = "dfu" +CONF_DCDC = "dcdc" +CONF_REG0 = "reg0" +CONF_UICR_ERASE = "uicr_erase" + +VOLTAGE_LEVELS = [1.8, 2.1, 2.4, 2.7, 3.0, 3.3] CONFIG_SCHEMA = cv.All( _detect_bootloader, @@ -116,6 +122,16 @@ CONFIG_SCHEMA = cv.All( cv.Required(CONF_RESET_PIN): pins.gpio_output_pin_schema, } ), + cv.Optional(CONF_DCDC, default=True): cv.boolean, + cv.Optional(CONF_REG0): cv.Schema( + { + cv.Required(CONF_VOLTAGE): cv.All( + cv.voltage, + cv.one_of(*VOLTAGE_LEVELS, float=True), + ), + cv.Optional(CONF_UICR_ERASE, default=False): cv.boolean, + } + ), } ), ) @@ -182,6 +198,13 @@ async def to_code(config: ConfigType) -> None: if dfu_config := config.get(CONF_DFU): CORE.add_job(_dfu_to_code, dfu_config) + zephyr_add_prj_conf("BOARD_ENABLE_DCDC", config[CONF_DCDC]) + + if reg0_config := config.get(CONF_REG0): + value = VOLTAGE_LEVELS.index(reg0_config[CONF_VOLTAGE]) + cg.add_define("USE_NRF52_REG0_VOUT", value) + if reg0_config[CONF_UICR_ERASE]: + cg.add_define("USE_NRF52_UICR_ERASE") @coroutine_with_priority(CoroPriority.DIAGNOSTICS) diff --git a/esphome/components/nrf52/uicr.cpp b/esphome/components/nrf52/uicr.cpp new file mode 100644 index 0000000000..4c0beeb503 --- /dev/null +++ b/esphome/components/nrf52/uicr.cpp @@ -0,0 +1,121 @@ +#include "esphome/core/defines.h" + +#ifdef USE_NRF52_REG0_VOUT +#include +#include +#include + +extern "C" { +void nvmc_config(uint32_t mode); +void nvmc_wait(); +nrfx_err_t nrfx_nvmc_uicr_erase(); +} + +namespace esphome::nrf52 { + +enum class StatusFlags : uint8_t { + OK = 0x00, + NEED_RESET = 0x01, + NEED_ERASE = 0x02, +}; + +constexpr StatusFlags &operator|=(StatusFlags &a, StatusFlags b) { + a = static_cast(static_cast(a) | static_cast(b)); + return a; +} + +constexpr bool operator&(StatusFlags a, StatusFlags b) { + return (static_cast(a) & static_cast(b)) != 0; +} + +static bool regout0_ok() { + return (NRF_UICR->REGOUT0 & UICR_REGOUT0_VOUT_Msk) == (USE_NRF52_REG0_VOUT << UICR_REGOUT0_VOUT_Pos); +} + +static StatusFlags set_regout0() { + /* If the board is powered from USB (high voltage mode), + * GPIO output voltage is set to 1.8 volts by default. + */ + if (!regout0_ok()) { + nvmc_config(NVMC_CONFIG_WEN_Wen); + NRF_UICR->REGOUT0 = + (NRF_UICR->REGOUT0 & ~((uint32_t) UICR_REGOUT0_VOUT_Msk)) | (USE_NRF52_REG0_VOUT << UICR_REGOUT0_VOUT_Pos); + nvmc_wait(); + nvmc_config(NVMC_CONFIG_WEN_Ren); + return regout0_ok() ? StatusFlags::NEED_RESET : StatusFlags::NEED_ERASE; + } + return StatusFlags::OK; +} + +#ifndef USE_BOOTLOADER_MCUBOOT +// https://github.com/adafruit/Adafruit_nRF52_Bootloader/blob/6a9a6a3e6d0f86918e9286188426a279976645bd/lib/sdk11/components/libraries/bootloader_dfu/dfu_types.h#L61 +constexpr uint32_t BOOTLOADER_REGION_START = 0x000F4000; +constexpr uint32_t BOOTLOADER_MBR_PARAMS_PAGE_ADDRESS = 0x000FE000; + +static bool bootloader_ok() { + return NRF_UICR->NRFFW[0] == BOOTLOADER_REGION_START && NRF_UICR->NRFFW[1] == BOOTLOADER_MBR_PARAMS_PAGE_ADDRESS; +} + +static StatusFlags fix_bootloader() { + if (!bootloader_ok()) { + nvmc_config(NVMC_CONFIG_WEN_Wen); + NRF_UICR->NRFFW[0] = BOOTLOADER_REGION_START; + NRF_UICR->NRFFW[1] = BOOTLOADER_MBR_PARAMS_PAGE_ADDRESS; + nvmc_wait(); + nvmc_config(NVMC_CONFIG_WEN_Ren); + return bootloader_ok() ? StatusFlags::NEED_RESET : StatusFlags::NEED_ERASE; + } + return StatusFlags::OK; +} +#endif + +#define BOOTLOADER_VERSION_REGISTER NRF_TIMER2->CC[0] + +static StatusFlags set_uicr() { + StatusFlags status = StatusFlags::OK; +#ifndef USE_BOOTLOADER_MCUBOOT + if (BOOTLOADER_VERSION_REGISTER <= 0x902) { +#ifdef CONFIG_PRINTK + printk("cannot control regout0 for %#x\n", BOOTLOADER_VERSION_REGISTER); +#endif + } else +#endif + { + status |= set_regout0(); + } +#ifndef USE_BOOTLOADER_MCUBOOT + status |= fix_bootloader(); +#endif + return status; +} + +static int board_esphome_init() { + StatusFlags status = set_uicr(); + +#ifdef USE_NRF52_UICR_ERASE + if (status & StatusFlags::NEED_ERASE) { + nrfx_err_t ret = nrfx_nvmc_uicr_erase(); + if (ret != NRFX_SUCCESS) { +#ifdef CONFIG_PRINTK + printk("nrfx_nvmc_uicr_erase failed %d\n", ret); +#endif + } else { + status |= set_uicr(); + } + } +#endif + + if (status & StatusFlags::NEED_RESET) { + /* a reset is required for changes to take effect */ + NVIC_SystemReset(); + } + + return 0; +} +} // namespace esphome::nrf52 + +static int board_esphome_init() { return esphome::nrf52::board_esphome_init(); } + +SYS_INIT(board_esphome_init, PRE_KERNEL_1, CONFIG_KERNEL_INIT_PRIORITY_DEFAULT); + +#endif diff --git a/esphome/components/number/automation.cpp b/esphome/components/number/automation.cpp index bfc59d0465..78ffc255fe 100644 --- a/esphome/components/number/automation.cpp +++ b/esphome/components/number/automation.cpp @@ -1,8 +1,7 @@ #include "automation.h" #include "esphome/core/log.h" -namespace esphome { -namespace number { +namespace esphome::number { static const char *const TAG = "number.automation"; @@ -52,5 +51,4 @@ void ValueRangeTrigger::on_state_(float state) { this->rtc_.save(&in_range); } -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/automation.h b/esphome/components/number/automation.h index 79eba883c4..a7cd04f083 100644 --- a/esphome/components/number/automation.h +++ b/esphome/components/number/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" -namespace esphome { -namespace number { +namespace esphome::number { class NumberStateTrigger : public Trigger { public: @@ -91,5 +90,4 @@ template class NumberInRangeCondition : public Condition float max_{NAN}; }; -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number.cpp b/esphome/components/number/number.cpp index f12e0e9e1e..992100ead0 100644 --- a/esphome/components/number/number.cpp +++ b/esphome/components/number/number.cpp @@ -3,8 +3,7 @@ #include "esphome/core/controller_registry.h" #include "esphome/core/log.h" -namespace esphome { -namespace number { +namespace esphome::number { static const char *const TAG = "number"; @@ -43,5 +42,4 @@ void Number::add_on_state_callback(std::function &&callback) { this->state_callback_.add(std::move(callback)); } -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number.h b/esphome/components/number/number.h index da91d70d53..472e06ad61 100644 --- a/esphome/components/number/number.h +++ b/esphome/components/number/number.h @@ -6,8 +6,7 @@ #include "number_call.h" #include "number_traits.h" -namespace esphome { -namespace number { +namespace esphome::number { class Number; void log_number(const char *tag, const char *prefix, const char *type, Number *obj); @@ -53,5 +52,4 @@ class Number : public EntityBase { CallbackManager state_callback_; }; -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number_call.cpp b/esphome/components/number/number_call.cpp index 669dd65184..27a857c112 100644 --- a/esphome/components/number/number_call.cpp +++ b/esphome/components/number/number_call.cpp @@ -2,8 +2,7 @@ #include "number.h" #include "esphome/core/log.h" -namespace esphome { -namespace number { +namespace esphome::number { static const char *const TAG = "number"; @@ -125,5 +124,4 @@ void NumberCall::perform() { this->parent_->control(target_value); } -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number_call.h b/esphome/components/number/number_call.h index 807207f0ec..584c13f413 100644 --- a/esphome/components/number/number_call.h +++ b/esphome/components/number/number_call.h @@ -4,12 +4,11 @@ #include "esphome/core/log.h" #include "number_traits.h" -namespace esphome { -namespace number { +namespace esphome::number { class Number; -enum NumberOperation { +enum NumberOperation : uint8_t { NUMBER_OP_NONE, NUMBER_OP_SET, NUMBER_OP_INCREMENT, @@ -39,10 +38,9 @@ class NumberCall { float limit); Number *const parent_; - NumberOperation operation_{NUMBER_OP_NONE}; optional value_; - bool cycle_; + NumberOperation operation_{NUMBER_OP_NONE}; + bool cycle_{false}; }; -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number_traits.cpp b/esphome/components/number/number_traits.cpp index 89035661f5..1e4239ceca 100644 --- a/esphome/components/number/number_traits.cpp +++ b/esphome/components/number/number_traits.cpp @@ -1,10 +1,8 @@ #include "esphome/core/log.h" #include "number_traits.h" -namespace esphome { -namespace number { +namespace esphome::number { static const char *const TAG = "number"; -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/number/number_traits.h b/esphome/components/number/number_traits.h index fa68c2390a..5ccbb9ba48 100644 --- a/esphome/components/number/number_traits.h +++ b/esphome/components/number/number_traits.h @@ -3,8 +3,7 @@ #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" -namespace esphome { -namespace number { +namespace esphome::number { enum NumberMode : uint8_t { NUMBER_MODE_AUTO = 0, @@ -35,5 +34,4 @@ class NumberTraits : public EntityBase_DeviceClass, public EntityBase_UnitOfMeas NumberMode mode_{NUMBER_MODE_AUTO}; }; -} // namespace number -} // namespace esphome +} // namespace esphome::number diff --git a/esphome/components/online_image/png_image.cpp b/esphome/components/online_image/png_image.cpp index 2038d09ed0..ce9d3bdc91 100644 --- a/esphome/components/online_image/png_image.cpp +++ b/esphome/components/online_image/png_image.cpp @@ -2,6 +2,7 @@ #ifdef USE_ONLINE_IMAGE_PNG_SUPPORT #include "esphome/components/display/display_buffer.h" +#include "esphome/core/application.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -38,6 +39,14 @@ static void draw_callback(pngle_t *pngle, uint32_t x, uint32_t y, uint32_t w, ui PngDecoder *decoder = (PngDecoder *) pngle_get_user_data(pngle); Color color(rgba[0], rgba[1], rgba[2], rgba[3]); decoder->draw(x, y, w, h, color); + + // Feed watchdog periodically to avoid triggering during long decode operations. + // Feed every 1024 pixels to balance efficiency and responsiveness. + uint32_t pixels = w * h; + decoder->increment_pixels_decoded(pixels); + if ((decoder->get_pixels_decoded() % 1024) < pixels) { + App.feed_wdt(); + } } PngDecoder::PngDecoder(OnlineImage *image) : ImageDecoder(image) { diff --git a/esphome/components/online_image/png_image.h b/esphome/components/online_image/png_image.h index 46519f8ef4..40e85dde33 100644 --- a/esphome/components/online_image/png_image.h +++ b/esphome/components/online_image/png_image.h @@ -25,9 +25,13 @@ class PngDecoder : public ImageDecoder { int prepare(size_t download_size) override; int HOT decode(uint8_t *buffer, size_t size) override; + void increment_pixels_decoded(uint32_t count) { this->pixels_decoded_ += count; } + uint32_t get_pixels_decoded() const { return this->pixels_decoded_; } + protected: RAMAllocator allocator_; pngle_t *pngle_; + uint32_t pixels_decoded_{0}; }; } // namespace online_image diff --git a/esphome/components/openthread/__init__.py b/esphome/components/openthread/__init__.py index e3ad3ed76c..5b1abe4fb5 100644 --- a/esphome/components/openthread/__init__.py +++ b/esphome/components/openthread/__init__.py @@ -1,5 +1,6 @@ import esphome.codegen as cg from esphome.components.esp32 import ( + VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, add_idf_sdkconfig_option, @@ -152,7 +153,7 @@ CONFIG_SCHEMA = cv.All( ).extend(_CONNECTION_SCHEMA), cv.has_exactly_one_key(CONF_NETWORK_KEY, CONF_TLV), cv.only_with_esp_idf, - only_on_variant(supported=[VARIANT_ESP32C6, VARIANT_ESP32H2]), + only_on_variant(supported=[VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2]), _validate, _require_vfs_select, ) diff --git a/esphome/components/ota/__init__.py b/esphome/components/ota/__init__.py index eec39668db..be1b6da241 100644 --- a/esphome/components/ota/__init__.py +++ b/esphome/components/ota/__init__.py @@ -87,9 +87,6 @@ BASE_OTA_SCHEMA = cv.Schema( async def to_code(config): cg.add_define("USE_OTA") - if CORE.is_esp32 and CORE.using_arduino: - cg.add_library("Update", None) - if CORE.is_rp2040 and CORE.using_arduino: cg.add_library("Updater", None) @@ -127,8 +124,10 @@ async def ota_to_code(var, config): FILTER_SOURCE_FILES = filter_source_files_from_platform( { - "ota_backend_arduino_esp32.cpp": {PlatformFramework.ESP32_ARDUINO}, - "ota_backend_esp_idf.cpp": {PlatformFramework.ESP32_IDF}, + "ota_backend_esp_idf.cpp": { + PlatformFramework.ESP32_ARDUINO, + PlatformFramework.ESP32_IDF, + }, "ota_backend_arduino_esp8266.cpp": {PlatformFramework.ESP8266_ARDUINO}, "ota_backend_arduino_rp2040.cpp": {PlatformFramework.RP2040_ARDUINO}, "ota_backend_arduino_libretiny.cpp": { diff --git a/esphome/components/ota/ota_backend_arduino_esp32.cpp b/esphome/components/ota/ota_backend_arduino_esp32.cpp deleted file mode 100644 index 5c6230f2ce..0000000000 --- a/esphome/components/ota/ota_backend_arduino_esp32.cpp +++ /dev/null @@ -1,72 +0,0 @@ -#ifdef USE_ESP32_FRAMEWORK_ARDUINO -#include "esphome/core/defines.h" -#include "esphome/core/log.h" - -#include "ota_backend.h" -#include "ota_backend_arduino_esp32.h" - -#include - -namespace esphome { -namespace ota { - -static const char *const TAG = "ota.arduino_esp32"; - -std::unique_ptr make_ota_backend() { return make_unique(); } - -OTAResponseTypes ArduinoESP32OTABackend::begin(size_t image_size) { - // Handle UPDATE_SIZE_UNKNOWN (0) which is used by web server OTA - // where the exact firmware size is unknown due to multipart encoding - if (image_size == 0) { - image_size = UPDATE_SIZE_UNKNOWN; - } - bool ret = Update.begin(image_size, U_FLASH); - if (ret) { - return OTA_RESPONSE_OK; - } - - uint8_t error = Update.getError(); - if (error == UPDATE_ERROR_SIZE) - return OTA_RESPONSE_ERROR_ESP32_NOT_ENOUGH_SPACE; - - ESP_LOGE(TAG, "Begin error: %d", error); - - return OTA_RESPONSE_ERROR_UNKNOWN; -} - -void ArduinoESP32OTABackend::set_update_md5(const char *md5) { - Update.setMD5(md5); - this->md5_set_ = true; -} - -OTAResponseTypes ArduinoESP32OTABackend::write(uint8_t *data, size_t len) { - size_t written = Update.write(data, len); - if (written == len) { - return OTA_RESPONSE_OK; - } - - uint8_t error = Update.getError(); - ESP_LOGE(TAG, "Write error: %d", error); - - return OTA_RESPONSE_ERROR_WRITING_FLASH; -} - -OTAResponseTypes ArduinoESP32OTABackend::end() { - // Use strict validation (false) when MD5 is set, lenient validation (true) when no MD5 - // This matches the behavior of the old web_server OTA implementation - if (Update.end(!this->md5_set_)) { - return OTA_RESPONSE_OK; - } - - uint8_t error = Update.getError(); - ESP_LOGE(TAG, "End error: %d", error); - - return OTA_RESPONSE_ERROR_UPDATE_END; -} - -void ArduinoESP32OTABackend::abort() { Update.abort(); } - -} // namespace ota -} // namespace esphome - -#endif // USE_ESP32_FRAMEWORK_ARDUINO diff --git a/esphome/components/ota/ota_backend_arduino_esp32.h b/esphome/components/ota/ota_backend_arduino_esp32.h deleted file mode 100644 index 6615cf3dc0..0000000000 --- a/esphome/components/ota/ota_backend_arduino_esp32.h +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once -#ifdef USE_ESP32_FRAMEWORK_ARDUINO -#include "ota_backend.h" - -#include "esphome/core/defines.h" -#include "esphome/core/helpers.h" - -namespace esphome { -namespace ota { - -class ArduinoESP32OTABackend : public OTABackend { - public: - OTAResponseTypes begin(size_t image_size) override; - void set_update_md5(const char *md5) override; - OTAResponseTypes write(uint8_t *data, size_t len) override; - OTAResponseTypes end() override; - void abort() override; - bool supports_compression() override { return false; } - - private: - bool md5_set_{false}; -}; - -} // namespace ota -} // namespace esphome - -#endif // USE_ESP32_FRAMEWORK_ARDUINO diff --git a/esphome/components/ota/ota_backend_esp_idf.cpp b/esphome/components/ota/ota_backend_esp_idf.cpp index 97aae09bd9..f278c3741f 100644 --- a/esphome/components/ota/ota_backend_esp_idf.cpp +++ b/esphome/components/ota/ota_backend_esp_idf.cpp @@ -1,4 +1,4 @@ -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "ota_backend_esp_idf.h" #include "esphome/components/md5/md5.h" @@ -107,4 +107,4 @@ void IDFOTABackend::abort() { } // namespace ota } // namespace esphome -#endif +#endif // USE_ESP32 diff --git a/esphome/components/ota/ota_backend_esp_idf.h b/esphome/components/ota/ota_backend_esp_idf.h index 6e93982131..764010e614 100644 --- a/esphome/components/ota/ota_backend_esp_idf.h +++ b/esphome/components/ota/ota_backend_esp_idf.h @@ -1,5 +1,5 @@ #pragma once -#ifdef USE_ESP_IDF +#ifdef USE_ESP32 #include "ota_backend.h" #include "esphome/components/md5/md5.h" @@ -29,4 +29,4 @@ class IDFOTABackend : public OTABackend { } // namespace ota } // namespace esphome -#endif +#endif // USE_ESP32 diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 04057c07f2..67fd2770e9 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,7 +1,9 @@ +import logging from pathlib import Path from esphome import git, yaml_util -from esphome.config_helpers import merge_config +from esphome.components.substitutions.jinja import has_jinja +from esphome.config_helpers import Remove, merge_config import esphome.config_validation as cv from esphome.const import ( CONF_ESPHOME, @@ -20,18 +22,46 @@ from esphome.const import ( ) from esphome.core import EsphomeError +_LOGGER = logging.getLogger(__name__) + DOMAIN = CONF_PACKAGES -def validate_git_package(config: dict): - if CONF_URL not in config: - return config - config = BASE_SCHEMA(config) - new_config = config +def valid_package_contents(package_config: dict): + """Validates that a package_config that will be merged looks as much as possible to a valid config + to fail early on obvious mistakes.""" + if isinstance(package_config, dict): + if CONF_URL in package_config: + # If a URL key is found, then make sure the config conforms to a remote package schema: + return REMOTE_PACKAGE_SCHEMA(package_config) + + # Validate manually since Voluptuous would regenerate dicts and lose metadata + # such as ESPHomeDataBase + for k, v in package_config.items(): + if not isinstance(k, str): + raise cv.Invalid("Package content keys must be strings") + if isinstance(v, (dict, list, Remove)): + continue # e.g. script: [], psram: !remove, logger: {level: debug} + if v is None: + continue # e.g. web_server: + if isinstance(v, str) and has_jinja(v): + # e.g: remote package shorthand: + # package_name: github://esphome/repo/file.yaml@${ branch } + continue + + raise cv.Invalid("Invalid component content in package definition") + return package_config + + raise cv.Invalid("Package contents must be a dict") + + +def expand_file_to_files(config: dict): if CONF_FILE in config: + new_config = config new_config[CONF_FILES] = [config[CONF_FILE]] del new_config[CONF_FILE] - return new_config + return new_config + return config def validate_yaml_filename(value): @@ -45,7 +75,7 @@ def validate_yaml_filename(value): def validate_source_shorthand(value): if not isinstance(value, str): - raise cv.Invalid("Shorthand only for strings") + raise cv.Invalid("Git URL shorthand only for strings") git_file = git.GitFile.from_shorthand(value) @@ -56,10 +86,17 @@ def validate_source_shorthand(value): if git_file.ref: conf[CONF_REF] = git_file.ref - return BASE_SCHEMA(conf) + return REMOTE_PACKAGE_SCHEMA(conf) -BASE_SCHEMA = cv.All( +def deprecate_single_package(config): + _LOGGER.warning( + "Including a single package under `packages:` is deprecated. Use a list instead." + ) + return config + + +REMOTE_PACKAGE_SCHEMA = cv.All( cv.Schema( { cv.Required(CONF_URL): cv.url, @@ -90,23 +127,30 @@ BASE_SCHEMA = cv.All( } ), cv.has_at_least_one_key(CONF_FILE, CONF_FILES), + expand_file_to_files, ) -PACKAGE_SCHEMA = cv.All( - cv.Any(validate_source_shorthand, BASE_SCHEMA, dict), validate_git_package +PACKAGE_SCHEMA = cv.Any( # A package definition is either: + validate_source_shorthand, # A git URL shorthand string that expands to a remote package schema, or + REMOTE_PACKAGE_SCHEMA, # a valid remote package schema, or + valid_package_contents, # Something that at least looks like an actual package, e.g. {wifi:{ssid: xxx}} + # which will have to be fully validated later as per each component's schema. ) -CONFIG_SCHEMA = cv.Any( +CONFIG_SCHEMA = cv.Any( # under `packages:` we can have either: cv.Schema( { - str: PACKAGE_SCHEMA, + str: PACKAGE_SCHEMA, # a named dict of package definitions, or } ), - [PACKAGE_SCHEMA], + [PACKAGE_SCHEMA], # a list of package definitions, or + cv.All( # a single package definition (deprecated) + cv.ensure_list(PACKAGE_SCHEMA), deprecate_single_package + ), ) -def _process_base_package(config: dict, skip_update: bool = False) -> dict: +def _process_remote_package(config: dict, skip_update: bool = False) -> dict: # When skip_update is True, use NEVER_REFRESH to prevent updates actual_refresh = git.NEVER_REFRESH if skip_update else config[CONF_REFRESH] repo_dir, revert = git.clone_or_update( @@ -185,7 +229,7 @@ def _process_base_package(config: dict, skip_update: bool = False) -> dict: def _process_package(package_config, config, skip_update: bool = False): recursive_package = package_config if CONF_URL in package_config: - package_config = _process_base_package(package_config, skip_update) + package_config = _process_remote_package(package_config, skip_update) if isinstance(package_config, dict): recursive_package = do_packages_pass(package_config, skip_update) return merge_config(recursive_package, config) diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index 8bde4ee505..37e5f3d9e1 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -195,8 +195,8 @@ static void add(std::vector &vec, const char *str) { void PacketTransport::setup() { this->name_ = App.get_name().c_str(); if (strlen(this->name_) > 255) { + this->status_set_error(LOG_STR("Device name exceeds 255 chars")); this->mark_failed(); - this->status_set_error("Device name exceeds 255 chars"); return; } this->resend_ping_key_ = this->ping_pong_enable_; diff --git a/esphome/components/pcf85063/pcf85063.cpp b/esphome/components/pcf85063/pcf85063.cpp index cb987c6129..f38b60b55d 100644 --- a/esphome/components/pcf85063/pcf85063.cpp +++ b/esphome/components/pcf85063/pcf85063.cpp @@ -23,7 +23,7 @@ void PCF85063Component::dump_config() { if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } - ESP_LOGCONFIG(TAG, " Timezone: '%s'", this->timezone_.c_str()); + RealTimeClock::dump_config(); } float PCF85063Component::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/pcf8563/pcf8563.cpp b/esphome/components/pcf8563/pcf8563.cpp index 27020378a6..2090936bb6 100644 --- a/esphome/components/pcf8563/pcf8563.cpp +++ b/esphome/components/pcf8563/pcf8563.cpp @@ -23,7 +23,7 @@ void PCF8563Component::dump_config() { if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } - ESP_LOGCONFIG(TAG, " Timezone: '%s'", this->timezone_.c_str()); + RealTimeClock::dump_config(); } float PCF8563Component::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/prometheus/prometheus_handler.cpp b/esphome/components/prometheus/prometheus_handler.cpp index 5cfcacf0cb..4b5d834ebf 100644 --- a/esphome/components/prometheus/prometheus_handler.cpp +++ b/esphome/components/prometheus/prometheus_handler.cpp @@ -53,6 +53,18 @@ void PrometheusHandler::handleRequest(AsyncWebServerRequest *req) { this->lock_row_(stream, obj, area, node, friendly_name); #endif +#ifdef USE_EVENT + this->event_type_(stream); + for (auto *obj : App.get_events()) + this->event_row_(stream, obj, area, node, friendly_name); +#endif + +#ifdef USE_TEXT + this->text_type_(stream); + for (auto *obj : App.get_texts()) + this->text_row_(stream, obj, area, node, friendly_name); +#endif + #ifdef USE_TEXT_SENSOR this->text_sensor_type_(stream); for (auto *obj : App.get_text_sensors()) @@ -129,6 +141,24 @@ void PrometheusHandler::add_friendly_name_label_(AsyncResponseStream *stream, st } } +#ifdef USE_ESP8266 +void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name, + EntityBase *obj, std::string &area, std::string &node, + std::string &friendly_name) { +#else +void PrometheusHandler::print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj, + std::string &area, std::string &node, std::string &friendly_name) { +#endif + stream->print(metric_name); + stream->print(ESPHOME_F("{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); +} + // Type-specific implementation #ifdef USE_SENSOR void PrometheusHandler::sensor_type_(AsyncResponseStream *stream) { @@ -291,13 +321,7 @@ void PrometheusHandler::light_row_(AsyncResponseStream *stream, light::LightStat if (obj->is_internal() && !this->include_internal_) return; // State - stream->print(ESPHOME_F("esphome_light_state{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + print_metric_labels_(stream, ESPHOME_F("esphome_light_state"), obj, area, node, friendly_name); stream->print(ESPHOME_F("\"} ")); stream->print(obj->remote_values.is_on()); stream->print(ESPHOME_F("\n")); @@ -306,78 +330,45 @@ void PrometheusHandler::light_row_(AsyncResponseStream *stream, light::LightStat float brightness, r, g, b, w; color.as_brightness(&brightness); color.as_rgbw(&r, &g, &b, &w); - stream->print(ESPHOME_F("esphome_light_color{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",channel=\"brightness\"} ")); - stream->print(brightness); - stream->print(ESPHOME_F("\n")); - stream->print(ESPHOME_F("esphome_light_color{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",channel=\"r\"} ")); - stream->print(r); - stream->print(ESPHOME_F("\n")); - stream->print(ESPHOME_F("esphome_light_color{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",channel=\"g\"} ")); - stream->print(g); - stream->print(ESPHOME_F("\n")); - stream->print(ESPHOME_F("esphome_light_color{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",channel=\"b\"} ")); - stream->print(b); - stream->print(ESPHOME_F("\n")); - stream->print(ESPHOME_F("esphome_light_color{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",channel=\"w\"} ")); - stream->print(w); - stream->print(ESPHOME_F("\n")); - // Effect - std::string effect = obj->get_effect_name(); - if (effect == "None") { - stream->print(ESPHOME_F("esphome_light_effect_active{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); - stream->print(ESPHOME_F("\",effect=\"None\"} 0\n")); - } else { - stream->print(ESPHOME_F("esphome_light_effect_active{id=\"")); - stream->print(relabel_id_(obj).c_str()); - add_area_label_(stream, area); - add_node_label_(stream, node); - add_friendly_name_label_(stream, friendly_name); - stream->print(ESPHOME_F("\",name=\"")); - stream->print(relabel_name_(obj).c_str()); + if (obj->get_traits().supports_color_capability(light::ColorCapability::BRIGHTNESS)) { + print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name); + stream->print(ESPHOME_F("\",channel=\"brightness\"} ")); + stream->print(brightness); + stream->print(ESPHOME_F("\n")); + } + if (obj->get_traits().supports_color_capability(light::ColorCapability::RGB)) { + print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name); + stream->print(ESPHOME_F("\",channel=\"r\"} ")); + stream->print(r); + stream->print(ESPHOME_F("\n")); + print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name); + stream->print(ESPHOME_F("\",channel=\"g\"} ")); + stream->print(g); + stream->print(ESPHOME_F("\n")); + print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name); + stream->print(ESPHOME_F("\",channel=\"b\"} ")); + stream->print(b); + stream->print(ESPHOME_F("\n")); + } + if (obj->get_traits().supports_color_capability(light::ColorCapability::WHITE)) { + print_metric_labels_(stream, ESPHOME_F("esphome_light_color"), obj, area, node, friendly_name); + stream->print(ESPHOME_F("\",channel=\"w\"} ")); + stream->print(w); + stream->print(ESPHOME_F("\n")); + } + // Skip effect metrics if light has no effects + if (!obj->get_effects().empty()) { + // Effect + std::string effect = obj->get_effect_name(); + print_metric_labels_(stream, ESPHOME_F("esphome_light_effect_active"), obj, area, node, friendly_name); stream->print(ESPHOME_F("\",effect=\"")); - stream->print(effect.c_str()); - stream->print(ESPHOME_F("\"} 1\n")); + // Only vary based on effect + if (effect == "None") { + stream->print(ESPHOME_F("None\"} 0\n")); + } else { + stream->print(effect.c_str()); + stream->print(ESPHOME_F("\"} 1\n")); + } } } #endif @@ -547,6 +538,100 @@ void PrometheusHandler::text_sensor_row_(AsyncResponseStream *stream, text_senso } #endif +// Type-specific implementation +#ifdef USE_TEXT +void PrometheusHandler::text_type_(AsyncResponseStream *stream) { + stream->print(ESPHOME_F("#TYPE esphome_text_value gauge\n")); + stream->print(ESPHOME_F("#TYPE esphome_text_failed gauge\n")); +} +void PrometheusHandler::text_row_(AsyncResponseStream *stream, text::Text *obj, std::string &area, std::string &node, + std::string &friendly_name) { + if (obj->is_internal() && !this->include_internal_) + return; + if (obj->has_state()) { + // We have a valid value, output this value + stream->print(ESPHOME_F("esphome_text_failed{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\"} 0\n")); + // Data itself + stream->print(ESPHOME_F("esphome_text_value{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\",value=\"")); + stream->print(obj->state.c_str()); + stream->print(ESPHOME_F("\"} ")); + stream->print(ESPHOME_F("1.0")); + stream->print(ESPHOME_F("\n")); + } else { + // Invalid state + stream->print(ESPHOME_F("esphome_text_failed{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\"} 1\n")); + } +} +#endif + +// Type-specific implementation +#ifdef USE_EVENT +void PrometheusHandler::event_type_(AsyncResponseStream *stream) { + stream->print(ESPHOME_F("#TYPE esphome_event_value gauge\n")); + stream->print(ESPHOME_F("#TYPE esphome_event_failed gauge\n")); +} +void PrometheusHandler::event_row_(AsyncResponseStream *stream, event::Event *obj, std::string &area, std::string &node, + std::string &friendly_name) { + if (obj->is_internal() && !this->include_internal_) + return; + if (obj->get_last_event_type() != nullptr) { + // We have a valid event type, output this value + stream->print(ESPHOME_F("esphome_event_failed{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\"} 0\n")); + // Data itself + stream->print(ESPHOME_F("esphome_event_value{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\",last_event_type=\"")); + stream->print(obj->get_last_event_type()); + stream->print(ESPHOME_F("\"} ")); + stream->print(ESPHOME_F("1.0")); + stream->print(ESPHOME_F("\n")); + } else { + // No event triggered yet + stream->print(ESPHOME_F("esphome_event_failed{id=\"")); + stream->print(relabel_id_(obj).c_str()); + add_area_label_(stream, area); + add_node_label_(stream, node); + add_friendly_name_label_(stream, friendly_name); + stream->print(ESPHOME_F("\",name=\"")); + stream->print(relabel_name_(obj).c_str()); + stream->print(ESPHOME_F("\"} 1\n")); + } +} +#endif + // Type-specific implementation #ifdef USE_NUMBER void PrometheusHandler::number_type_(AsyncResponseStream *stream) { @@ -620,7 +705,7 @@ void PrometheusHandler::select_row_(AsyncResponseStream *stream, select::Select stream->print(ESPHOME_F("\",name=\"")); stream->print(relabel_name_(obj).c_str()); stream->print(ESPHOME_F("\",value=\"")); - stream->print(obj->state.c_str()); + stream->print(obj->current_option()); stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); @@ -810,7 +895,11 @@ void PrometheusHandler::valve_row_(AsyncResponseStream *stream, valve::Valve *ob stream->print(ESPHOME_F("\",name=\"")); stream->print(relabel_name_(obj).c_str()); stream->print(ESPHOME_F("\",operation=\"")); - stream->print(valve::valve_operation_to_str(obj->current_operation)); +#ifdef USE_STORE_LOG_STR_IN_FLASH + stream->print((const __FlashStringHelper *) valve::valve_operation_to_str(obj->current_operation)); +#else + stream->print((const char *) valve::valve_operation_to_str(obj->current_operation)); +#endif stream->print(ESPHOME_F("\"} ")); stream->print(ESPHOME_F("1.0")); stream->print(ESPHOME_F("\n")); diff --git a/esphome/components/prometheus/prometheus_handler.h b/esphome/components/prometheus/prometheus_handler.h index c4598f44b0..24243c8c98 100644 --- a/esphome/components/prometheus/prometheus_handler.h +++ b/esphome/components/prometheus/prometheus_handler.h @@ -66,6 +66,14 @@ class PrometheusHandler : public AsyncWebHandler, public Component { void add_area_label_(AsyncResponseStream *stream, std::string &area); void add_node_label_(AsyncResponseStream *stream, std::string &node); void add_friendly_name_label_(AsyncResponseStream *stream, std::string &friendly_name); + /// Print metric name and common labels (id, area, node, friendly_name, name) +#ifdef USE_ESP8266 + void print_metric_labels_(AsyncResponseStream *stream, const __FlashStringHelper *metric_name, EntityBase *obj, + std::string &area, std::string &node, std::string &friendly_name); +#else + void print_metric_labels_(AsyncResponseStream *stream, const char *metric_name, EntityBase *obj, std::string &area, + std::string &node, std::string &friendly_name); +#endif #ifdef USE_SENSOR /// Return the type for prometheus @@ -123,6 +131,22 @@ class PrometheusHandler : public AsyncWebHandler, public Component { std::string &friendly_name); #endif +#ifdef USE_EVENT + /// Return the type for prometheus + void event_type_(AsyncResponseStream *stream); + /// Return the event values state as prometheus data point + void event_row_(AsyncResponseStream *stream, event::Event *obj, std::string &area, std::string &node, + std::string &friendly_name); +#endif + +#ifdef USE_TEXT + /// Return the type for prometheus + void text_type_(AsyncResponseStream *stream); + /// Return the text values state as prometheus data point + void text_row_(AsyncResponseStream *stream, text::Text *obj, std::string &area, std::string &node, + std::string &friendly_name); +#endif + #ifdef USE_TEXT_SENSOR /// Return the type for prometheus void text_sensor_type_(AsyncResponseStream *stream); diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 11c238c1bf..4ee4e97696 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -11,6 +11,7 @@ from esphome.components.esp32 import ( get_esp32_variant, ) from esphome.components.esp32.const import ( + VARIANT_ESP32C5, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, @@ -35,6 +36,9 @@ DOMAIN = "psram" DEPENDENCIES = [PLATFORM_ESP32] +# PSRAM availability tracking for cross-component coordination +KEY_PSRAM_GUARANTEED = "psram_guaranteed" + _LOGGER = logging.getLogger(__name__) psram_ns = cg.esphome_ns.namespace(DOMAIN) @@ -52,6 +56,7 @@ SPIRAM_MODES = { VARIANT_ESP32: (TYPE_QUAD,), VARIANT_ESP32S2: (TYPE_QUAD,), VARIANT_ESP32S3: (TYPE_QUAD, TYPE_OCTAL), + VARIANT_ESP32C5: (TYPE_QUAD,), VARIANT_ESP32P4: (TYPE_HEX,), } @@ -60,6 +65,7 @@ SPIRAM_SPEEDS = { VARIANT_ESP32: (40, 80, 120), VARIANT_ESP32S2: (40, 80, 120), VARIANT_ESP32S3: (40, 80, 120), + VARIANT_ESP32C5: (40, 80, 120), VARIANT_ESP32P4: (20, 100, 200), } @@ -71,6 +77,23 @@ def supported() -> bool: return variant in SPIRAM_MODES +def is_guaranteed() -> bool: + """Check if PSRAM is guaranteed to be available. + + Returns True when PSRAM is configured with both 'disabled: false' and + 'ignore_not_found: false', meaning the device will fail to boot if PSRAM + is not found. This ensures safe use of high buffer configurations that + depend on PSRAM. + + This function should be called during code generation (to_code phase) by + components that need to know PSRAM availability for configuration decisions. + + Returns: + bool: True if PSRAM is guaranteed, False otherwise + """ + return CORE.data.get(KEY_PSRAM_GUARANTEED, False) + + def validate_psram_mode(config): esp32_config = fv.full_config.get()[PLATFORM_ESP32] if config[CONF_SPEED] == "120MHZ": @@ -131,7 +154,22 @@ def get_config_schema(config): CONFIG_SCHEMA = get_config_schema -FINAL_VALIDATE_SCHEMA = validate_psram_mode + +def _store_psram_guaranteed(config): + """Store PSRAM guaranteed status in CORE.data for other components. + + PSRAM is "guaranteed" when it will fail if not found, ensuring safe use + of high buffer configurations in network/wifi components. + + Called during final validation to ensure the flag is available + before any to_code() functions run. + """ + psram_guaranteed = not config[CONF_DISABLED] and not config[CONF_IGNORE_NOT_FOUND] + CORE.data[KEY_PSRAM_GUARANTEED] = psram_guaranteed + return config + + +FINAL_VALIDATE_SCHEMA = cv.All(validate_psram_mode, _store_psram_guaranteed) async def to_code(config): diff --git a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp index b6916ad68f..8436633619 100644 --- a/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp +++ b/esphome/components/pvvx_mithermometer/display/pvvx_display.cpp @@ -14,7 +14,7 @@ void PVVXDisplay::dump_config() { " Service UUID : %s\n" " Characteristic UUID : %s\n" " Auto clear : %s", - this->parent_->address_str().c_str(), this->service_uuid_.to_string().c_str(), + this->parent_->address_str(), this->service_uuid_.to_string().c_str(), this->char_uuid_.to_string().c_str(), YESNO(this->auto_clear_enabled_)); #ifdef USE_TIME ESP_LOGCONFIG(TAG, " Set time on connection: %s", YESNO(this->time_ != nullptr)); @@ -28,12 +28,12 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t switch (event) { case ESP_GATTC_OPEN_EVT: if (param->open.status == ESP_GATT_OK) { - ESP_LOGV(TAG, "[%s] Connected successfully!", this->parent_->address_str().c_str()); + ESP_LOGV(TAG, "[%s] Connected successfully!", this->parent_->address_str()); this->delayed_disconnect_(); } break; case ESP_GATTC_DISCONNECT_EVT: - ESP_LOGV(TAG, "[%s] Disconnected", this->parent_->address_str().c_str()); + ESP_LOGV(TAG, "[%s] Disconnected", this->parent_->address_str()); this->connection_established_ = false; this->cancel_timeout("disconnect"); this->char_handle_ = 0; @@ -41,7 +41,7 @@ void PVVXDisplay::gattc_event_handler(esp_gattc_cb_event_t event, esp_gatt_if_t case ESP_GATTC_SEARCH_CMPL_EVT: { auto *chr = this->parent_->get_characteristic(this->service_uuid_, this->char_uuid_); if (chr == nullptr) { - ESP_LOGW(TAG, "[%s] Characteristic not found.", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Characteristic not found.", this->parent_->address_str()); break; } this->connection_established_ = true; @@ -66,11 +66,11 @@ void PVVXDisplay::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb return; if (param->ble_security.auth_cmpl.success) { - ESP_LOGD(TAG, "[%s] Authentication successful, performing writes.", this->parent_->address_str().c_str()); + ESP_LOGD(TAG, "[%s] Authentication successful, performing writes.", this->parent_->address_str()); // Now that pairing is complete, perform the pending writes this->sync_time_and_display_(); } else { - ESP_LOGW(TAG, "[%s] Authentication failed.", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Authentication failed.", this->parent_->address_str()); } break; } @@ -89,22 +89,20 @@ void PVVXDisplay::update() { void PVVXDisplay::display() { if (!this->parent_->enabled) { - ESP_LOGD(TAG, "[%s] BLE client not enabled. Init connection.", this->parent_->address_str().c_str()); + ESP_LOGD(TAG, "[%s] BLE client not enabled. Init connection.", this->parent_->address_str()); this->parent_->set_enabled(true); return; } if (!this->connection_established_) { - ESP_LOGW(TAG, "[%s] Not connected to BLE client. State update can not be written.", - this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Not connected to BLE client. State update can not be written.", this->parent_->address_str()); return; } if (!this->char_handle_) { - ESP_LOGW(TAG, "[%s] No ble handle to BLE client. State update can not be written.", - this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] No ble handle to BLE client. State update can not be written.", this->parent_->address_str()); return; } ESP_LOGD(TAG, "[%s] Send to display: bignum %d, smallnum: %d, cfg: 0x%02x, validity period: %u.", - this->parent_->address_str().c_str(), this->bignum_, this->smallnum_, this->cfg_, this->validity_period_); + this->parent_->address_str(), this->bignum_, this->smallnum_, this->cfg_, this->validity_period_); uint8_t blk[8] = {}; blk[0] = 0x22; blk[1] = this->bignum_ & 0xff; @@ -128,16 +126,16 @@ void PVVXDisplay::setcfgbit_(uint8_t bit, bool value) { void PVVXDisplay::send_to_setup_char_(uint8_t *blk, size_t size) { if (!this->connection_established_) { - ESP_LOGW(TAG, "[%s] Not connected to BLE client.", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Not connected to BLE client.", this->parent_->address_str()); return; } auto status = esp_ble_gattc_write_char(this->parent_->get_gattc_if(), this->parent_->get_conn_id(), this->char_handle_, size, blk, ESP_GATT_WRITE_TYPE_NO_RSP, ESP_GATT_AUTH_REQ_NONE); if (status) { - ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str().c_str(), status); + ESP_LOGW(TAG, "[%s] esp_ble_gattc_write_char failed, status=%d", this->parent_->address_str(), status); } else { - ESP_LOGV(TAG, "[%s] send %u bytes", this->parent_->address_str().c_str(), size); + ESP_LOGV(TAG, "[%s] send %u bytes", this->parent_->address_str(), size); this->delayed_disconnect_(); } } @@ -161,21 +159,21 @@ void PVVXDisplay::sync_time_() { if (this->time_ == nullptr) return; if (!this->connection_established_) { - ESP_LOGW(TAG, "[%s] Not connected to BLE client. Time can not be synced.", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Not connected to BLE client. Time can not be synced.", this->parent_->address_str()); return; } if (!this->char_handle_) { - ESP_LOGW(TAG, "[%s] No ble handle to BLE client. Time can not be synced.", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] No ble handle to BLE client. Time can not be synced.", this->parent_->address_str()); return; } auto time = this->time_->now(); if (!time.is_valid()) { - ESP_LOGW(TAG, "[%s] Time is not yet valid. Time can not be synced.", this->parent_->address_str().c_str()); + ESP_LOGW(TAG, "[%s] Time is not yet valid. Time can not be synced.", this->parent_->address_str()); return; } time.recalc_timestamp_utc(true); // calculate timestamp of local time uint8_t blk[5] = {}; - ESP_LOGD(TAG, "[%s] Sync time with timestamp %" PRIu64 ".", this->parent_->address_str().c_str(), time.timestamp); + ESP_LOGD(TAG, "[%s] Sync time with timestamp %" PRIu64 ".", this->parent_->address_str(), time.timestamp); blk[0] = 0x23; blk[1] = time.timestamp & 0xff; blk[2] = (time.timestamp >> 8) & 0xff; diff --git a/esphome/components/qmp6988/qmp6988.cpp b/esphome/components/qmp6988/qmp6988.cpp index 61fde186d7..57f54b6432 100644 --- a/esphome/components/qmp6988/qmp6988.cpp +++ b/esphome/components/qmp6988/qmp6988.cpp @@ -310,7 +310,7 @@ void QMP6988Component::calculate_pressure_() { void QMP6988Component::setup() { if (!this->device_check_()) { - this->mark_failed(ESP_LOG_MSG_COMM_FAIL); + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); return; } diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index cd2b440645..e79b3f91ed 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -114,6 +114,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( bk72xx="1000b", ln882x="1000b", rtl87xx="1000b", + rp2040="1000b", ): cv.validate_bytes, cv.Optional(CONF_FILTER, default="50us"): cv.All( cv.positive_time_period_microseconds, @@ -213,6 +214,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, + PlatformFramework.RP2040_ARDUINO, }, } ) diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index a8438e20d7..53bfb0890f 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -3,7 +3,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) namespace esphome { namespace remote_receiver { diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 3ddcf353c7..3d2f7f0ef9 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -12,7 +12,7 @@ namespace esphome { namespace remote_receiver { -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) struct RemoteReceiverComponentStore { static void gpio_intr(RemoteReceiverComponentStore *arg); @@ -84,7 +84,7 @@ class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, std::string error_string_{""}; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_ESP32) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_ESP32) || defined(USE_RP2040) RemoteReceiverComponentStore store_; HighFrequencyLoopRequester high_freq_; #endif diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index faa6c827f7..ff055b959b 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -156,6 +156,7 @@ FILTER_SOURCE_FILES = filter_source_files_from_platform( PlatformFramework.BK72XX_ARDUINO, PlatformFramework.RTL87XX_ARDUINO, PlatformFramework.LN882X_ARDUINO, + PlatformFramework.RP2040_ARDUINO, }, } ) diff --git a/esphome/components/remote_transmitter/remote_transmitter.cpp b/esphome/components/remote_transmitter/remote_transmitter.cpp index 347e9d9d33..576143bcbc 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.cpp +++ b/esphome/components/remote_transmitter/remote_transmitter.cpp @@ -2,7 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -#if defined(USE_LIBRETINY) || defined(USE_ESP8266) +#if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) namespace esphome { namespace remote_transmitter { @@ -40,8 +40,8 @@ void RemoteTransmitterComponent::await_target_time_() { if (this->target_time_ == 0) { this->target_time_ = current_time; } else if ((int32_t) (this->target_time_ - current_time) > 0) { -#if defined(USE_LIBRETINY) - // busy loop for libretiny is required (see the comment inside micros() in wiring.c) +#if defined(USE_LIBRETINY) || defined(USE_RP2040) + // busy loop is required for libretiny and rp2040 as interrupts are disabled while ((int32_t) (this->target_time_ - micros()) > 0) ; #else diff --git a/esphome/components/remote_transmitter/remote_transmitter.h b/esphome/components/remote_transmitter/remote_transmitter.h index cc3b82ad61..dd6a849e4c 100644 --- a/esphome/components/remote_transmitter/remote_transmitter.h +++ b/esphome/components/remote_transmitter/remote_transmitter.h @@ -62,7 +62,7 @@ class RemoteTransmitterComponent : public remote_base::RemoteTransmitterBase, protected: void send_internal(uint32_t send_times, uint32_t send_wait) override; -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) void calculate_on_off_time_(uint32_t carrier_frequency, uint32_t *on_time_period, uint32_t *off_time_period); void mark_(uint32_t on_time, uint32_t off_time, uint32_t usec); diff --git a/esphome/components/resampler/speaker/resampler_speaker.cpp b/esphome/components/resampler/speaker/resampler_speaker.cpp index 5e5615cbb9..ad61aca084 100644 --- a/esphome/components/resampler/speaker/resampler_speaker.cpp +++ b/esphome/components/resampler/speaker/resampler_speaker.cpp @@ -66,17 +66,17 @@ void ResamplerSpeaker::loop() { } if (event_group_bits & ResamplingEventGroupBits::ERR_ESP_NO_MEM) { - this->status_set_error("Resampler task failed to allocate the internal buffers"); + this->status_set_error(LOG_STR("Resampler task failed to allocate the internal buffers")); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ERR_ESP_NO_MEM); this->state_ = speaker::STATE_STOPPING; } if (event_group_bits & ResamplingEventGroupBits::ERR_ESP_NOT_SUPPORTED) { - this->status_set_error("Cannot resample due to an unsupported audio stream"); + this->status_set_error(LOG_STR("Cannot resample due to an unsupported audio stream")); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ERR_ESP_NOT_SUPPORTED); this->state_ = speaker::STATE_STOPPING; } if (event_group_bits & ResamplingEventGroupBits::ERR_ESP_FAIL) { - this->status_set_error("Resampler task failed"); + this->status_set_error(LOG_STR("Resampler task failed")); xEventGroupClearBits(this->event_group_, ResamplingEventGroupBits::ERR_ESP_FAIL); this->state_ = speaker::STATE_STOPPING; } @@ -106,12 +106,12 @@ void ResamplerSpeaker::loop() { } else { switch (err) { case ESP_ERR_INVALID_STATE: - this->status_set_error("Failed to start resampler: resampler task failed to start"); + this->status_set_error(LOG_STR("Failed to start resampler: resampler task failed to start")); break; case ESP_ERR_NO_MEM: - this->status_set_error("Failed to start resampler: not enough memory for task stack"); + this->status_set_error(LOG_STR("Failed to start resampler: not enough memory for task stack")); default: - this->status_set_error("Failed to start resampler"); + this->status_set_error(LOG_STR("Failed to start resampler")); break; } diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index 109c986f75..d24ffcea3d 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -6,6 +6,7 @@ # in schema.py file in this directory. from esphome import pins +import esphome.codegen as cg from esphome.components import libretiny from esphome.components.libretiny.const import ( COMPONENT_RTL87XX, @@ -45,6 +46,9 @@ CONFIG_SCHEMA.prepend_extra(_set_core_data) async def to_code(config): + # Use FreeRTOS 8.2.3+ for xTaskNotifyGive/ulTaskNotifyTake required by AsyncTCP 3.4.3+ + # https://github.com/esphome/esphome/issues/10220 + cg.add_platformio_option("custom_versions.freertos", "8.2.3") return await libretiny.component_to_code(config) diff --git a/esphome/components/rx8130/__init__.py b/esphome/components/rx8130/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/rx8130/rx8130.cpp b/esphome/components/rx8130/rx8130.cpp new file mode 100644 index 0000000000..ba092a4834 --- /dev/null +++ b/esphome/components/rx8130/rx8130.cpp @@ -0,0 +1,128 @@ +#include "rx8130.h" +#include "esphome/core/log.h" + +// https://download.epsondevice.com/td/pdf/app/RX8130CE_en.pdf + +namespace esphome { +namespace rx8130 { + +static const uint8_t RX8130_REG_SEC = 0x10; +static const uint8_t RX8130_REG_MIN = 0x11; +static const uint8_t RX8130_REG_HOUR = 0x12; +static const uint8_t RX8130_REG_WDAY = 0x13; +static const uint8_t RX8130_REG_MDAY = 0x14; +static const uint8_t RX8130_REG_MONTH = 0x15; +static const uint8_t RX8130_REG_YEAR = 0x16; +static const uint8_t RX8130_REG_EXTEN = 0x1C; +static const uint8_t RX8130_REG_FLAG = 0x1D; +static const uint8_t RX8130_REG_CTRL0 = 0x1E; +static const uint8_t RX8130_REG_CTRL1 = 0x1F; +static const uint8_t RX8130_REG_DIG_OFFSET = 0x30; +static const uint8_t RX8130_BIT_CTRL_STOP = 0x40; +static const uint8_t RX8130_BAT_FLAGS = 0x30; +static const uint8_t RX8130_CLEAR_FLAGS = 0x00; + +static const char *const TAG = "rx8130"; + +constexpr uint8_t bcd2dec(uint8_t val) { return (val >> 4) * 10 + (val & 0x0f); } +constexpr uint8_t dec2bcd(uint8_t val) { return ((val / 10) << 4) + (val % 10); } + +void RX8130Component::setup() { + // Set digital offset to disabled with no offset + if (this->write_register(RX8130_REG_DIG_OFFSET, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + // Disable wakeup timers + if (this->write_register(RX8130_REG_EXTEN, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + // Clear VLF flag in case there has been data loss + if (this->write_register(RX8130_REG_FLAG, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + // Clear test flag and disable interrupts + if (this->write_register(RX8130_REG_CTRL0, &RX8130_CLEAR_FLAGS, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + // Enable battery charging and switching + if (this->write_register(RX8130_REG_CTRL1, &RX8130_BAT_FLAGS, 1) != i2c::ERROR_OK) { + this->mark_failed(); + return; + } + // Clear STOP bit + this->stop_(false); +} + +void RX8130Component::update() { this->read_time(); } + +void RX8130Component::dump_config() { + ESP_LOGCONFIG(TAG, "RX8130:"); + LOG_I2C_DEVICE(this); + RealTimeClock::dump_config(); +} + +void RX8130Component::read_time() { + uint8_t date[7]; + if (this->read_register(RX8130_REG_SEC, date, 7) != i2c::ERROR_OK) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + return; + } + ESPTime rtc_time{ + .second = bcd2dec(date[0] & 0x7f), + .minute = bcd2dec(date[1] & 0x7f), + .hour = bcd2dec(date[2] & 0x3f), + .day_of_week = bcd2dec(date[3] & 0x7f), + .day_of_month = bcd2dec(date[4] & 0x3f), + .day_of_year = 1, // ignored by recalc_timestamp_utc(false) + .month = bcd2dec(date[5] & 0x1f), + .year = static_cast(bcd2dec(date[6]) + 2000), + .is_dst = false, // not used + .timestamp = 0 // overwritten by recalc_timestamp_utc(false) + }; + rtc_time.recalc_timestamp_utc(false); + if (!rtc_time.is_valid()) { + ESP_LOGE(TAG, "Invalid RTC time, not syncing to system clock."); + return; + } + ESP_LOGD(TAG, "Read UTC time: %04d-%02d-%02d %02d:%02d:%02d", rtc_time.year, rtc_time.month, rtc_time.day_of_month, + rtc_time.hour, rtc_time.minute, rtc_time.second); + time::RealTimeClock::synchronize_epoch_(rtc_time.timestamp); +} + +void RX8130Component::write_time() { + auto now = time::RealTimeClock::utcnow(); + if (!now.is_valid()) { + ESP_LOGE(TAG, "Invalid system time, not syncing to RTC."); + return; + } + uint8_t buff[7]; + buff[0] = dec2bcd(now.second); + buff[1] = dec2bcd(now.minute); + buff[2] = dec2bcd(now.hour); + buff[3] = dec2bcd(now.day_of_week); + buff[4] = dec2bcd(now.day_of_month); + buff[5] = dec2bcd(now.month); + buff[6] = dec2bcd(now.year % 100); + this->stop_(true); + if (this->write_register(RX8130_REG_SEC, buff, 7) != i2c::ERROR_OK) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + } else { + ESP_LOGD(TAG, "Wrote UTC time: %04d-%02d-%02d %02d:%02d:%02d", now.year, now.month, now.day_of_month, now.hour, + now.minute, now.second); + } + this->stop_(false); +} + +void RX8130Component::stop_(bool stop) { + const uint8_t data = stop ? RX8130_BIT_CTRL_STOP : RX8130_CLEAR_FLAGS; + if (this->write_register(RX8130_REG_CTRL0, &data, 1) != i2c::ERROR_OK) { + this->status_set_warning(ESP_LOG_MSG_COMM_FAIL); + } +} + +} // namespace rx8130 +} // namespace esphome diff --git a/esphome/components/rx8130/rx8130.h b/esphome/components/rx8130/rx8130.h new file mode 100644 index 0000000000..6694c763cd --- /dev/null +++ b/esphome/components/rx8130/rx8130.h @@ -0,0 +1,35 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/i2c/i2c.h" +#include "esphome/components/time/real_time_clock.h" + +namespace esphome { +namespace rx8130 { + +class RX8130Component : public time::RealTimeClock, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + void read_time(); + void write_time(); + /// Ensure RTC is initialized at the correct time in the setup sequence + float get_setup_priority() const override { return setup_priority::DATA; } + + protected: + void stop_(bool stop); +}; + +template class WriteAction : public Action, public Parented { + public: + void play(const Ts... x) override { this->parent_->write_time(); } +}; + +template class ReadAction : public Action, public Parented { + public: + void play(const Ts... x) override { this->parent_->read_time(); } +}; + +} // namespace rx8130 +} // namespace esphome diff --git a/esphome/components/rx8130/time.py b/esphome/components/rx8130/time.py new file mode 100644 index 0000000000..cb0402bd32 --- /dev/null +++ b/esphome/components/rx8130/time.py @@ -0,0 +1,56 @@ +from esphome import automation +import esphome.codegen as cg +from esphome.components import i2c, time +import esphome.config_validation as cv +from esphome.const import CONF_ID + +CODEOWNERS = ["@beormund"] +DEPENDENCIES = ["i2c"] +rx8130_ns = cg.esphome_ns.namespace("rx8130") +RX8130Component = rx8130_ns.class_("RX8130Component", time.RealTimeClock, i2c.I2CDevice) +WriteAction = rx8130_ns.class_("WriteAction", automation.Action) +ReadAction = rx8130_ns.class_("ReadAction", automation.Action) + + +CONFIG_SCHEMA = time.TIME_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(RX8130Component), + } +).extend(i2c.i2c_device_schema(0x32)) + + +@automation.register_action( + "rx8130.write_time", + WriteAction, + cv.Schema( + { + cv.GenerateID(): cv.use_id(RX8130Component), + } + ), +) +async def rx8130_write_time_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +@automation.register_action( + "rx8130.read_time", + ReadAction, + automation.maybe_simple_id( + { + cv.GenerateID(): cv.use_id(RX8130Component), + } + ), +) +async def rx8130_read_time_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + return var + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) + await time.register_time(var, config) diff --git a/esphome/components/script/script.h b/esphome/components/script/script.h index 51cece01e4..cd1a084f16 100644 --- a/esphome/components/script/script.h +++ b/esphome/components/script/script.h @@ -1,8 +1,8 @@ #pragma once +#include #include #include -#include #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/helpers.h" @@ -46,14 +46,14 @@ template class Script : public ScriptLogger, public Trigger &tuple) { - this->execute_tuple_(tuple, typename gens::type()); + this->execute_tuple_(tuple, std::make_index_sequence{}); } // Internal function to give scripts readable names. void set_name(const LogString *name) { name_ = name; } protected: - template void execute_tuple_(const std::tuple &tuple, seq /*unused*/) { + template void execute_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->execute(std::get(tuple)...); } @@ -157,7 +157,7 @@ template class QueueingScript : public Script, public Com const size_t queue_capacity = static_cast(this->max_runs_ - 1); auto tuple_ptr = std::move(this->var_queue_[this->queue_front_]); this->queue_front_ = (this->queue_front_ + 1) % queue_capacity; - this->trigger_tuple_(*tuple_ptr, typename gens::type()); + this->trigger_tuple_(*tuple_ptr, std::make_index_sequence{}); } } @@ -174,7 +174,7 @@ template class QueueingScript : public Script, public Com } } - template void trigger_tuple_(const std::tuple &tuple, seq /*unused*/) { + template void trigger_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->trigger(std::get(tuple)...); } @@ -278,7 +278,12 @@ template class ScriptWaitAction : public Action, void setup() override { // Start with loop disabled - only enable when there's work to do - this->disable_loop(); + // IMPORTANT: Only disable if num_running_ is 0, otherwise play_complex() was already + // called before our setup() (e.g., from on_boot trigger at same priority level) + // and we must not undo its enable_loop() call + if (this->num_running_ == 0) { + this->disable_loop(); + } } void play_complex(const Ts &...x) override { @@ -290,10 +295,10 @@ template class ScriptWaitAction : public Action, } // Store parameters for later execution - this->param_queue_.emplace_front(x...); - // Enable loop now that we have work to do + this->param_queue_.emplace_back(x...); + // Enable loop now that we have work to do - don't call loop() synchronously! + // Let the event loop call it to avoid reentrancy issues this->enable_loop(); - this->loop(); } void loop() override { @@ -303,13 +308,17 @@ template class ScriptWaitAction : public Action, if (this->script_->is_running()) return; - while (!this->param_queue_.empty()) { + // Only process ONE queued item per loop iteration + // Processing all items in a while loop causes infinite loops because + // play_next_() can trigger more items to be queued + if (!this->param_queue_.empty()) { auto ¶ms = this->param_queue_.front(); - this->play_next_tuple_(params, typename gens::type()); + this->play_next_tuple_(params, std::make_index_sequence{}); this->param_queue_.pop_front(); + } else { + // Queue is now empty - disable loop until next play_complex + this->disable_loop(); } - // Queue is now empty - disable loop until next play_complex - this->disable_loop(); } void play(const Ts &...x) override { /* ignore - see play_complex */ @@ -321,12 +330,12 @@ template class ScriptWaitAction : public Action, } protected: - template void play_next_tuple_(const std::tuple &tuple, seq /*unused*/) { + template void play_next_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->play_next_(std::get(tuple)...); } C *script_; - std::forward_list> param_queue_; + std::list> param_queue_; }; } // namespace script diff --git a/esphome/components/select/automation.h b/esphome/components/select/automation.h index 3e42eaf98a..768f2621f7 100644 --- a/esphome/components/select/automation.h +++ b/esphome/components/select/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/component.h" #include "select.h" -namespace esphome { -namespace select { +namespace esphome::select { class SelectStateTrigger : public Trigger { public: @@ -63,5 +62,4 @@ template class SelectOperationAction : public Action { Select *select_; }; -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 9fe7a52422..3ec413f167 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace select { +namespace esphome::select { static const char *const TAG = "select"; @@ -86,5 +85,4 @@ optional Select::at(size_t index) const { const char *Select::option_at(size_t index) const { return traits.get_options().at(index); } -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index 7459c9d146..c4d7412d50 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -6,8 +6,7 @@ #include "select_call.h" #include "select_traits.h" -namespace esphome { -namespace select { +namespace esphome::select { #define LOG_SELECT(prefix, type, obj) \ if ((obj) != nullptr) { \ @@ -114,5 +113,4 @@ class Select : public EntityBase { CallbackManager state_callback_; }; -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index aa7559e24e..aecfed0d64 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -2,8 +2,7 @@ #include "select.h" #include "esphome/core/log.h" -namespace esphome { -namespace select { +namespace esphome::select { static const char *const TAG = "select"; @@ -125,5 +124,4 @@ void SelectCall::perform() { parent->control(idx); } -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select_call.h b/esphome/components/select/select_call.h index eae7d3de1d..b31d890ef6 100644 --- a/esphome/components/select/select_call.h +++ b/esphome/components/select/select_call.h @@ -2,8 +2,7 @@ #include "esphome/core/helpers.h" -namespace esphome { -namespace select { +namespace esphome::select { class Select; @@ -45,5 +44,4 @@ class SelectCall { bool cycle_; }; -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select_traits.cpp b/esphome/components/select/select_traits.cpp index e5e12bdc7a..ff52c0d85b 100644 --- a/esphome/components/select/select_traits.cpp +++ b/esphome/components/select/select_traits.cpp @@ -1,7 +1,6 @@ #include "select_traits.h" -namespace esphome { -namespace select { +namespace esphome::select { void SelectTraits::set_options(const std::initializer_list &options) { this->options_ = options; } @@ -14,5 +13,4 @@ void SelectTraits::set_options(const FixedVector &options) { const FixedVector &SelectTraits::get_options() const { return this->options_; } -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/select/select_traits.h b/esphome/components/select/select_traits.h index ee59a030ad..78a83e5944 100644 --- a/esphome/components/select/select_traits.h +++ b/esphome/components/select/select_traits.h @@ -3,8 +3,7 @@ #include "esphome/core/helpers.h" #include -namespace esphome { -namespace select { +namespace esphome::select { class SelectTraits { public: @@ -16,5 +15,4 @@ class SelectTraits { FixedVector options_; }; -} // namespace select -} // namespace esphome +} // namespace esphome::select diff --git a/esphome/components/sensor/__init__.py b/esphome/components/sensor/__init__.py index e8fec222a1..027d9a69b8 100644 --- a/esphome/components/sensor/__init__.py +++ b/esphome/components/sensor/__init__.py @@ -182,6 +182,7 @@ STATE_CLASSES = { "measurement": StateClasses.STATE_CLASS_MEASUREMENT, "total_increasing": StateClasses.STATE_CLASS_TOTAL_INCREASING, "total": StateClasses.STATE_CLASS_TOTAL, + "measurement_angle": StateClasses.STATE_CLASS_MEASUREMENT_ANGLE, } validate_state_class = cv.enum(STATE_CLASSES, lower=True, space="_") @@ -270,7 +271,9 @@ ThrottleFilter = sensor_ns.class_("ThrottleFilter", Filter) ThrottleWithPriorityFilter = sensor_ns.class_( "ThrottleWithPriorityFilter", ValueListFilter ) -TimeoutFilter = sensor_ns.class_("TimeoutFilter", Filter, cg.Component) +TimeoutFilterBase = sensor_ns.class_("TimeoutFilterBase", Filter, cg.Component) +TimeoutFilterLast = sensor_ns.class_("TimeoutFilterLast", TimeoutFilterBase) +TimeoutFilterConfigured = sensor_ns.class_("TimeoutFilterConfigured", TimeoutFilterBase) DebounceFilter = sensor_ns.class_("DebounceFilter", Filter, cg.Component) HeartbeatFilter = sensor_ns.class_("HeartbeatFilter", Filter, cg.Component) DeltaFilter = sensor_ns.class_("DeltaFilter", Filter) @@ -681,11 +684,16 @@ TIMEOUT_SCHEMA = cv.maybe_simple_value( ) -@FILTER_REGISTRY.register("timeout", TimeoutFilter, TIMEOUT_SCHEMA) +@FILTER_REGISTRY.register("timeout", TimeoutFilterBase, TIMEOUT_SCHEMA) async def timeout_filter_to_code(config, filter_id): + filter_id = filter_id.copy() if config[CONF_VALUE] == "last": + # Use TimeoutFilterLast for "last" mode (smaller, more common - LD2450, LD2412, etc.) + filter_id.type = TimeoutFilterLast var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT]) else: + # Use TimeoutFilterConfigured for configured value mode + filter_id.type = TimeoutFilterConfigured template_ = await cg.templatable(config[CONF_VALUE], [], float) var = cg.new_Pvariable(filter_id, config[CONF_TIMEOUT], template_) await cg.register_component(var, {}) diff --git a/esphome/components/sensor/filter.cpp b/esphome/components/sensor/filter.cpp index 65d8dea31c..c8c6540112 100644 --- a/esphome/components/sensor/filter.cpp +++ b/esphome/components/sensor/filter.cpp @@ -339,20 +339,43 @@ void OrFilter::initialize(Sensor *parent, Filter *next) { this->phi_.initialize(parent, nullptr); } -// TimeoutFilter -optional TimeoutFilter::new_value(float value) { - if (this->value_.has_value()) { - this->set_timeout("timeout", this->time_period_, [this]() { this->output(this->value_.value().value()); }); - } else { - this->set_timeout("timeout", this->time_period_, [this, value]() { this->output(value); }); +// TimeoutFilterBase - shared loop logic +void TimeoutFilterBase::loop() { + // Check if timeout period has elapsed + // Use cached loop start time to avoid repeated millis() calls + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->timeout_start_time_ >= this->time_period_) { + // Timeout fired - get output value from derived class and output it + this->output(this->get_output_value()); + + // Disable loop until next value arrives + this->disable_loop(); } +} + +float TimeoutFilterBase::get_setup_priority() const { return setup_priority::HARDWARE; } + +// TimeoutFilterLast - "last" mode implementation +optional TimeoutFilterLast::new_value(float value) { + // Store the value to output when timeout fires + this->pending_value_ = value; + + // Record when timeout started and enable loop + this->timeout_start_time_ = millis(); + this->enable_loop(); + return value; } -TimeoutFilter::TimeoutFilter(uint32_t time_period) : time_period_(time_period) {} -TimeoutFilter::TimeoutFilter(uint32_t time_period, const TemplatableValue &new_value) - : time_period_(time_period), value_(new_value) {} -float TimeoutFilter::get_setup_priority() const { return setup_priority::HARDWARE; } +// TimeoutFilterConfigured - configured value mode implementation +optional TimeoutFilterConfigured::new_value(float value) { + // Record when timeout started and enable loop + // Note: we don't store the incoming value since we have a configured value + this->timeout_start_time_ = millis(); + this->enable_loop(); + + return value; +} // DebounceFilter optional DebounceFilter::new_value(float value) { diff --git a/esphome/components/sensor/filter.h b/esphome/components/sensor/filter.h index 75e28a1efe..92a9184c18 100644 --- a/esphome/components/sensor/filter.h +++ b/esphome/components/sensor/filter.h @@ -380,18 +380,46 @@ class ThrottleWithPriorityFilter : public ValueListFilter { uint32_t min_time_between_inputs_; }; -class TimeoutFilter : public Filter, public Component { +// Base class for timeout filters - contains common loop logic +class TimeoutFilterBase : public Filter, public Component { public: - explicit TimeoutFilter(uint32_t time_period); - explicit TimeoutFilter(uint32_t time_period, const TemplatableValue &new_value); - - optional new_value(float value) override; - + void loop() override; float get_setup_priority() const override; protected: - uint32_t time_period_; - optional> value_; + explicit TimeoutFilterBase(uint32_t time_period) : time_period_(time_period) { this->disable_loop(); } + virtual float get_output_value() = 0; + + uint32_t time_period_; // 4 bytes (timeout duration in ms) + uint32_t timeout_start_time_{0}; // 4 bytes (when the timeout was started) + // Total base: 8 bytes +}; + +// Timeout filter for "last" mode - outputs the last received value after timeout +class TimeoutFilterLast : public TimeoutFilterBase { + public: + explicit TimeoutFilterLast(uint32_t time_period) : TimeoutFilterBase(time_period) {} + + optional new_value(float value) override; + + protected: + float get_output_value() override { return this->pending_value_; } + float pending_value_{0}; // 4 bytes (value to output when timeout fires) + // Total: 8 (base) + 4 = 12 bytes + vtable ptr + Component overhead +}; + +// Timeout filter with configured value - evaluates TemplatableValue after timeout +class TimeoutFilterConfigured : public TimeoutFilterBase { + public: + explicit TimeoutFilterConfigured(uint32_t time_period, const TemplatableValue &new_value) + : TimeoutFilterBase(time_period), value_(new_value) {} + + optional new_value(float value) override; + + protected: + float get_output_value() override { return this->value_.value(); } + TemplatableValue value_; // 16 bytes (configured output value, can be lambda) + // Total: 8 (base) + 16 = 24 bytes + vtable ptr + Component overhead }; class DebounceFilter : public Filter, public Component { diff --git a/esphome/components/sensor/sensor.cpp b/esphome/components/sensor/sensor.cpp index 30187f498b..3364798e55 100644 --- a/esphome/components/sensor/sensor.cpp +++ b/esphome/components/sensor/sensor.cpp @@ -44,6 +44,8 @@ const LogString *state_class_to_string(StateClass state_class) { return LOG_STR("total_increasing"); case STATE_CLASS_TOTAL: return LOG_STR("total"); + case STATE_CLASS_MEASUREMENT_ANGLE: + return LOG_STR("measurement_angle"); case STATE_CLASS_NONE: default: return LOG_STR(""); diff --git a/esphome/components/sensor/sensor.h b/esphome/components/sensor/sensor.h index 42e540a349..6f0412842a 100644 --- a/esphome/components/sensor/sensor.h +++ b/esphome/components/sensor/sensor.h @@ -31,6 +31,7 @@ enum StateClass : uint8_t { STATE_CLASS_MEASUREMENT = 1, STATE_CLASS_TOTAL_INCREASING = 2, STATE_CLASS_TOTAL = 3, + STATE_CLASS_MEASUREMENT_ANGLE = 4 }; const LogString *state_class_to_string(StateClass state_class); diff --git a/esphome/components/sfa30/sfa30.cpp b/esphome/components/sfa30/sfa30.cpp index 99709d5fbb..bbe3bcd7d2 100644 --- a/esphome/components/sfa30/sfa30.cpp +++ b/esphome/components/sfa30/sfa30.cpp @@ -73,17 +73,17 @@ void SFA30Component::update() { } if (this->formaldehyde_sensor_ != nullptr) { - const float formaldehyde = raw_data[0] / 5.0f; + const float formaldehyde = static_cast(raw_data[0]) / 5.0f; this->formaldehyde_sensor_->publish_state(formaldehyde); } if (this->humidity_sensor_ != nullptr) { - const float humidity = raw_data[1] / 100.0f; + const float humidity = static_cast(raw_data[1]) / 100.0f; this->humidity_sensor_->publish_state(humidity); } if (this->temperature_sensor_ != nullptr) { - const float temperature = raw_data[2] / 200.0f; + const float temperature = static_cast(raw_data[2]) / 200.0f; this->temperature_sensor_->publish_state(temperature); } diff --git a/esphome/components/sht4x/sht4x.cpp b/esphome/components/sht4x/sht4x.cpp index 62b8717ded..9d29746f0b 100644 --- a/esphome/components/sht4x/sht4x.cpp +++ b/esphome/components/sht4x/sht4x.cpp @@ -7,16 +7,28 @@ namespace sht4x { static const char *const TAG = "sht4x"; static const uint8_t MEASURECOMMANDS[] = {0xFD, 0xF6, 0xE0}; +static const uint8_t SERIAL_NUMBER_COMMAND = 0x89; void SHT4XComponent::start_heater_() { uint8_t cmd[] = {MEASURECOMMANDS[this->heater_command_]}; ESP_LOGD(TAG, "Heater turning on"); if (this->write(cmd, 1) != i2c::ERROR_OK) { - this->status_set_error("Failed to turn on heater"); + this->status_set_error(LOG_STR("Failed to turn on heater")); } } +void SHT4XComponent::read_serial_number_() { + uint16_t buffer[2]; + if (!this->get_8bit_register(SERIAL_NUMBER_COMMAND, buffer, 2, 1)) { + ESP_LOGE(TAG, "Get serial number failed"); + this->serial_number_ = 0; + return; + } + this->serial_number_ = (uint32_t(buffer[0]) << 16) | (uint32_t(buffer[1])); + ESP_LOGD(TAG, "Serial number: %08" PRIx32, this->serial_number_); +} + void SHT4XComponent::setup() { auto err = this->write(nullptr, 0); if (err != i2c::ERROR_OK) { @@ -24,6 +36,8 @@ void SHT4XComponent::setup() { return; } + this->read_serial_number_(); + if (std::isfinite(this->duty_cycle_) && this->duty_cycle_ > 0.0f) { uint32_t heater_interval = static_cast(static_cast(this->heater_time_) / this->duty_cycle_); ESP_LOGD(TAG, "Heater interval: %" PRIu32, heater_interval); @@ -54,11 +68,18 @@ void SHT4XComponent::setup() { } void SHT4XComponent::dump_config() { - ESP_LOGCONFIG(TAG, "SHT4x:"); + ESP_LOGCONFIG(TAG, + "SHT4x:\n" + " Serial number: %08" PRIx32, + this->serial_number_); + LOG_I2C_DEVICE(this); if (this->is_failed()) { ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); } + if (this->serial_number_ == 0) { + ESP_LOGW(TAG, "Get serial number failed"); + } } void SHT4XComponent::update() { diff --git a/esphome/components/sht4x/sht4x.h b/esphome/components/sht4x/sht4x.h index accc7323be..aec0f3d7f8 100644 --- a/esphome/components/sht4x/sht4x.h +++ b/esphome/components/sht4x/sht4x.h @@ -36,7 +36,9 @@ class SHT4XComponent : public PollingComponent, public sensirion_common::Sensiri float duty_cycle_; void start_heater_(); + void read_serial_number_(); uint8_t heater_command_; + uint32_t serial_number_; sensor::Sensor *temp_sensor_{nullptr}; sensor::Sensor *humidity_sensor_{nullptr}; diff --git a/esphome/components/sntp/sntp_component.cpp b/esphome/components/sntp/sntp_component.cpp index 331a9b3509..c4d78b6e0b 100644 --- a/esphome/components/sntp/sntp_component.cpp +++ b/esphome/components/sntp/sntp_component.cpp @@ -61,6 +61,7 @@ void SNTPComponent::dump_config() { for (auto &server : this->servers_) { ESP_LOGCONFIG(TAG, " Server %zu: '%s'", i++, server); } + RealTimeClock::dump_config(); } void SNTPComponent::update() { #if !defined(USE_ESP32) diff --git a/esphome/components/sntp/time.py b/esphome/components/sntp/time.py index d27fc9991d..69a2436d3d 100644 --- a/esphome/components/sntp/time.py +++ b/esphome/components/sntp/time.py @@ -1,9 +1,14 @@ +import logging + import esphome.codegen as cg from esphome.components import time as time_ +from esphome.config_helpers import merge_config import esphome.config_validation as cv from esphome.const import ( CONF_ID, + CONF_PLATFORM, CONF_SERVERS, + CONF_TIME, PLATFORM_BK72XX, PLATFORM_ESP32, PLATFORM_ESP8266, @@ -12,13 +17,74 @@ from esphome.const import ( PLATFORM_RTL87XX, ) from esphome.core import CORE +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) DEPENDENCIES = ["network"] + +CONF_SNTP = "sntp" + sntp_ns = cg.esphome_ns.namespace("sntp") SNTPComponent = sntp_ns.class_("SNTPComponent", time_.RealTimeClock) DEFAULT_SERVERS = ["0.pool.ntp.org", "1.pool.ntp.org", "2.pool.ntp.org"] + +def _sntp_final_validate(config: ConfigType) -> None: + """Merge multiple SNTP instances into one, similar to OTA merging behavior.""" + full_conf = fv.full_config.get() + time_confs = full_conf.get(CONF_TIME, []) + + sntp_configs: list[ConfigType] = [] + other_time_configs: list[ConfigType] = [] + + for time_conf in time_confs: + if time_conf.get(CONF_PLATFORM) == CONF_SNTP: + sntp_configs.append(time_conf) + else: + other_time_configs.append(time_conf) + + if len(sntp_configs) <= 1: + return + + # Merge all SNTP configs into the first one + merged = sntp_configs[0] + for sntp_conf in sntp_configs[1:]: + # Validate that IDs are consistent if manually specified + if merged[CONF_ID].is_manual and sntp_conf[CONF_ID].is_manual: + raise cv.Invalid( + f"Found multiple SNTP configurations but {CONF_ID} is inconsistent" + ) + merged = merge_config(merged, sntp_conf) + + # Deduplicate servers while preserving order + servers = merged[CONF_SERVERS] + unique_servers = list(dict.fromkeys(servers)) + + # Warn if we're dropping servers due to 3-server limit + if len(unique_servers) > 3: + dropped = unique_servers[3:] + unique_servers = unique_servers[:3] + _LOGGER.warning( + "SNTP supports maximum 3 servers. Dropped excess server(s): %s", + dropped, + ) + + merged[CONF_SERVERS] = unique_servers + + _LOGGER.warning( + "Found and merged %d SNTP time configurations into one instance", + len(sntp_configs), + ) + + # Replace time configs with merged SNTP + other time platforms + other_time_configs.append(merged) + full_conf[CONF_TIME] = other_time_configs + fv.full_config.set(full_conf) + + CONFIG_SCHEMA = cv.All( time_.TIME_SCHEMA.extend( { @@ -40,6 +106,8 @@ CONFIG_SCHEMA = cv.All( ), ) +FINAL_VALIDATE_SCHEMA = _sntp_final_validate + async def to_code(config): servers = config[CONF_SERVERS] diff --git a/esphome/components/socket/socket.cpp b/esphome/components/socket/socket.cpp index 1c8e72b8fd..cc9232d21a 100644 --- a/esphome/components/socket/socket.cpp +++ b/esphome/components/socket/socket.cpp @@ -61,9 +61,18 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::stri server->sin6_family = AF_INET6; server->sin6_port = htons(port); +#ifdef USE_SOCKET_IMPL_BSD_SOCKETS + // Use standard inet_pton for BSD sockets + if (inet_pton(AF_INET6, ip_address.c_str(), &server->sin6_addr) != 1) { + errno = EINVAL; + return 0; + } +#else + // Use LWIP-specific functions ip6_addr_t ip6; inet6_aton(ip_address.c_str(), &ip6); memcpy(server->sin6_addr.un.u32_addr, ip6.addr, sizeof(ip6.addr)); +#endif return sizeof(sockaddr_in6); } #endif /* USE_NETWORK_IPV6 */ diff --git a/esphome/components/sound_level/sound_level.cpp b/esphome/components/sound_level/sound_level.cpp index db6b168bbc..2719172409 100644 --- a/esphome/components/sound_level/sound_level.cpp +++ b/esphome/components/sound_level/sound_level.cpp @@ -167,7 +167,7 @@ bool SoundLevelComponent::start_() { this->audio_buffer_ = audio::AudioSourceTransferBuffer::create( this->microphone_source_->get_audio_stream_info().ms_to_bytes(AUDIO_BUFFER_DURATION_MS)); if (this->audio_buffer_ == nullptr) { - this->status_momentary_error("Failed to allocate transfer buffer", 15000); + this->status_momentary_error("transfer_buffer", 15000); return false; } @@ -176,7 +176,7 @@ bool SoundLevelComponent::start_() { std::shared_ptr temp_ring_buffer = RingBuffer::create(this->microphone_source_->get_audio_stream_info().ms_to_bytes(RING_BUFFER_DURATION_MS)); if (temp_ring_buffer.use_count() == 0) { - this->status_momentary_error("Failed to allocate ring buffer", 15000); + this->status_momentary_error("ring_buffer", 15000); this->stop_(); return false; } else { diff --git a/esphome/components/speaker/media_player/__init__.py b/esphome/components/speaker/media_player/__init__.py index e50656e723..062bff92f8 100644 --- a/esphome/components/speaker/media_player/__init__.py +++ b/esphome/components/speaker/media_player/__init__.py @@ -6,7 +6,7 @@ from pathlib import Path from esphome import automation, external_files import esphome.codegen as cg -from esphome.components import audio, esp32, media_player, psram, speaker +from esphome.components import audio, esp32, media_player, network, psram, speaker import esphome.config_validation as cv from esphome.const import ( CONF_BUFFER_SIZE, @@ -32,6 +32,7 @@ _LOGGER = logging.getLogger(__name__) AUTO_LOAD = ["audio"] +DEPENDENCIES = ["network"] CODEOWNERS = ["@kahrendt", "@synesthesiam"] DOMAIN = "media_player" @@ -280,6 +281,18 @@ PIPELINE_SCHEMA = cv.Schema( } ) + +def _request_high_performance_networking(config): + """Request high performance networking for streaming media. + + Speaker media player streams audio data, so it always benefits from + optimized WiFi and lwip settings regardless of codec support. + Called during config validation to ensure flags are set before to_code(). + """ + network.require_high_performance_networking() + return config + + CONFIG_SCHEMA = cv.All( media_player.media_player_schema(SpeakerMediaPlayer).extend( { @@ -304,6 +317,7 @@ CONFIG_SCHEMA = cv.All( ), cv.only_with_esp_idf, _validate_repeated_speaker, + _request_high_performance_networking, ) @@ -321,28 +335,10 @@ FINAL_VALIDATE_SCHEMA = cv.All( async def to_code(config): if CORE.data[DOMAIN][config[CONF_ID].id][CONF_CODEC_SUPPORT_ENABLED]: - # Compile all supported audio codecs and optimize the wifi settings - + # Compile all supported audio codecs cg.add_define("USE_AUDIO_FLAC_SUPPORT", True) cg.add_define("USE_AUDIO_MP3_SUPPORT", True) - # Based on https://github.com/espressif/esp-idf/blob/release/v5.4/examples/wifi/iperf/sdkconfig.defaults.esp32 - esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM", 16) - esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM", 64) - esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM", 64) - esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_TX_ENABLED", True) - esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_TX_BA_WIN", 32) - esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_RX_ENABLED", True) - esp32.add_idf_sdkconfig_option("CONFIG_ESP_WIFI_RX_BA_WIN", 32) - - esp32.add_idf_sdkconfig_option("CONFIG_LWIP_TCP_SND_BUF_DEFAULT", 65534) - esp32.add_idf_sdkconfig_option("CONFIG_LWIP_TCP_WND_DEFAULT", 65534) - esp32.add_idf_sdkconfig_option("CONFIG_LWIP_TCP_RECVMBOX_SIZE", 64) - esp32.add_idf_sdkconfig_option("CONFIG_LWIP_TCPIP_RECVMBOX_SIZE", 64) - - # Allocate wifi buffers in PSRAM - esp32.add_idf_sdkconfig_option("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP", True) - var = await media_player.new_media_player(config) await cg.register_component(var, config) diff --git a/esphome/components/stts22h/__init__.py b/esphome/components/stts22h/__init__.py new file mode 100644 index 0000000000..a33c0b554b --- /dev/null +++ b/esphome/components/stts22h/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@B48D81EFCC"] diff --git a/esphome/components/stts22h/sensor.py b/esphome/components/stts22h/sensor.py new file mode 100644 index 0000000000..094c233361 --- /dev/null +++ b/esphome/components/stts22h/sensor.py @@ -0,0 +1,33 @@ +import esphome.codegen as cg +from esphome.components import i2c, sensor +import esphome.config_validation as cv +from esphome.const import ( + DEVICE_CLASS_TEMPERATURE, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, +) + +DEPENDENCIES = ["i2c"] + +sensor_ns = cg.esphome_ns.namespace("stts22h") +stts22h = sensor_ns.class_( + "STTS22HComponent", sensor.Sensor, cg.PollingComponent, i2c.I2CDevice +) + +CONFIG_SCHEMA = ( + sensor.sensor_schema( + stts22h, + accuracy_decimals=2, + unit_of_measurement=UNIT_CELSIUS, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ) + .extend(cv.polling_component_schema("60s")) + .extend(i2c.i2c_device_schema(0x3C)) +) + + +async def to_code(config): + var = await sensor.new_sensor(config) + await cg.register_component(var, config) + await i2c.register_i2c_device(var, config) diff --git a/esphome/components/stts22h/stts22h.cpp b/esphome/components/stts22h/stts22h.cpp new file mode 100644 index 0000000000..2b2559c843 --- /dev/null +++ b/esphome/components/stts22h/stts22h.cpp @@ -0,0 +1,101 @@ +#include "esphome/core/log.h" +#include "stts22h.h" + +namespace esphome::stts22h { + +static const char *const TAG = "stts22h"; + +static const uint8_t WHOAMI_REG = 0x01; +static const uint8_t CTRL_REG = 0x04; +static const uint8_t TEMPERATURE_REG = 0x06; + +// CTRL_REG flags +static const uint8_t LOW_ODR_CTRL_ENABLE_FLAG = 0x80; // Flag to enable low ODR mode in CTRL_REG +static const uint8_t FREERUN_CTRL_ENABLE_FLAG = 0x04; // Flag to enable FREERUN mode in CTRL_REG +static const uint8_t ADD_INC_ENABLE_FLAG = 0x08; // Flag to enable ADD_INC (IF_ADD_INC) mode in CTRL_REG + +static const uint8_t WHOAMI_STTS22H_IDENTIFICATION = 0xA0; // ID value of STTS22H in WHOAMI_REG + +static const float SENSOR_SCALE = 0.01f; // Sensor resolution in degrees Celsius + +void STTS22HComponent::setup() { + // Check if device is a STTS22H + if (!this->is_stts22h_sensor_()) { + this->mark_failed(LOG_STR("Device is not a STTS22H sensor")); + return; + } + + this->initialize_sensor_(); +} + +void STTS22HComponent::update() { + if (this->is_failed()) { + return; + } + + this->publish_state(this->read_temperature_()); +} + +void STTS22HComponent::dump_config() { + LOG_SENSOR("", "STTS22H", this); + LOG_I2C_DEVICE(this); + LOG_UPDATE_INTERVAL(this); + if (this->is_failed()) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + } +} + +float STTS22HComponent::read_temperature_() { + uint8_t temp_reg_value[2]; + if (this->read_register(TEMPERATURE_REG, temp_reg_value, 2) != i2c::NO_ERROR) { + ESP_LOGE(TAG, ESP_LOG_MSG_COMM_FAIL); + return NAN; + } + + // Combine the two bytes into a single 16-bit signed integer + // The STTS22H temperature data is in two's complement format + int16_t temp_raw_value = static_cast(encode_uint16(temp_reg_value[1], temp_reg_value[0])); + return temp_raw_value * SENSOR_SCALE; // Apply sensor resolution +} + +bool STTS22HComponent::is_stts22h_sensor_() { + uint8_t whoami_value; + if (this->read_register(WHOAMI_REG, &whoami_value, 1) != i2c::NO_ERROR) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return false; + } + + if (whoami_value != WHOAMI_STTS22H_IDENTIFICATION) { + this->mark_failed(LOG_STR("Unexpected WHOAMI identifier. Sensor is not a STTS22H")); + return false; + } + + return true; +} + +void STTS22HComponent::initialize_sensor_() { + // Read current CTRL_REG configuration + uint8_t ctrl_value; + if (this->read_register(CTRL_REG, &ctrl_value, 1) != i2c::NO_ERROR) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Enable low ODR mode and enable ADD_INC + // Before low ODR mode can be used, + // FREERUN bit must be cleared (see sensor documentation) + ctrl_value &= ~FREERUN_CTRL_ENABLE_FLAG; // Clear FREERUN bit + if (this->write_register(CTRL_REG, &ctrl_value, 1) != i2c::NO_ERROR) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } + + // Enable LOW ODR mode and ADD_INC + ctrl_value |= LOW_ODR_CTRL_ENABLE_FLAG | ADD_INC_ENABLE_FLAG; // Set LOW ODR bit and ADD_INC bit + if (this->write_register(CTRL_REG, &ctrl_value, 1) != i2c::NO_ERROR) { + this->mark_failed(LOG_STR(ESP_LOG_MSG_COMM_FAIL)); + return; + } +} + +} // namespace esphome::stts22h diff --git a/esphome/components/stts22h/stts22h.h b/esphome/components/stts22h/stts22h.h new file mode 100644 index 0000000000..442a263e49 --- /dev/null +++ b/esphome/components/stts22h/stts22h.h @@ -0,0 +1,21 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/i2c/i2c.h" + +namespace esphome::stts22h { + +class STTS22HComponent : public sensor::Sensor, public PollingComponent, public i2c::I2CDevice { + public: + void setup() override; + void update() override; + void dump_config() override; + + protected: + void initialize_sensor_(); + bool is_stts22h_sensor_(); + float read_temperature_(); +}; + +} // namespace esphome::stts22h diff --git a/esphome/components/syslog/esphome_syslog.cpp b/esphome/components/syslog/esphome_syslog.cpp index 71468fa932..f5c20c891e 100644 --- a/esphome/components/syslog/esphome_syslog.cpp +++ b/esphome/components/syslog/esphome_syslog.cpp @@ -19,11 +19,10 @@ constexpr int LOG_LEVEL_TO_SYSLOG_SEVERITY[] = { 7 // VERY_VERBOSE }; -void Syslog::setup() { - logger::global_logger->add_on_log_callback( - [this](int level, const char *tag, const char *message, size_t message_len) { - this->log_(level, tag, message, message_len); - }); +void Syslog::setup() { logger::global_logger->add_log_listener(this); } + +void Syslog::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { + this->log_(level, tag, message, message_len); } void Syslog::log_(const int level, const char *tag, const char *message, size_t message_len) const { diff --git a/esphome/components/syslog/esphome_syslog.h b/esphome/components/syslog/esphome_syslog.h index e3b2f7dae5..1010993265 100644 --- a/esphome/components/syslog/esphome_syslog.h +++ b/esphome/components/syslog/esphome_syslog.h @@ -2,16 +2,18 @@ #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#include "esphome/components/logger/logger.h" #include "esphome/components/udp/udp_component.h" #include "esphome/components/time/real_time_clock.h" #ifdef USE_NETWORK namespace esphome { namespace syslog { -class Syslog : public Component, public Parented { +class Syslog : public Component, public Parented, public logger::LogListener { public: Syslog(int level, time::RealTimeClock *time) : log_level_(level), time_(time) {} void setup() override; + void on_log(uint8_t level, const char *tag, const char *message, size_t message_len) override; void set_strip(bool strip) { this->strip_ = strip; } void set_facility(int facility) { this->facility_ = facility; } diff --git a/esphome/components/template/alarm_control_panel/__init__.py b/esphome/components/template/alarm_control_panel/__init__.py index 5d2421fcbc..256c7f276a 100644 --- a/esphome/components/template/alarm_control_panel/__init__.py +++ b/esphome/components/template/alarm_control_panel/__init__.py @@ -137,7 +137,11 @@ async def to_code(config): cg.add(var.set_arming_night_time(config[CONF_ARMING_NIGHT_TIME])) supports_arm_night = True - for sensor in config.get(CONF_BINARY_SENSORS, []): + if sensors := config.get(CONF_BINARY_SENSORS, []): + # Initialize FixedVector with the exact number of sensors + cg.add(var.init_sensors(len(sensors))) + + for sensor in sensors: bs = await cg.get_variable(sensor[CONF_INPUT]) flags = BinarySensorFlags[FLAG_NORMAL] diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp index af662a05a0..f025435261 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.cpp @@ -20,10 +20,13 @@ void TemplateAlarmControlPanel::add_sensor(binary_sensor::BinarySensor *sensor, // Save the flags and type. Assign a store index for the per sensor data type. SensorDataStore sd; sd.last_chime_state = false; - this->sensor_map_[sensor].flags = flags; - this->sensor_map_[sensor].type = type; + AlarmSensor alarm_sensor; + alarm_sensor.sensor = sensor; + alarm_sensor.info.flags = flags; + alarm_sensor.info.type = type; + alarm_sensor.info.store_index = this->next_store_index_++; + this->sensors_.push_back(alarm_sensor); this->sensor_data_.push_back(sd); - this->sensor_map_[sensor].store_index = this->next_store_index_++; }; static const LogString *sensor_type_to_string(AlarmSensorType type) { @@ -45,7 +48,7 @@ void TemplateAlarmControlPanel::dump_config() { ESP_LOGCONFIG(TAG, "TemplateAlarmControlPanel:\n" " Current State: %s\n" - " Number of Codes: %u\n" + " Number of Codes: %zu\n" " Requires Code To Arm: %s\n" " Arming Away Time: %" PRIu32 "s\n" " Arming Home Time: %" PRIu32 "s\n" @@ -58,7 +61,8 @@ void TemplateAlarmControlPanel::dump_config() { (this->arming_home_time_ / 1000), (this->arming_night_time_ / 1000), (this->pending_time_ / 1000), (this->trigger_time_ / 1000), this->get_supported_features()); #ifdef USE_BINARY_SENSOR - for (auto const &[sensor, info] : this->sensor_map_) { + for (const auto &alarm_sensor : this->sensors_) { + const uint16_t flags = alarm_sensor.info.flags; ESP_LOGCONFIG(TAG, " Binary Sensor:\n" " Name: %s\n" @@ -67,11 +71,10 @@ void TemplateAlarmControlPanel::dump_config() { " Armed night bypass: %s\n" " Auto bypass: %s\n" " Chime mode: %s", - sensor->get_name().c_str(), LOG_STR_ARG(sensor_type_to_string(info.type)), - TRUEFALSE(info.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_HOME), - TRUEFALSE(info.flags & BINARY_SENSOR_MODE_BYPASS_ARMED_NIGHT), - TRUEFALSE(info.flags & BINARY_SENSOR_MODE_BYPASS_AUTO), - TRUEFALSE(info.flags & BINARY_SENSOR_MODE_CHIME)); + alarm_sensor.sensor->get_name().c_str(), LOG_STR_ARG(sensor_type_to_string(alarm_sensor.info.type)), + TRUEFALSE(flags & BINARY_SENSOR_MODE_BYPASS_ARMED_HOME), + TRUEFALSE(flags & BINARY_SENSOR_MODE_BYPASS_ARMED_NIGHT), + TRUEFALSE(flags & BINARY_SENSOR_MODE_BYPASS_AUTO), TRUEFALSE(flags & BINARY_SENSOR_MODE_CHIME)); } #endif } @@ -121,7 +124,9 @@ void TemplateAlarmControlPanel::loop() { #ifdef USE_BINARY_SENSOR // Test all of the sensors regardless of the alarm panel state - for (auto const &[sensor, info] : this->sensor_map_) { + for (const auto &alarm_sensor : this->sensors_) { + const auto &info = alarm_sensor.info; + auto *sensor = alarm_sensor.sensor; // Check for chime zones if (info.flags & BINARY_SENSOR_MODE_CHIME) { // Look for the transition from closed to open @@ -242,11 +247,11 @@ void TemplateAlarmControlPanel::arm_(optional code, alarm_control_p void TemplateAlarmControlPanel::bypass_before_arming() { #ifdef USE_BINARY_SENSOR - for (auto const &[sensor, info] : this->sensor_map_) { + for (const auto &alarm_sensor : this->sensors_) { // Check for faulted bypass_auto sensors and remove them from monitoring - if ((info.flags & BINARY_SENSOR_MODE_BYPASS_AUTO) && (sensor->state)) { - ESP_LOGW(TAG, "'%s' is faulted and will be automatically bypassed", sensor->get_name().c_str()); - this->bypassed_sensor_indicies_.push_back(info.store_index); + if ((alarm_sensor.info.flags & BINARY_SENSOR_MODE_BYPASS_AUTO) && (alarm_sensor.sensor->state)) { + ESP_LOGW(TAG, "'%s' is faulted and will be automatically bypassed", alarm_sensor.sensor->get_name().c_str()); + this->bypassed_sensor_indicies_.push_back(alarm_sensor.info.store_index); } } #endif diff --git a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h index 202dc7c13f..80ce34b8ae 100644 --- a/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h +++ b/esphome/components/template/alarm_control_panel/template_alarm_control_panel.h @@ -1,11 +1,12 @@ #pragma once #include -#include +#include #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/defines.h" +#include "esphome/core/helpers.h" #include "esphome/components/alarm_control_panel/alarm_control_panel.h" @@ -49,6 +50,13 @@ struct SensorInfo { uint8_t store_index; }; +#ifdef USE_BINARY_SENSOR +struct AlarmSensor { + binary_sensor::BinarySensor *sensor; + SensorInfo info; +}; +#endif + class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControlPanel, public Component { public: TemplateAlarmControlPanel(); @@ -63,6 +71,12 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl void bypass_before_arming(); #ifdef USE_BINARY_SENSOR + /** Initialize the sensors vector with the specified capacity. + * + * @param capacity The number of sensors to allocate space for. + */ + void init_sensors(size_t capacity) { this->sensors_.init(capacity); } + /** Add a binary_sensor to the alarm_panel. * * @param sensor The BinarySensor instance. @@ -122,8 +136,8 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl protected: void control(const alarm_control_panel::AlarmControlPanelCall &call) override; #ifdef USE_BINARY_SENSOR - // This maps a binary sensor to its alarm specific info - std::map sensor_map_; + // List of binary sensors with their alarm-specific info + FixedVector sensors_; // a list of automatically bypassed sensors std::vector bypassed_sensor_indicies_; #endif diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index a242b43b1c..40a37febee 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -66,10 +66,14 @@ SubstituteFilter::SubstituteFilter(const std::initializer_list &su : substitutions_(substitutions) {} optional SubstituteFilter::new_value(std::string value) { - std::size_t pos; for (const auto &sub : this->substitutions_) { - while ((pos = value.find(sub.from)) != std::string::npos) + std::size_t pos = 0; + while ((pos = value.find(sub.from, pos)) != std::string::npos) { value.replace(pos, sub.from.size(), sub.to); + // Advance past the replacement to avoid infinite loop when + // the replacement contains the search pattern (e.g., f -> foo) + pos += sub.to.size(); + } } return value; } diff --git a/esphome/components/text_sensor/text_sensor.cpp b/esphome/components/text_sensor/text_sensor.cpp index 65d7b1f0be..35921ec8fc 100644 --- a/esphome/components/text_sensor/text_sensor.cpp +++ b/esphome/components/text_sensor/text_sensor.cpp @@ -25,7 +25,11 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text } void TextSensor::publish_state(const std::string &state) { +// Suppress deprecation warning - we need to populate raw_state for backwards compatibility +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" this->raw_state = state; +#pragma GCC diagnostic pop // Call raw callbacks (before filters) this->callbacks_.call_first(this->raw_count_, state); @@ -78,7 +82,13 @@ void TextSensor::add_on_raw_state_callback(std::function call } std::string TextSensor::get_state() const { return this->state; } -std::string TextSensor::get_raw_state() const { return this->raw_state; } +std::string TextSensor::get_raw_state() const { +// Suppress deprecation warning - get_raw_state() is the replacement API +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + return this->raw_state; +#pragma GCC diagnostic pop +} void TextSensor::internal_send_state_to_frontend(const std::string &state) { this->state = state; this->set_has_state(true); diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index 1f4f3170e0..fbd02c6688 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -50,7 +50,13 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { void add_on_raw_state_callback(std::function callback); std::string state; + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + /// @deprecated Use get_raw_state() instead. This member will be removed in ESPHome 2026.6.0. + ESPDEPRECATED("Use get_raw_state() instead of .raw_state. Will be removed in 2026.6.0", "2025.12.0") std::string raw_state; +#pragma GCC diagnostic pop // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) diff --git a/esphome/components/thermopro_ble/__init__.py b/esphome/components/thermopro_ble/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/esphome/components/thermopro_ble/sensor.py b/esphome/components/thermopro_ble/sensor.py new file mode 100644 index 0000000000..de63229621 --- /dev/null +++ b/esphome/components/thermopro_ble/sensor.py @@ -0,0 +1,97 @@ +import esphome.codegen as cg +from esphome.components import esp32_ble_tracker, sensor +import esphome.config_validation as cv +from esphome.const import ( + CONF_BATTERY_LEVEL, + CONF_EXTERNAL_TEMPERATURE, + CONF_HUMIDITY, + CONF_ID, + CONF_MAC_ADDRESS, + CONF_SIGNAL_STRENGTH, + CONF_TEMPERATURE, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_SIGNAL_STRENGTH, + DEVICE_CLASS_TEMPERATURE, + ENTITY_CATEGORY_DIAGNOSTIC, + STATE_CLASS_MEASUREMENT, + UNIT_CELSIUS, + UNIT_DECIBEL_MILLIWATT, + UNIT_PERCENT, +) + +CODEOWNERS = ["@sittner"] + +DEPENDENCIES = ["esp32_ble_tracker"] + +thermopro_ble_ns = cg.esphome_ns.namespace("thermopro_ble") +ThermoProBLE = thermopro_ble_ns.class_( + "ThermoProBLE", esp32_ble_tracker.ESPBTDeviceListener, cg.Component +) + +CONFIG_SCHEMA = ( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(ThermoProBLE), + cv.Required(CONF_MAC_ADDRESS): cv.mac_address, + cv.Optional(CONF_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_EXTERNAL_TEMPERATURE): sensor.sensor_schema( + unit_of_measurement=UNIT_CELSIUS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_TEMPERATURE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_HUMIDITY): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_HUMIDITY, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_BATTERY_LEVEL): sensor.sensor_schema( + unit_of_measurement=UNIT_PERCENT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_BATTERY, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + cv.Optional(CONF_SIGNAL_STRENGTH): sensor.sensor_schema( + unit_of_measurement=UNIT_DECIBEL_MILLIWATT, + accuracy_decimals=0, + device_class=DEVICE_CLASS_SIGNAL_STRENGTH, + state_class=STATE_CLASS_MEASUREMENT, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), + } + ) + .extend(esp32_ble_tracker.ESP_BLE_DEVICE_SCHEMA) + .extend(cv.COMPONENT_SCHEMA) +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await esp32_ble_tracker.register_ble_device(var, config) + + cg.add(var.set_address(config[CONF_MAC_ADDRESS].as_hex)) + + if temperature_config := config.get(CONF_TEMPERATURE): + sens = await sensor.new_sensor(temperature_config) + cg.add(var.set_temperature(sens)) + if external_temperature_config := config.get(CONF_EXTERNAL_TEMPERATURE): + sens = await sensor.new_sensor(external_temperature_config) + cg.add(var.set_external_temperature(sens)) + if humidity_config := config.get(CONF_HUMIDITY): + sens = await sensor.new_sensor(humidity_config) + cg.add(var.set_humidity(sens)) + if battery_level_config := config.get(CONF_BATTERY_LEVEL): + sens = await sensor.new_sensor(battery_level_config) + cg.add(var.set_battery_level(sens)) + if signal_strength_config := config.get(CONF_SIGNAL_STRENGTH): + sens = await sensor.new_sensor(signal_strength_config) + cg.add(var.set_signal_strength(sens)) diff --git a/esphome/components/thermopro_ble/thermopro_ble.cpp b/esphome/components/thermopro_ble/thermopro_ble.cpp new file mode 100644 index 0000000000..4b43c9b39e --- /dev/null +++ b/esphome/components/thermopro_ble/thermopro_ble.cpp @@ -0,0 +1,204 @@ +#include "thermopro_ble.h" +#include "esphome/core/log.h" + +#ifdef USE_ESP32 + +namespace esphome::thermopro_ble { + +// this size must be large enough to hold the largest data frame +// of all supported devices +static constexpr std::size_t MAX_DATA_SIZE = 24; + +struct DeviceParserMapping { + const char *prefix; + DeviceParser parser; +}; + +static float tp96_battery(uint16_t voltage); + +static optional parse_tp972(const uint8_t *data, std::size_t data_size); +static optional parse_tp96(const uint8_t *data, std::size_t data_size); +static optional parse_tp3(const uint8_t *data, std::size_t data_size); + +static const char *const TAG = "thermopro_ble"; + +static const struct DeviceParserMapping DEVICE_PARSER_MAP[] = { + {"TP972", parse_tp972}, {"TP970", parse_tp96}, {"TP96", parse_tp96}, {"TP3", parse_tp3}}; + +void ThermoProBLE::dump_config() { + ESP_LOGCONFIG(TAG, "ThermoPro BLE"); + LOG_SENSOR(" ", "Temperature", this->temperature_); + LOG_SENSOR(" ", "External temperature", this->external_temperature_); + LOG_SENSOR(" ", "Humidity", this->humidity_); + LOG_SENSOR(" ", "Battery Level", this->battery_level_); +} + +bool ThermoProBLE::parse_device(const esp32_ble_tracker::ESPBTDevice &device) { + // check for matching mac address + if (device.address_uint64() != this->address_) { + ESP_LOGVV(TAG, "parse_device(): unknown MAC address."); + return false; + } + + // check for valid device type + update_device_type_(device.get_name()); + if (this->device_parser_ == nullptr) { + ESP_LOGVV(TAG, "parse_device(): invalid device type."); + return false; + } + + ESP_LOGVV(TAG, "parse_device(): MAC address %s found.", device.address_str().c_str()); + + // publish signal strength + float signal_strength = float(device.get_rssi()); + if (this->signal_strength_ != nullptr) + this->signal_strength_->publish_state(signal_strength); + + bool success = false; + for (auto &service_data : device.get_manufacturer_datas()) { + // check maximum data size + std::size_t data_size = service_data.data.size() + 2; + if (data_size > MAX_DATA_SIZE) { + ESP_LOGVV(TAG, "parse_device(): maximum data size exceeded!"); + continue; + } + + // reconstruct whole record from 2 byte uuid and data + esp_bt_uuid_t uuid = service_data.uuid.get_uuid(); + uint8_t data[MAX_DATA_SIZE] = {static_cast(uuid.uuid.uuid16), static_cast(uuid.uuid.uuid16 >> 8)}; + std::copy(service_data.data.begin(), service_data.data.end(), std::begin(data) + 2); + + // dispatch data to parser + optional result = this->device_parser_(data, data_size); + if (!result.has_value()) { + continue; + } + + // publish sensor values + if (result->temperature.has_value() && this->temperature_ != nullptr) + this->temperature_->publish_state(*result->temperature); + if (result->external_temperature.has_value() && this->external_temperature_ != nullptr) + this->external_temperature_->publish_state(*result->external_temperature); + if (result->humidity.has_value() && this->humidity_ != nullptr) + this->humidity_->publish_state(*result->humidity); + if (result->battery_level.has_value() && this->battery_level_ != nullptr) + this->battery_level_->publish_state(*result->battery_level); + + success = true; + } + + return success; +} + +void ThermoProBLE::update_device_type_(const std::string &device_name) { + // check for changed device name (should only happen on initial call) + if (this->device_name_ == device_name) { + return; + } + + // remember device name + this->device_name_ = device_name; + + // try to find device parser + for (const auto &mapping : DEVICE_PARSER_MAP) { + if (device_name.starts_with(mapping.prefix)) { + this->device_parser_ = mapping.parser; + return; + } + } + + // device type unknown + this->device_parser_ = nullptr; + ESP_LOGVV(TAG, "update_device_type_(): unknown device type %s.", device_name.c_str()); +} + +static inline uint16_t read_uint16(const uint8_t *data, std::size_t offset) { + return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8); +} + +static inline int16_t read_int16(const uint8_t *data, std::size_t offset) { + return static_cast(read_uint16(data, offset)); +} + +static inline uint32_t read_uint32(const uint8_t *data, std::size_t offset) { + return static_cast(data[offset + 0]) | (static_cast(data[offset + 1]) << 8) | + (static_cast(data[offset + 2]) << 16) | (static_cast(data[offset + 3]) << 24); +} + +// Battery calculation used with permission from: +// https://github.com/Bluetooth-Devices/thermopro-ble/blob/main/src/thermopro_ble/parser.py +// +// TP96x battery values appear to be a voltage reading, probably in millivolts. +// This means that calculating battery life from it is a non-linear function. +// Examining the curve, it looked fairly close to a curve from the tanh function. +// So, I created a script to use Tensorflow to optimize an equation in the format +// A*tanh(B*x+C)+D +// Where A,B,C,D are the variables to optimize for. This yielded the below function +static float tp96_battery(uint16_t voltage) { + float level = 52.317286f * tanh(static_cast(voltage) / 273.624277936f - 8.76485439394f) + 51.06925f; + return std::max(0.0f, std::min(level, 100.0f)); +} + +static optional parse_tp972(const uint8_t *data, std::size_t data_size) { + if (data_size != 23) { + ESP_LOGVV(TAG, "parse_tp972(): payload has wrong size of %d (!= 23)!", data_size); + return {}; + } + + ParseResult result; + + // ambient temperature, 2 bytes, 16-bit unsigned integer, -54 °C offset + result.external_temperature = static_cast(read_uint16(data, 1)) - 54.0f; + + // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage) + result.battery_level = tp96_battery(read_uint16(data, 3)); + + // internal temperature, 4 bytes, float, -54 °C offset + result.temperature = static_cast(read_uint32(data, 9)) - 54.0f; + + return result; +} + +static optional parse_tp96(const uint8_t *data, std::size_t data_size) { + if (data_size != 7) { + ESP_LOGVV(TAG, "parse_tp96(): payload has wrong size of %d (!= 7)!", data_size); + return {}; + } + + ParseResult result; + + // internal temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset + result.temperature = static_cast(read_uint16(data, 1)) - 30.0f; + + // battery level, 2 bytes, 16-bit unsigned integer, voltage (convert to percentage) + result.battery_level = tp96_battery(read_uint16(data, 3)); + + // ambient temperature, 2 bytes, 16-bit unsigned integer, -30 °C offset + result.external_temperature = static_cast(read_uint16(data, 5)) - 30.0f; + + return result; +} + +static optional parse_tp3(const uint8_t *data, std::size_t data_size) { + if (data_size < 6) { + ESP_LOGVV(TAG, "parse_tp3(): payload has wrong size of %d (< 6)!", data_size); + return {}; + } + + ParseResult result; + + // temperature, 2 bytes, 16-bit signed integer, 0.1 °C + result.temperature = static_cast(read_int16(data, 1)) * 0.1f; + + // humidity, 1 byte, 8-bit unsigned integer, 1.0 % + result.humidity = static_cast(data[3]); + + // battery level, 2 bits (0-2) + result.battery_level = static_cast(data[4] & 0x3) * 50.0; + + return result; +} + +} // namespace esphome::thermopro_ble + +#endif diff --git a/esphome/components/thermopro_ble/thermopro_ble.h b/esphome/components/thermopro_ble/thermopro_ble.h new file mode 100644 index 0000000000..38bed82102 --- /dev/null +++ b/esphome/components/thermopro_ble/thermopro_ble.h @@ -0,0 +1,49 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/esp32_ble_tracker/esp32_ble_tracker.h" + +#ifdef USE_ESP32 + +namespace esphome::thermopro_ble { + +struct ParseResult { + optional temperature; + optional external_temperature; + optional humidity; + optional battery_level; +}; + +using DeviceParser = optional (*)(const uint8_t *data, std::size_t data_size); + +class ThermoProBLE : public Component, public esp32_ble_tracker::ESPBTDeviceListener { + public: + void set_address(uint64_t address) { this->address_ = address; }; + + bool parse_device(const esp32_ble_tracker::ESPBTDevice &device) override; + void dump_config() override; + void set_signal_strength(sensor::Sensor *signal_strength) { this->signal_strength_ = signal_strength; } + void set_temperature(sensor::Sensor *temperature) { this->temperature_ = temperature; } + void set_external_temperature(sensor::Sensor *external_temperature) { + this->external_temperature_ = external_temperature; + } + void set_humidity(sensor::Sensor *humidity) { this->humidity_ = humidity; } + void set_battery_level(sensor::Sensor *battery_level) { this->battery_level_ = battery_level; } + + protected: + uint64_t address_; + std::string device_name_; + DeviceParser device_parser_{nullptr}; + sensor::Sensor *signal_strength_{nullptr}; + sensor::Sensor *temperature_{nullptr}; + sensor::Sensor *external_temperature_{nullptr}; + sensor::Sensor *humidity_{nullptr}; + sensor::Sensor *battery_level_{nullptr}; + + void update_device_type_(const std::string &device_name); +}; + +} // namespace esphome::thermopro_ble + +#endif diff --git a/esphome/components/thermostat/climate.py b/esphome/components/thermostat/climate.py index a928d208f3..a3c155aac0 100644 --- a/esphome/components/thermostat/climate.py +++ b/esphome/components/thermostat/climate.py @@ -945,6 +945,10 @@ async def to_code(config): cg.add(var.set_humidity_hysteresis(config[CONF_HUMIDITY_HYSTERESIS])) if CONF_PRESET in config: + # Separate standard and custom presets, and build preset config variables + standard_presets: list[tuple[cg.MockObj, cg.MockObj]] = [] + custom_presets: list[tuple[str, cg.MockObj]] = [] + for preset_config in config[CONF_PRESET]: name = preset_config[CONF_NAME] standard_preset = None @@ -987,9 +991,39 @@ async def to_code(config): ) if standard_preset is not None: - cg.add(var.set_preset_config(standard_preset, preset_target_variable)) + standard_presets.append((standard_preset, preset_target_variable)) else: - cg.add(var.set_custom_preset_config(name, preset_target_variable)) + custom_presets.append((name, preset_target_variable)) + + # Build initializer list for standard presets + if standard_presets: + cg.add( + var.set_preset_config( + [ + cg.StructInitializer( + thermostat_ns.struct("ThermostatPresetEntry"), + ("preset", preset), + ("config", preset_var), + ) + for preset, preset_var in standard_presets + ] + ) + ) + + # Build initializer list for custom presets + if custom_presets: + cg.add( + var.set_custom_preset_config( + [ + cg.StructInitializer( + thermostat_ns.struct("ThermostatCustomPresetEntry"), + ("name", cg.RawExpression(f'"{name}"')), + ("config", preset_var), + ) + for name, preset_var in custom_presets + ] + ) + ) if CONF_DEFAULT_PRESET in config: default_preset_name = config[CONF_DEFAULT_PRESET] diff --git a/esphome/components/thermostat/thermostat_climate.cpp b/esphome/components/thermostat/thermostat_climate.cpp index d533ef93ec..e79eed4055 100644 --- a/esphome/components/thermostat/thermostat_climate.cpp +++ b/esphome/components/thermostat/thermostat_climate.cpp @@ -53,8 +53,8 @@ void ThermostatClimate::setup() { if (use_default_preset) { if (this->default_preset_ != climate::ClimatePreset::CLIMATE_PRESET_NONE) { this->change_preset_(this->default_preset_); - } else if (!this->default_custom_preset_.empty()) { - this->change_custom_preset_(this->default_custom_preset_.c_str()); + } else if (this->default_custom_preset_ != nullptr) { + this->change_custom_preset_(this->default_custom_preset_); } } @@ -319,16 +319,16 @@ climate::ClimateTraits ThermostatClimate::traits() { if (this->supports_swing_mode_vertical_) traits.add_supported_swing_mode(climate::CLIMATE_SWING_VERTICAL); - for (auto &it : this->preset_config_) { - traits.add_supported_preset(it.first); + for (const auto &entry : this->preset_config_) { + traits.add_supported_preset(entry.preset); } - // Extract custom preset names from the custom_preset_config_ map + // Extract custom preset names from the custom_preset_config_ vector if (!this->custom_preset_config_.empty()) { std::vector custom_preset_names; custom_preset_names.reserve(this->custom_preset_config_.size()); - for (const auto &it : this->custom_preset_config_) { - custom_preset_names.push_back(it.first.c_str()); + for (const auto &entry : this->custom_preset_config_) { + custom_preset_names.push_back(entry.name); } traits.set_supported_custom_presets(custom_preset_names); } @@ -654,7 +654,7 @@ void ThermostatClimate::trigger_supplemental_action_() { void ThermostatClimate::switch_to_humidity_control_action_(HumidificationAction action) { // setup_complete_ helps us ensure an action is called immediately after boot - if ((action == this->humidification_action_) && this->setup_complete_) { + if ((action == this->humidification_action) && this->setup_complete_) { // already in target mode return; } @@ -683,7 +683,7 @@ void ThermostatClimate::switch_to_humidity_control_action_(HumidificationAction this->prev_humidity_control_trigger_->stop_action(); this->prev_humidity_control_trigger_ = nullptr; } - this->humidification_action_ = action; + this->humidification_action = action; this->prev_humidity_control_trigger_ = trig; if (trig != nullptr) { trig->trigger(); @@ -1114,7 +1114,7 @@ bool ThermostatClimate::dehumidification_required_() { } // if we get here, the current humidity is between target + hysteresis and target - hysteresis, // so the action should not change - return this->humidification_action_ == THERMOSTAT_HUMIDITY_CONTROL_ACTION_DEHUMIDIFY; + return this->humidification_action == THERMOSTAT_HUMIDITY_CONTROL_ACTION_DEHUMIDIFY; } bool ThermostatClimate::humidification_required_() { @@ -1127,7 +1127,7 @@ bool ThermostatClimate::humidification_required_() { } // if we get here, the current humidity is between target - hysteresis and target + hysteresis, // so the action should not change - return this->humidification_action_ == THERMOSTAT_HUMIDITY_CONTROL_ACTION_HUMIDIFY; + return this->humidification_action == THERMOSTAT_HUMIDITY_CONTROL_ACTION_HUMIDIFY; } void ThermostatClimate::dump_preset_config_(const char *preset_name, const ThermostatClimateTargetTempConfig &config) { @@ -1154,12 +1154,18 @@ void ThermostatClimate::dump_preset_config_(const char *preset_name, const Therm } void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { - auto config = this->preset_config_.find(preset); + // Linear search through preset configurations + const ThermostatClimateTargetTempConfig *config = nullptr; + for (const auto &entry : this->preset_config_) { + if (entry.preset == preset) { + config = &entry.config; + break; + } + } - if (config != this->preset_config_.end()) { + if (config != nullptr) { ESP_LOGV(TAG, "Preset %s requested", LOG_STR_ARG(climate::climate_preset_to_string(preset))); - if (this->change_preset_internal_(config->second) || (!this->preset.has_value()) || - this->preset.value() != preset) { + if (this->change_preset_internal_(*config) || (!this->preset.has_value()) || this->preset.value() != preset) { // Fire any preset changed trigger if defined Trigger<> *trig = this->preset_change_trigger_; this->set_preset_(preset); @@ -1178,11 +1184,18 @@ void ThermostatClimate::change_preset_(climate::ClimatePreset preset) { } void ThermostatClimate::change_custom_preset_(const char *custom_preset) { - auto config = this->custom_preset_config_.find(custom_preset); + // Linear search through custom preset configurations + const ThermostatClimateTargetTempConfig *config = nullptr; + for (const auto &entry : this->custom_preset_config_) { + if (strcmp(entry.name, custom_preset) == 0) { + config = &entry.config; + break; + } + } - if (config != this->custom_preset_config_.end()) { + if (config != nullptr) { ESP_LOGV(TAG, "Custom preset %s requested", custom_preset); - if (this->change_preset_internal_(config->second) || !this->has_custom_preset() || + if (this->change_preset_internal_(*config) || !this->has_custom_preset() || strcmp(this->get_custom_preset(), custom_preset) != 0) { // Fire any preset changed trigger if defined Trigger<> *trig = this->preset_change_trigger_; @@ -1247,14 +1260,12 @@ bool ThermostatClimate::change_preset_internal_(const ThermostatClimateTargetTem return something_changed; } -void ThermostatClimate::set_preset_config(climate::ClimatePreset preset, - const ThermostatClimateTargetTempConfig &config) { - this->preset_config_[preset] = config; +void ThermostatClimate::set_preset_config(std::initializer_list presets) { + this->preset_config_ = presets; } -void ThermostatClimate::set_custom_preset_config(const std::string &name, - const ThermostatClimateTargetTempConfig &config) { - this->custom_preset_config_[name] = config; +void ThermostatClimate::set_custom_preset_config(std::initializer_list presets) { + this->custom_preset_config_ = presets; } ThermostatClimate::ThermostatClimate() @@ -1293,8 +1304,16 @@ ThermostatClimate::ThermostatClimate() humidity_control_humidify_action_trigger_(new Trigger<>()), humidity_control_off_action_trigger_(new Trigger<>()) {} -void ThermostatClimate::set_default_preset(const std::string &custom_preset) { - this->default_custom_preset_ = custom_preset; +void ThermostatClimate::set_default_preset(const char *custom_preset) { + // Find the preset in custom_preset_config_ and store pointer from there + for (const auto &entry : this->custom_preset_config_) { + if (strcmp(entry.name, custom_preset) == 0) { + this->default_custom_preset_ = entry.name; + return; + } + } + // If not found, it will be caught during validation + this->default_custom_preset_ = nullptr; } void ThermostatClimate::set_default_preset(climate::ClimatePreset preset) { this->default_preset_ = preset; } @@ -1605,19 +1624,22 @@ void ThermostatClimate::dump_config() { if (!this->preset_config_.empty()) { ESP_LOGCONFIG(TAG, " Supported PRESETS:"); - for (auto &it : this->preset_config_) { - const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(it.first)); - ESP_LOGCONFIG(TAG, " %s:%s", preset_name, it.first == this->default_preset_ ? " (default)" : ""); - this->dump_preset_config_(preset_name, it.second); + for (const auto &entry : this->preset_config_) { + const auto *preset_name = LOG_STR_ARG(climate::climate_preset_to_string(entry.preset)); + ESP_LOGCONFIG(TAG, " %s:%s", preset_name, entry.preset == this->default_preset_ ? " (default)" : ""); + this->dump_preset_config_(preset_name, entry.config); } } if (!this->custom_preset_config_.empty()) { ESP_LOGCONFIG(TAG, " Supported CUSTOM PRESETS:"); - for (auto &it : this->custom_preset_config_) { - const auto *preset_name = it.first.c_str(); - ESP_LOGCONFIG(TAG, " %s:%s", preset_name, it.first == this->default_custom_preset_ ? " (default)" : ""); - this->dump_preset_config_(preset_name, it.second); + for (const auto &entry : this->custom_preset_config_) { + const auto *preset_name = entry.name; + ESP_LOGCONFIG(TAG, " %s:%s", preset_name, + (this->default_custom_preset_ != nullptr && strcmp(entry.name, this->default_custom_preset_) == 0) + ? " (default)" + : ""); + this->dump_preset_config_(preset_name, entry.config); } } } diff --git a/esphome/components/thermostat/thermostat_climate.h b/esphome/components/thermostat/thermostat_climate.h index c9795d9666..69d2307b1c 100644 --- a/esphome/components/thermostat/thermostat_climate.h +++ b/esphome/components/thermostat/thermostat_climate.h @@ -3,12 +3,12 @@ #include "esphome/core/automation.h" #include "esphome/core/component.h" #include "esphome/core/hal.h" +#include "esphome/core/helpers.h" #include "esphome/components/climate/climate.h" #include "esphome/components/sensor/sensor.h" #include #include -#include namespace esphome { namespace thermostat { @@ -72,14 +72,29 @@ struct ThermostatClimateTargetTempConfig { optional mode_{}; }; +/// Entry for standard preset lookup +struct ThermostatPresetEntry { + climate::ClimatePreset preset; + ThermostatClimateTargetTempConfig config; +}; + +/// Entry for custom preset lookup +struct ThermostatCustomPresetEntry { + const char *name; + ThermostatClimateTargetTempConfig config; +}; + class ThermostatClimate : public climate::Climate, public Component { public: + using PresetEntry = ThermostatPresetEntry; + using CustomPresetEntry = ThermostatCustomPresetEntry; + ThermostatClimate(); void setup() override; void dump_config() override; void loop() override; - void set_default_preset(const std::string &custom_preset); + void set_default_preset(const char *custom_preset); void set_default_preset(climate::ClimatePreset preset); void set_on_boot_restore_from(OnBootRestoreFrom on_boot_restore_from); void set_set_point_minimum_differential(float differential); @@ -131,8 +146,8 @@ class ThermostatClimate : public climate::Climate, public Component { void set_supports_humidification(bool supports_humidification); void set_supports_two_points(bool supports_two_points); - void set_preset_config(climate::ClimatePreset preset, const ThermostatClimateTargetTempConfig &config); - void set_custom_preset_config(const std::string &name, const ThermostatClimateTargetTempConfig &config); + void set_preset_config(std::initializer_list presets); + void set_custom_preset_config(std::initializer_list presets); Trigger<> *get_cool_action_trigger() const; Trigger<> *get_supplemental_cool_action_trigger() const; @@ -192,6 +207,9 @@ class ThermostatClimate : public climate::Climate, public Component { void validate_target_temperature_high(); void validate_target_humidity(); + /// The current humidification action + HumidificationAction humidification_action{THERMOSTAT_HUMIDITY_CONTROL_ACTION_NONE}; + protected: /// Override control to change settings of the climate device. void control(const climate::ClimateCall &call) override; @@ -286,9 +304,6 @@ class ThermostatClimate : public climate::Climate, public Component { /// The current supplemental action climate::ClimateAction supplemental_action_{climate::CLIMATE_ACTION_OFF}; - /// The current humidification action - HumidificationAction humidification_action_{THERMOSTAT_HUMIDITY_CONTROL_ACTION_NONE}; - /// Default standard preset to use on start up climate::ClimatePreset default_preset_{}; @@ -516,9 +531,6 @@ class ThermostatClimate : public climate::Climate, public Component { Trigger<> *prev_swing_mode_trigger_{nullptr}; Trigger<> *prev_humidity_control_trigger_{nullptr}; - /// Default custom preset to use on start up - std::string default_custom_preset_{}; - /// Climate action timers std::array timer_{ ThermostatClimateTimer(false, 0, 0, std::bind(&ThermostatClimate::cooling_max_run_time_timer_callback_, this)), @@ -534,9 +546,12 @@ class ThermostatClimate : public climate::Climate, public Component { }; /// The set of standard preset configurations this thermostat supports (Eg. AWAY, ECO, etc) - std::map preset_config_{}; + FixedVector preset_config_{}; /// The set of custom preset configurations this thermostat supports (eg. "My Custom Preset") - std::map custom_preset_config_{}; + FixedVector custom_preset_config_{}; + /// Default custom preset to use on start up (pointer to entry in custom_preset_config_) + private: + const char *default_custom_preset_{nullptr}; }; } // namespace thermostat diff --git a/esphome/components/time/real_time_clock.cpp b/esphome/components/time/real_time_clock.cpp index 42c564659f..175cee0c1f 100644 --- a/esphome/components/time/real_time_clock.cpp +++ b/esphome/components/time/real_time_clock.cpp @@ -23,6 +23,13 @@ namespace time { static const char *const TAG = "time"; RealTimeClock::RealTimeClock() = default; + +void RealTimeClock::dump_config() { +#ifdef USE_TIME_TIMEZONE + ESP_LOGCONFIG(TAG, "Timezone: '%s'", this->timezone_.c_str()); +#endif +} + void RealTimeClock::synchronize_epoch_(uint32_t epoch) { ESP_LOGVV(TAG, "Got epoch %" PRIu32, epoch); // Update UTC epoch time. diff --git a/esphome/components/time/real_time_clock.h b/esphome/components/time/real_time_clock.h index bbcecaa628..2f17bd86d6 100644 --- a/esphome/components/time/real_time_clock.h +++ b/esphome/components/time/real_time_clock.h @@ -52,6 +52,8 @@ class RealTimeClock : public PollingComponent { this->time_sync_callback_.add(std::move(callback)); }; + void dump_config() override; + protected: /// Report a unix epoch as current time. void synchronize_epoch_(uint32_t epoch); diff --git a/esphome/components/uart/__init__.py b/esphome/components/uart/__init__.py index cbc11d0db0..6494aaa286 100644 --- a/esphome/components/uart/__init__.py +++ b/esphome/components/uart/__init__.py @@ -1,3 +1,5 @@ +from dataclasses import dataclass +from logging import getLogger import math import re @@ -31,11 +33,26 @@ from esphome.const import ( PLATFORM_HOST, PlatformFramework, ) -from esphome.core import CORE, ID +from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority import esphome.final_validate as fv from esphome.yaml_util import make_data_base +_LOGGER = getLogger(__name__) + CODEOWNERS = ["@esphome/core"] +DOMAIN = "uart" + + +def AUTO_LOAD() -> list[str]: + """Ideally, we would only auto-load socket only when wake_loop_on_rx is requested; + however, AUTO_LOAD is examined before wake_loop_on_rx is set, so instead, since ESP32 + always uses socket select support in the main app, we'll just ensure it's loaded here. + """ + if CORE.is_esp32: + return ["socket"] + return [] + + uart_ns = cg.esphome_ns.namespace("uart") UARTComponent = uart_ns.class_("UARTComponent") @@ -49,6 +66,7 @@ LibreTinyUARTComponent = uart_ns.class_( ) HostUartComponent = uart_ns.class_("HostUartComponent", UARTComponent, cg.Component) + NATIVE_UART_CLASSES = ( str(IDFUARTComponent), str(ESP8266UartComponent), @@ -97,6 +115,38 @@ MULTI_CONF = True MULTI_CONF_NO_DEFAULT = True +@dataclass +class UARTData: + """State data for UART component configuration generation.""" + + wake_loop_on_rx: bool = False + + +def _get_data() -> UARTData: + """Get UART component data from CORE.data.""" + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = UARTData() + return CORE.data[DOMAIN] + + +def request_wake_loop_on_rx() -> None: + """Request that the UART wake the main loop when data is received. + + Components that need low-latency notification of incoming UART data + should call this function during their code generation. + This enables the RX event task which wakes the main loop when data arrives. + """ + data = _get_data() + if not data.wake_loop_on_rx: + data.wake_loop_on_rx = True + + # UART RX event task uses wake_loop_threadsafe() to notify the main loop + # Automatically enable the socket wake infrastructure when RX wake is requested + from esphome.components import socket + + socket.require_wake_loop_threadsafe() + + def validate_raw_data(value): if isinstance(value, str): return value.encode("utf-8") @@ -130,6 +180,21 @@ def validate_host_config(config): return config +def validate_rx_buffer_size(config): + if CORE.is_esp32: + # ESP32 UART hardware FIFO is 128 bytes (LP UART is 16 bytes, but we use 128 as safe minimum) + # rx_buffer_size must be greater than the hardware FIFO length + min_buffer_size = 128 + if config[CONF_RX_BUFFER_SIZE] <= min_buffer_size: + _LOGGER.warning( + "UART rx_buffer_size (%d bytes) is too small and must be greater than the hardware " + "FIFO size (%d bytes). The buffer size will be automatically adjusted at runtime.", + config[CONF_RX_BUFFER_SIZE], + min_buffer_size, + ) + return config + + def _uart_declare_type(value): if CORE.is_esp8266: return cv.declare_id(ESP8266UartComponent)(value) @@ -247,6 +312,7 @@ CONFIG_SCHEMA = cv.All( ).extend(cv.COMPONENT_SCHEMA), cv.has_at_least_one_key(CONF_TX_PIN, CONF_RX_PIN, CONF_PORT), validate_host_config, + validate_rx_buffer_size, ) @@ -316,6 +382,8 @@ async def to_code(config): if CONF_DEBUG in config: await debug_to_code(config[CONF_DEBUG], var) + CORE.add_job(final_step) + # A schema to use for all UART devices, all UART integrations must extend this! UART_DEVICE_SCHEMA = cv.Schema( @@ -453,6 +521,13 @@ async def uart_write_to_code(config, action_id, template_arg, args): return var +@coroutine_with_priority(CoroPriority.FINAL) +async def final_step(): + """Final code generation step to configure optional UART features.""" + if _get_data().wake_loop_on_rx: + cg.add_define("USE_UART_WAKE_LOOP_ON_RX") + + FILTER_SOURCE_FILES = filter_source_files_from_platform( { "uart_component_esp_idf.cpp": { diff --git a/esphome/components/uart/automation.h b/esphome/components/uart/automation.h index c2eb308eb8..c99caac97b 100644 --- a/esphome/components/uart/automation.h +++ b/esphome/components/uart/automation.h @@ -5,8 +5,7 @@ #include -namespace esphome { -namespace uart { +namespace esphome::uart { template class UARTWriteAction : public Action, public Parented { public: @@ -41,5 +40,4 @@ template class UARTWriteAction : public Action, public Pa } code_; }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/button/uart_button.cpp b/esphome/components/uart/button/uart_button.cpp index dd228b9bb7..809ceaabb0 100644 --- a/esphome/components/uart/button/uart_button.cpp +++ b/esphome/components/uart/button/uart_button.cpp @@ -1,8 +1,7 @@ #include "uart_button.h" #include "esphome/core/log.h" -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.button"; @@ -13,5 +12,4 @@ void UARTButton::press_action() { void UARTButton::dump_config() { LOG_BUTTON("", "UART Button", this); } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/button/uart_button.h b/esphome/components/uart/button/uart_button.h index 8c7d762a05..2b530d3c4b 100644 --- a/esphome/components/uart/button/uart_button.h +++ b/esphome/components/uart/button/uart_button.h @@ -6,8 +6,7 @@ #include -namespace esphome { -namespace uart { +namespace esphome::uart { class UARTButton : public button::Button, public UARTDevice, public Component { public: @@ -21,5 +20,4 @@ class UARTButton : public button::Button, public UARTDevice, public Component { std::vector data_; }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/packet_transport/uart_transport.cpp b/esphome/components/uart/packet_transport/uart_transport.cpp index 423b657532..4a9aa0fe47 100644 --- a/esphome/components/uart/packet_transport/uart_transport.cpp +++ b/esphome/components/uart/packet_transport/uart_transport.cpp @@ -2,8 +2,7 @@ #include "esphome/core/application.h" #include "uart_transport.h" -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart_transport"; @@ -84,5 +83,5 @@ void UARTTransport::send_packet(const std::vector &buf) const { this->write_byte_(crc >> 8); this->parent_->write_byte(FLAG_BYTE); } -} // namespace uart -} // namespace esphome + +} // namespace esphome::uart diff --git a/esphome/components/uart/packet_transport/uart_transport.h b/esphome/components/uart/packet_transport/uart_transport.h index f1431e948c..e84bed95e6 100644 --- a/esphome/components/uart/packet_transport/uart_transport.h +++ b/esphome/components/uart/packet_transport/uart_transport.h @@ -5,8 +5,7 @@ #include #include "../uart.h" -namespace esphome { -namespace uart { +namespace esphome::uart { /** * A transport protocol for sending and receiving packets over a UART connection. @@ -37,5 +36,4 @@ class UARTTransport : public packet_transport::PacketTransport, public UARTDevic bool rx_control_{}; }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/switch/uart_switch.cpp b/esphome/components/uart/switch/uart_switch.cpp index 4f5ff9fc99..642bd19772 100644 --- a/esphome/components/uart/switch/uart_switch.cpp +++ b/esphome/components/uart/switch/uart_switch.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include "esphome/core/application.h" -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.switch"; @@ -58,5 +57,4 @@ void UARTSwitch::dump_config() { } } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/switch/uart_switch.h b/esphome/components/uart/switch/uart_switch.h index 909307d57e..5730fc9b4b 100644 --- a/esphome/components/uart/switch/uart_switch.h +++ b/esphome/components/uart/switch/uart_switch.h @@ -7,8 +7,7 @@ #include #include -namespace esphome { -namespace uart { +namespace esphome::uart { class UARTSwitch : public switch_::Switch, public UARTDevice, public Component { public: @@ -33,5 +32,4 @@ class UARTSwitch : public switch_::Switch, public UARTDevice, public Component { uint32_t last_transmission_; }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/uart.cpp b/esphome/components/uart/uart.cpp index b18454bf9d..6cfd6537a5 100644 --- a/esphome/components/uart/uart.cpp +++ b/esphome/components/uart/uart.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart"; @@ -43,5 +42,4 @@ const LogString *parity_to_str(UARTParityOptions parity) { } } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/uart.h b/esphome/components/uart/uart.h index e2912db122..72c282f1c4 100644 --- a/esphome/components/uart/uart.h +++ b/esphome/components/uart/uart.h @@ -6,8 +6,7 @@ #include "esphome/core/log.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class UARTDevice { public: @@ -74,5 +73,4 @@ class UARTDevice { UARTComponent *parent_{nullptr}; }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/uart_component.cpp b/esphome/components/uart/uart_component.cpp index 8f670275d4..30fc208fc9 100644 --- a/esphome/components/uart/uart_component.cpp +++ b/esphome/components/uart/uart_component.cpp @@ -1,7 +1,6 @@ #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart"; @@ -28,5 +27,4 @@ void UARTComponent::set_rx_full_threshold_ms(uint8_t time) { this->set_rx_full_threshold(val); } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/uart_component.h b/esphome/components/uart/uart_component.h index 452688b3e9..fd528e228f 100644 --- a/esphome/components/uart/uart_component.h +++ b/esphome/components/uart/uart_component.h @@ -11,8 +11,7 @@ #include "esphome/core/automation.h" #endif -namespace esphome { -namespace uart { +namespace esphome::uart { enum UARTParityOptions { UART_CONFIG_PARITY_NONE, @@ -199,5 +198,4 @@ class UARTComponent { #endif }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart diff --git a/esphome/components/uart/uart_component_esp8266.cpp b/esphome/components/uart/uart_component_esp8266.cpp index 7a453dbb50..c78daa7462 100644 --- a/esphome/components/uart/uart_component_esp8266.cpp +++ b/esphome/components/uart/uart_component_esp8266.cpp @@ -9,8 +9,7 @@ #include "esphome/components/logger/logger.h" #endif -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.arduino_esp8266"; bool ESP8266UartComponent::serial0_in_use = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -56,11 +55,19 @@ uint32_t ESP8266UartComponent::get_config() { } void ESP8266UartComponent::setup() { - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } // Use Arduino HardwareSerial UARTs if all used pins match the ones @@ -323,6 +330,5 @@ int ESP8266SoftwareSerial::available() { return avail; } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart #endif // USE_ESP8266 diff --git a/esphome/components/uart/uart_component_esp8266.h b/esphome/components/uart/uart_component_esp8266.h index 749dd4c61e..e33dd00644 100644 --- a/esphome/components/uart/uart_component_esp8266.h +++ b/esphome/components/uart/uart_component_esp8266.h @@ -9,8 +9,7 @@ #include "esphome/core/log.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class ESP8266SoftwareSerial { public: @@ -88,7 +87,5 @@ class ESP8266UartComponent : public UARTComponent, public Component { static bool serial0_in_use; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) }; -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_ESP8266 diff --git a/esphome/components/uart/uart_component_esp_idf.cpp b/esphome/components/uart/uart_component_esp_idf.cpp index 73813d2d5b..b438e4f7a6 100644 --- a/esphome/components/uart/uart_component_esp_idf.cpp +++ b/esphome/components/uart/uart_component_esp_idf.cpp @@ -14,8 +14,8 @@ #include "esphome/components/logger/logger.h" #endif -namespace esphome { -namespace uart { +namespace esphome::uart { + static const char *const TAG = "uart.idf"; uart_config_t IDFUARTComponent::get_config_() { @@ -91,6 +91,16 @@ void IDFUARTComponent::setup() { this->uart_num_ = static_cast(next_uart_num++); this->lock_ = xSemaphoreCreateMutex(); +#if (SOC_UART_LP_NUM >= 1) + size_t fifo_len = ((this->uart_num_ < SOC_UART_HP_NUM) ? SOC_UART_FIFO_LEN : SOC_LP_UART_FIFO_LEN); +#else + size_t fifo_len = SOC_UART_FIFO_LEN; +#endif + if (this->rx_buffer_size_ <= fifo_len) { + ESP_LOGW(TAG, "rx_buffer_size is too small, must be greater than %zu", fifo_len); + this->rx_buffer_size_ = fifo_len * 2; + } + xSemaphoreTake(this->lock_, portMAX_DELAY); this->load_settings(false); @@ -102,6 +112,12 @@ void IDFUARTComponent::load_settings(bool dump_config) { esp_err_t err; if (uart_is_driver_installed(this->uart_num_)) { +#ifdef USE_UART_WAKE_LOOP_ON_RX + if (this->rx_event_task_handle_ != nullptr) { + vTaskDelete(this->rx_event_task_handle_); + this->rx_event_task_handle_ = nullptr; + } +#endif err = uart_driver_delete(this->uart_num_); if (err != ESP_OK) { ESP_LOGW(TAG, "uart_driver_delete failed: %s", esp_err_to_name(err)); @@ -123,11 +139,19 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } int8_t tx = this->tx_pin_ != nullptr ? this->tx_pin_->get_pin() : -1; @@ -186,6 +210,11 @@ void IDFUARTComponent::load_settings(bool dump_config) { return; } +#ifdef USE_UART_WAKE_LOOP_ON_RX + // Start the RX event task to enable low-latency data notifications + this->start_rx_event_task_(); +#endif // USE_UART_WAKE_LOOP_ON_RX + if (dump_config) { ESP_LOGCONFIG(TAG, "Reloaded UART %u", this->uart_num_); this->dump_config(); @@ -208,7 +237,11 @@ void IDFUARTComponent::dump_config() { " Baud Rate: %" PRIu32 " baud\n" " Data Bits: %u\n" " Parity: %s\n" - " Stop bits: %u", + " Stop bits: %u" +#ifdef USE_UART_WAKE_LOOP_ON_RX + "\n Wake on data RX: ENABLED" +#endif + , this->baud_rate_, this->data_bits_, LOG_STR_ARG(parity_to_str(this->parity_)), this->stop_bits_); this->check_logger_conflict(); } @@ -237,8 +270,12 @@ void IDFUARTComponent::set_rx_timeout(size_t rx_timeout) { void IDFUARTComponent::write_array(const uint8_t *data, size_t len) { xSemaphoreTake(this->lock_, portMAX_DELAY); - uart_write_bytes(this->uart_num_, data, len); + int32_t write_len = uart_write_bytes(this->uart_num_, data, len); xSemaphoreGive(this->lock_); + if (write_len != (int32_t) len) { + ESP_LOGW(TAG, "uart_write_bytes failed: %d != %zu", write_len, len); + this->mark_failed(); + } #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(UART_DIRECTION_TX, data[i]); @@ -267,6 +304,7 @@ bool IDFUARTComponent::peek_byte(uint8_t *data) { bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { size_t length_to_read = len; + int32_t read_len = 0; if (!this->check_read_timeout_(len)) return false; xSemaphoreTake(this->lock_, portMAX_DELAY); @@ -277,25 +315,31 @@ bool IDFUARTComponent::read_array(uint8_t *data, size_t len) { this->has_peek_ = false; } if (length_to_read > 0) - uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS); + read_len = uart_read_bytes(this->uart_num_, data, length_to_read, 20 / portTICK_PERIOD_MS); xSemaphoreGive(this->lock_); #ifdef USE_UART_DEBUGGER for (size_t i = 0; i < len; i++) { this->debug_callback_.call(UART_DIRECTION_RX, data[i]); } #endif - return true; + return read_len == (int32_t) length_to_read; } int IDFUARTComponent::available() { - size_t available; + size_t available = 0; + esp_err_t err; xSemaphoreTake(this->lock_, portMAX_DELAY); - uart_get_buffered_data_len(this->uart_num_, &available); - if (this->has_peek_) - available++; + err = uart_get_buffered_data_len(this->uart_num_, &available); xSemaphoreGive(this->lock_); + if (err != ESP_OK) { + ESP_LOGW(TAG, "uart_get_buffered_data_len failed: %s", esp_err_to_name(err)); + this->mark_failed(); + } + if (this->has_peek_) { + available++; + } return available; } @@ -308,7 +352,58 @@ void IDFUARTComponent::flush() { void IDFUARTComponent::check_logger_conflict() {} -} // namespace uart -} // namespace esphome +#ifdef USE_UART_WAKE_LOOP_ON_RX +void IDFUARTComponent::start_rx_event_task_() { + // Create FreeRTOS task to monitor UART events + BaseType_t result = xTaskCreate(rx_event_task_func, // Task function + "uart_rx_evt", // Task name (max 16 chars) + 2240, // Stack size in bytes (~2.2KB); increase if needed for logging + this, // Task parameter (this pointer) + tskIDLE_PRIORITY + 1, // Priority (low, just above idle) + &this->rx_event_task_handle_ // Task handle + ); + if (result != pdPASS) { + ESP_LOGE(TAG, "Failed to create RX event task"); + return; + } + + ESP_LOGV(TAG, "RX event task started"); +} + +void IDFUARTComponent::rx_event_task_func(void *param) { + auto *self = static_cast(param); + uart_event_t event; + + ESP_LOGV(TAG, "RX event task running"); + + // Run forever - task lifecycle matches component lifecycle + while (true) { + // Wait for UART events (blocks efficiently) + if (xQueueReceive(self->uart_event_queue_, &event, portMAX_DELAY) == pdTRUE) { + switch (event.type) { + case UART_DATA: + // Data available in UART RX buffer - wake the main loop + ESP_LOGVV(TAG, "Data event: %d bytes", event.size); + App.wake_loop_threadsafe(); + break; + + case UART_FIFO_OVF: + case UART_BUFFER_FULL: + ESP_LOGW(TAG, "FIFO overflow or ring buffer full - clearing"); + uart_flush_input(self->uart_num_); + App.wake_loop_threadsafe(); + break; + + default: + // Ignore other event types + ESP_LOGVV(TAG, "Event type: %d", event.type); + break; + } + } + } +} +#endif // USE_UART_WAKE_LOOP_ON_RX + +} // namespace esphome::uart #endif // USE_ESP32 diff --git a/esphome/components/uart/uart_component_esp_idf.h b/esphome/components/uart/uart_component_esp_idf.h index a2ba2aa968..bd6d0c792e 100644 --- a/esphome/components/uart/uart_component_esp_idf.h +++ b/esphome/components/uart/uart_component_esp_idf.h @@ -6,8 +6,7 @@ #include "esphome/core/component.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class IDFUARTComponent : public UARTComponent, public Component { public: @@ -53,9 +52,15 @@ class IDFUARTComponent : public UARTComponent, public Component { bool has_peek_{false}; uint8_t peek_byte_; + +#ifdef USE_UART_WAKE_LOOP_ON_RX + // RX notification support + void start_rx_event_task_(); + static void rx_event_task_func(void *param); + + TaskHandle_t rx_event_task_handle_{nullptr}; +#endif // USE_UART_WAKE_LOOP_ON_RX }; -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_ESP32 diff --git a/esphome/components/uart/uart_component_host.cpp b/esphome/components/uart/uart_component_host.cpp index adb11266c5..69b24607d1 100644 --- a/esphome/components/uart/uart_component_host.cpp +++ b/esphome/components/uart/uart_component_host.cpp @@ -96,8 +96,7 @@ speed_t get_baud(int baud) { } // namespace -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.host"; @@ -296,7 +295,5 @@ void HostUartComponent::update_error_(const std::string &error) { ESP_LOGE(TAG, "Port error: %s", error.c_str()); } -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_HOST diff --git a/esphome/components/uart/uart_component_host.h b/esphome/components/uart/uart_component_host.h index c1f1dd0d2c..a4a6946c0c 100644 --- a/esphome/components/uart/uart_component_host.h +++ b/esphome/components/uart/uart_component_host.h @@ -6,8 +6,7 @@ #include "esphome/core/log.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class HostUartComponent : public UARTComponent, public Component { public: @@ -32,7 +31,5 @@ class HostUartComponent : public UARTComponent, public Component { uint8_t peek_byte_; }; -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_HOST diff --git a/esphome/components/uart/uart_component_libretiny.cpp b/esphome/components/uart/uart_component_libretiny.cpp index 8d1d28fce4..01c7063fe8 100644 --- a/esphome/components/uart/uart_component_libretiny.cpp +++ b/esphome/components/uart/uart_component_libretiny.cpp @@ -14,8 +14,7 @@ #include #endif -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.lt"; @@ -53,7 +52,7 @@ void LibreTinyUARTComponent::setup() { auto shouldFallbackToSoftwareSerial = [&]() -> bool { auto hasFlags = [](InternalGPIOPin *pin, const gpio::Flags mask) -> bool { - return pin && pin->get_flags() & mask != gpio::Flags::FLAG_NONE; + return pin && (pin->get_flags() & mask) != gpio::Flags::FLAG_NONE; }; if (hasFlags(this->tx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN) || hasFlags(this->rx_pin_, gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN)) { @@ -187,7 +186,5 @@ void LibreTinyUARTComponent::check_logger_conflict() { #endif } -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_LIBRETINY diff --git a/esphome/components/uart/uart_component_libretiny.h b/esphome/components/uart/uart_component_libretiny.h index 00982fd297..ec13e7da5a 100644 --- a/esphome/components/uart/uart_component_libretiny.h +++ b/esphome/components/uart/uart_component_libretiny.h @@ -8,8 +8,7 @@ #include "esphome/core/log.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class LibreTinyUARTComponent : public UARTComponent, public Component { public: @@ -37,7 +36,5 @@ class LibreTinyUARTComponent : public UARTComponent, public Component { int8_t hardware_idx_{-1}; }; -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_LIBRETINY diff --git a/esphome/components/uart/uart_component_rp2040.cpp b/esphome/components/uart/uart_component_rp2040.cpp index c78691653d..5799d26a54 100644 --- a/esphome/components/uart/uart_component_rp2040.cpp +++ b/esphome/components/uart/uart_component_rp2040.cpp @@ -11,8 +11,7 @@ #include "esphome/components/logger/logger.h" #endif -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart.arduino_rp2040"; @@ -52,11 +51,19 @@ uint16_t RP2040UartComponent::get_config() { } void RP2040UartComponent::setup() { - if (this->rx_pin_) { - this->rx_pin_->setup(); - } - if (this->tx_pin_ && this->rx_pin_ != this->tx_pin_) { - this->tx_pin_->setup(); + auto setup_pin_if_needed = [](InternalGPIOPin *pin) { + if (!pin) { + return; + } + const auto mask = gpio::Flags::FLAG_OPEN_DRAIN | gpio::Flags::FLAG_PULLUP | gpio::Flags::FLAG_PULLDOWN; + if ((pin->get_flags() & mask) != gpio::Flags::FLAG_NONE) { + pin->setup(); + } + }; + + setup_pin_if_needed(this->rx_pin_); + if (this->rx_pin_ != this->tx_pin_) { + setup_pin_if_needed(this->tx_pin_); } uint16_t config = get_config(); @@ -185,7 +192,5 @@ void RP2040UartComponent::flush() { this->serial_->flush(); } -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_RP2040 diff --git a/esphome/components/uart/uart_component_rp2040.h b/esphome/components/uart/uart_component_rp2040.h index f26c913cff..d626d11a2e 100644 --- a/esphome/components/uart/uart_component_rp2040.h +++ b/esphome/components/uart/uart_component_rp2040.h @@ -11,8 +11,7 @@ #include "esphome/core/log.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { class RP2040UartComponent : public UARTComponent, public Component { public: @@ -40,7 +39,5 @@ class RP2040UartComponent : public UARTComponent, public Component { HardwareSerial *serial_{nullptr}; }; -} // namespace uart -} // namespace esphome - +} // namespace esphome::uart #endif // USE_RP2040 diff --git a/esphome/components/uart/uart_debugger.cpp b/esphome/components/uart/uart_debugger.cpp index e2d92eac60..b51a57d68e 100644 --- a/esphome/components/uart/uart_debugger.cpp +++ b/esphome/components/uart/uart_debugger.cpp @@ -6,8 +6,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace uart { +namespace esphome::uart { static const char *const TAG = "uart_debug"; @@ -197,6 +196,5 @@ void UARTDebug::log_binary(UARTDirection direction, std::vector bytes, delay(10); } -} // namespace uart -} // namespace esphome +} // namespace esphome::uart #endif diff --git a/esphome/components/uart/uart_debugger.h b/esphome/components/uart/uart_debugger.h index 4f9b6d09df..df87655962 100644 --- a/esphome/components/uart/uart_debugger.h +++ b/esphome/components/uart/uart_debugger.h @@ -8,8 +8,7 @@ #include "uart.h" #include "uart_component.h" -namespace esphome { -namespace uart { +namespace esphome::uart { /// The UARTDebugger class adds debugging support to a UART bus. /// @@ -96,6 +95,5 @@ class UARTDebug { static void log_binary(UARTDirection direction, std::vector bytes, uint8_t separator); }; -} // namespace uart -} // namespace esphome +} // namespace esphome::uart #endif diff --git a/esphome/components/udp/udp_component.cpp b/esphome/components/udp/udp_component.cpp index 8a9ce612b4..9105ced21e 100644 --- a/esphome/components/udp/udp_component.cpp +++ b/esphome/components/udp/udp_component.cpp @@ -21,8 +21,8 @@ void UDPComponent::setup() { if (this->should_broadcast_) { this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { + this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); - this->status_set_error("Could not create socket"); return; } int enable = 1; @@ -41,15 +41,15 @@ void UDPComponent::setup() { if (this->should_listen_) { this->listen_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); if (this->listen_socket_ == nullptr) { + this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); - this->status_set_error("Could not create socket"); return; } auto err = this->listen_socket_->setblocking(false); if (err < 0) { ESP_LOGE(TAG, "Unable to set nonblocking: errno %d", errno); + this->status_set_error(LOG_STR("Unable to set nonblocking")); this->mark_failed(); - this->status_set_error("Unable to set nonblocking"); return; } int enable = 1; @@ -73,8 +73,8 @@ void UDPComponent::setup() { err = this->listen_socket_->setsockopt(IPPROTO_IP, IP_ADD_MEMBERSHIP, &imreq, sizeof(imreq)); if (err < 0) { ESP_LOGE(TAG, "Failed to set IP_ADD_MEMBERSHIP. Error %d", errno); + this->status_set_error(LOG_STR("Failed to set IP_ADD_MEMBERSHIP")); this->mark_failed(); - this->status_set_error("Failed to set IP_ADD_MEMBERSHIP"); return; } } @@ -82,8 +82,8 @@ void UDPComponent::setup() { err = this->listen_socket_->bind((struct sockaddr *) &server, sizeof(server)); if (err != 0) { ESP_LOGE(TAG, "Socket unable to bind: errno %d", errno); + this->status_set_error(LOG_STR("Unable to bind socket")); this->mark_failed(); - this->status_set_error("Unable to bind socket"); return; } } diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index 4c09cf8a49..fe61353b5d 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -188,7 +188,7 @@ void USBClient::setup() { auto err = usb_host_client_register(&config, &this->handle_); if (err != ESP_OK) { ESP_LOGE(TAG, "client register failed: %s", esp_err_to_name(err)); - this->status_set_error("Client register failed"); + this->status_set_error(LOG_STR("Client register failed")); this->mark_failed(); return; } diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index fb19239c73..1e70c289df 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -11,7 +11,7 @@ void USBHost::setup() { usb_host_config_t config{}; if (usb_host_install(&config) != ESP_OK) { - this->status_set_error("usb_host_install failed"); + this->status_set_error(LOG_STR("usb_host_install failed")); this->mark_failed(); return; } diff --git a/esphome/components/usb_uart/__init__.py b/esphome/components/usb_uart/__init__.py index a852e1f78b..d9bb58ae3a 100644 --- a/esphome/components/usb_uart/__init__.py +++ b/esphome/components/usb_uart/__init__.py @@ -1,4 +1,5 @@ import esphome.codegen as cg +from esphome.components import socket from esphome.components.uart import ( CONF_DATA_BITS, CONF_PARITY, @@ -17,7 +18,7 @@ from esphome.const import ( ) from esphome.cpp_types import Component -AUTO_LOAD = ["uart", "usb_host", "bytebuffer"] +AUTO_LOAD = ["uart", "usb_host", "bytebuffer", "socket"] CODEOWNERS = ["@clydebarrow"] usb_uart_ns = cg.esphome_ns.namespace("usb_uart") @@ -116,6 +117,10 @@ CONFIG_SCHEMA = cv.ensure_list( async def to_code(config): + # Enable wake_loop_threadsafe for low-latency USB data processing + # The USB task queues data events that need immediate processing + socket.require_wake_loop_threadsafe() + for device in config: var = await register_usb_client(device) for index, channel in enumerate(device[CONF_CHANNELS]): diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index c24fffb11d..fefccd3645 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -2,6 +2,7 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) #include "usb_uart.h" #include "esphome/core/log.h" +#include "esphome/core/application.h" #include "esphome/components/uart/uart_debugger.h" #include @@ -262,6 +263,11 @@ void USBUartComponent::start_input(USBUartChannel *channel) { // Push to lock-free queue for main loop processing // Push always succeeds because pool size == queue size this->usb_data_queue_.push(chunk); + + // Wake main loop immediately to process USB data instead of waiting for select() timeout +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + App.wake_loop_threadsafe(); +#endif } // On success, restart input immediately from USB task for performance @@ -320,7 +326,7 @@ static void fix_mps(const usb_ep_desc_t *ep) { void USBUartTypeCdcAcm::on_connected() { auto cdc_devs = this->parse_descriptors(this->device_handle_); if (cdc_devs.empty()) { - this->status_set_error("No CDC-ACM device found"); + this->status_set_error(LOG_STR("No CDC-ACM device found")); this->disconnect(); return; } @@ -341,7 +347,7 @@ void USBUartTypeCdcAcm::on_connected() { if (err != ESP_OK) { ESP_LOGE(TAG, "usb_host_interface_claim failed: %s, channel=%d, intf=%d", esp_err_to_name(err), channel->index_, channel->cdc_dev_.bulk_interface_number); - this->status_set_error("usb_host_interface_claim failed"); + this->status_set_error(LOG_STR("usb_host_interface_claim failed")); this->disconnect(); return; } diff --git a/esphome/components/valve/valve.cpp b/esphome/components/valve/valve.cpp index 381d9061de..fed113afc2 100644 --- a/esphome/components/valve/valve.cpp +++ b/esphome/components/valve/valve.cpp @@ -12,25 +12,25 @@ static const char *const TAG = "valve"; const float VALVE_OPEN = 1.0f; const float VALVE_CLOSED = 0.0f; -const char *valve_command_to_str(float pos) { +const LogString *valve_command_to_str(float pos) { if (pos == VALVE_OPEN) { - return "OPEN"; + return LOG_STR("OPEN"); } else if (pos == VALVE_CLOSED) { - return "CLOSE"; + return LOG_STR("CLOSE"); } else { - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } -const char *valve_operation_to_str(ValveOperation op) { +const LogString *valve_operation_to_str(ValveOperation op) { switch (op) { case VALVE_OPERATION_IDLE: - return "IDLE"; + return LOG_STR("IDLE"); case VALVE_OPERATION_OPENING: - return "OPENING"; + return LOG_STR("OPENING"); case VALVE_OPERATION_CLOSING: - return "CLOSING"; + return LOG_STR("CLOSING"); default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -82,7 +82,7 @@ void ValveCall::perform() { if (traits.get_supports_position()) { ESP_LOGD(TAG, " Position: %.0f%%", *this->position_ * 100.0f); } else { - ESP_LOGD(TAG, " Command: %s", valve_command_to_str(*this->position_)); + ESP_LOGD(TAG, " Command: %s", LOG_STR_ARG(valve_command_to_str(*this->position_))); } } if (this->toggle_.has_value()) { @@ -146,7 +146,7 @@ void Valve::publish_state(bool save) { ESP_LOGD(TAG, " State: UNKNOWN"); } } - ESP_LOGD(TAG, " Current Operation: %s", valve_operation_to_str(this->current_operation)); + ESP_LOGD(TAG, " Current Operation: %s", LOG_STR_ARG(valve_operation_to_str(this->current_operation))); this->state_callback_.call(); #if defined(USE_VALVE) && defined(USE_CONTROLLER_REGISTRY) diff --git a/esphome/components/valve/valve.h b/esphome/components/valve/valve.h index ab7ff5abe1..2cb28e4b2f 100644 --- a/esphome/components/valve/valve.h +++ b/esphome/components/valve/valve.h @@ -3,6 +3,7 @@ #include "esphome/core/component.h" #include "esphome/core/entity_base.h" #include "esphome/core/helpers.h" +#include "esphome/core/log.h" #include "esphome/core/preferences.h" #include "valve_traits.h" @@ -81,7 +82,7 @@ enum ValveOperation : uint8_t { VALVE_OPERATION_CLOSING, }; -const char *valve_operation_to_str(ValveOperation op); +const LogString *valve_operation_to_str(ValveOperation op); /** Base class for all valve devices. * diff --git a/esphome/components/voice_assistant/voice_assistant.cpp b/esphome/components/voice_assistant/voice_assistant.cpp index fd35dc7d09..551f0370f2 100644 --- a/esphome/components/voice_assistant/voice_assistant.cpp +++ b/esphome/components/voice_assistant/voice_assistant.cpp @@ -206,7 +206,7 @@ void VoiceAssistant::loop() { case State::START_MICROPHONE: { ESP_LOGD(TAG, "Starting Microphone"); if (!this->allocate_buffers_()) { - this->status_set_error("Failed to allocate buffers"); + this->status_set_error(LOG_STR("Failed to allocate buffers")); return; } if (this->status_has_error()) { diff --git a/esphome/components/wake_on_lan/wake_on_lan.cpp b/esphome/components/wake_on_lan/wake_on_lan.cpp index adf5a080e5..8c5bdac54b 100644 --- a/esphome/components/wake_on_lan/wake_on_lan.cpp +++ b/esphome/components/wake_on_lan/wake_on_lan.cpp @@ -67,8 +67,8 @@ void WakeOnLanButton::setup() { #if defined(USE_SOCKET_IMPL_BSD_SOCKETS) || defined(USE_SOCKET_IMPL_LWIP_SOCKETS) this->broadcast_socket_ = socket::socket(AF_INET, SOCK_DGRAM, IPPROTO_IP); if (this->broadcast_socket_ == nullptr) { + this->status_set_error(LOG_STR("Could not create socket")); this->mark_failed(); - this->status_set_error("Could not create socket"); return; } int enable = 1; diff --git a/esphome/components/web_server/ota/__init__.py b/esphome/components/web_server/ota/__init__.py index 4a98db8877..260e6aea6d 100644 --- a/esphome/components/web_server/ota/__init__.py +++ b/esphome/components/web_server/ota/__init__.py @@ -1,10 +1,17 @@ +import logging + import esphome.codegen as cg from esphome.components.esp32 import add_idf_component from esphome.components.ota import BASE_OTA_SCHEMA, OTAComponent, ota_to_code +from esphome.config_helpers import merge_config import esphome.config_validation as cv -from esphome.const import CONF_ID +from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER from esphome.core import CORE, coroutine_with_priority from esphome.coroutine import CoroPriority +import esphome.final_validate as fv +from esphome.types import ConfigType + +_LOGGER = logging.getLogger(__name__) CODEOWNERS = ["@esphome/core"] DEPENDENCIES = ["network", "web_server_base"] @@ -12,6 +19,53 @@ DEPENDENCIES = ["network", "web_server_base"] web_server_ns = cg.esphome_ns.namespace("web_server") WebServerOTAComponent = web_server_ns.class_("WebServerOTAComponent", OTAComponent) + +def _web_server_ota_final_validate(config: ConfigType) -> None: + """Merge multiple web_server OTA instances into one. + + Multiple web_server OTA instances register duplicate HTTP handlers for /update, + causing undefined behavior. Merge them into a single instance. + """ + full_conf = fv.full_config.get() + ota_confs = full_conf.get(CONF_OTA, []) + + web_server_ota_configs: list[ConfigType] = [] + other_ota_configs: list[ConfigType] = [] + + for ota_conf in ota_confs: + if ota_conf.get(CONF_PLATFORM) == CONF_WEB_SERVER: + web_server_ota_configs.append(ota_conf) + else: + other_ota_configs.append(ota_conf) + + if len(web_server_ota_configs) <= 1: + return + + # Merge all web_server OTA configs into the first one + merged = web_server_ota_configs[0] + for ota_conf in web_server_ota_configs[1:]: + # Validate that IDs are consistent if manually specified + if ( + merged[CONF_ID].is_manual + and ota_conf[CONF_ID].is_manual + and merged[CONF_ID] != ota_conf[CONF_ID] + ): + raise cv.Invalid( + f"Found multiple web_server OTA configurations but {CONF_ID} is inconsistent" + ) + merged = merge_config(merged, ota_conf) + + _LOGGER.warning( + "Found and merged %d web_server OTA configurations into one instance", + len(web_server_ota_configs), + ) + + # Replace OTA configs with merged web_server + other OTA platforms + other_ota_configs.append(merged) + full_conf[CONF_OTA] = other_ota_configs + fv.full_config.set(full_conf) + + CONFIG_SCHEMA = ( cv.Schema( { @@ -22,6 +76,8 @@ CONFIG_SCHEMA = ( .extend(cv.COMPONENT_SCHEMA) ) +FINAL_VALIDATE_SCHEMA = _web_server_ota_final_validate + @coroutine_with_priority(CoroPriority.WEB_SERVER_OTA) async def to_code(config): diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 5a8128ba43..1f3605a082 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -41,6 +41,10 @@ namespace web_server { static const char *const TAG = "web_server"; +// Longest: UPDATE AVAILABLE (16 chars + null terminator, rounded up) +static constexpr size_t PSTR_LOCAL_SIZE = 18; +#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), PSTR_LOCAL_SIZE - 1) + #ifdef USE_WEBSERVER_PRIVATE_NETWORK_ACCESS static const char *const HEADER_PNA_NAME = "Private-Network-Access-Name"; static const char *const HEADER_PNA_ID = "Private-Network-Access-ID"; @@ -240,8 +244,8 @@ void DeferredUpdateEventSourceList::on_client_connect_(DeferredUpdateEventSource for (auto &group : ws->sorting_groups_) { json::JsonBuilder builder; JsonObject root = builder.root(); - root["name"] = group.second.name; - root["sorting_weight"] = group.second.weight; + root[ESPHOME_F("name")] = group.second.name; + root[ESPHOME_F("sorting_weight")] = group.second.weight; message = builder.serialize(); // up to 31 groups should be able to be queued initially without defer @@ -282,15 +286,15 @@ std::string WebServer::get_config_json() { json::JsonBuilder builder; JsonObject root = builder.root(); - root["title"] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); - root["comment"] = App.get_comment(); + root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); + root[ESPHOME_F("comment")] = App.get_comment(); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) - root["ota"] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal + root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else - root["ota"] = true; + root[ESPHOME_F("ota")] = true; #endif - root["log"] = this->expose_log_; - root["lang"] = "en"; + root[ESPHOME_F("log")] = this->expose_log_; + root[ESPHOME_F("lang")] = "en"; return builder.serialize(); } @@ -301,12 +305,7 @@ void WebServer::setup() { #ifdef USE_LOGGER if (logger::global_logger != nullptr && this->expose_log_) { - logger::global_logger->add_on_log_callback( - // logs are not deferred, the memory overhead would be too large - [this](int level, const char *tag, const char *message, size_t message_len) { - (void) message_len; - this->events_.try_send_nodefer(message, "log", millis()); - }); + logger::global_logger->add_log_listener(this); } #endif @@ -322,6 +321,16 @@ void WebServer::setup() { this->set_interval(10000, [this]() { this->events_.try_send_nodefer("", "ping", millis(), 30000); }); } void WebServer::loop() { this->events_.loop(); } + +#ifdef USE_LOGGER +void WebServer::on_log(uint8_t level, const char *tag, const char *message, size_t message_len) { + (void) level; + (void) tag; + (void) message_len; + this->events_.try_send_nodefer(message, "log", millis()); +} +#endif + void WebServer::dump_config() { ESP_LOGCONFIG(TAG, "Web Server:\n" @@ -359,8 +368,8 @@ void WebServer::handle_pna_cors_request(AsyncWebServerRequest *request) { AsyncWebServerResponse *response = request->beginResponse(200, ""); response->addHeader(HEADER_CORS_ALLOW_PNA, "true"); response->addHeader(HEADER_PNA_NAME, App.get_name().c_str()); - std::string mac = get_mac_address_pretty(); - response->addHeader(HEADER_PNA_ID, mac.c_str()); + char mac_s[18]; + response->addHeader(HEADER_PNA_ID, get_mac_address_pretty_into_buffer(mac_s)); request->send(response); } #endif @@ -398,14 +407,14 @@ static void set_json_id(JsonObject &root, EntityBase *obj, const char *prefix, J char id_buf[160]; // object_id can be up to 128 chars + prefix + dash + null const auto &object_id = obj->get_object_id(); snprintf(id_buf, sizeof(id_buf), "%s-%s", prefix, object_id.c_str()); - root["id"] = id_buf; + root[ESPHOME_F("id")] = id_buf; if (start_config == DETAIL_ALL) { - root["name"] = obj->get_name(); - root["icon"] = obj->get_icon_ref(); - root["entity_category"] = obj->get_entity_category(); + root[ESPHOME_F("name")] = obj->get_name(); + root[ESPHOME_F("icon")] = obj->get_icon_ref(); + root[ESPHOME_F("entity_category")] = obj->get_entity_category(); bool is_disabled = obj->is_disabled_by_default(); if (is_disabled) - root["is_disabled_by_default"] = is_disabled; + root[ESPHOME_F("is_disabled_by_default")] = is_disabled; } } @@ -415,14 +424,14 @@ template static void set_json_value(JsonObject &root, EntityBase *obj, const char *prefix, const T &value, JsonDetail start_config) { set_json_id(root, obj, prefix, start_config); - root["value"] = value; + root[ESPHOME_F("value")] = value; } template static void set_json_icon_state_value(JsonObject &root, EntityBase *obj, const char *prefix, const std::string &state, const T &value, JsonDetail start_config) { set_json_value(root, obj, prefix, value, start_config); - root["state"] = state; + root[ESPHOME_F("state")] = state; } // Helper to get request detail parameter @@ -469,7 +478,7 @@ std::string WebServer::sensor_json(sensor::Sensor *obj, float value, JsonDetail if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); if (!uom_ref.empty()) - root["uom"] = uom_ref; + root[ESPHOME_F("uom")] = uom_ref; } return builder.serialize(); @@ -584,7 +593,7 @@ std::string WebServer::switch_json(switch_::Switch *obj, bool value, JsonDetail set_json_icon_state_value(root, obj, "switch", value ? "ON" : "OFF", value, start_config); if (start_config == DETAIL_ALL) { - root["assumed_state"] = obj->assumed_state(); + root[ESPHOME_F("assumed_state")] = obj->assumed_state(); this->add_sorting_info_(root, obj); } @@ -690,8 +699,14 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc } else if (match.method_equals("toggle")) { this->defer([obj]() { obj->toggle().perform(); }); request->send(200); - } else if (match.method_equals("turn_on") || match.method_equals("turn_off")) { - auto call = match.method_equals("turn_on") ? obj->turn_on() : obj->turn_off(); + } else { + bool is_on = match.method_equals("turn_on"); + bool is_off = match.method_equals("turn_off"); + if (!is_on && !is_off) { + request->send(404); + return; + } + auto call = is_on ? obj->turn_on() : obj->turn_off(); parse_int_param_(request, "speed_level", call, &decltype(call)::set_speed); @@ -715,8 +730,6 @@ void WebServer::handle_fan_request(AsyncWebServerRequest *request, const UrlMatc } this->defer([call]() mutable { call.perform(); }); request->send(200); - } else { - request->send(404); } return; } @@ -735,11 +748,11 @@ std::string WebServer::fan_json(fan::Fan *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "fan", obj->state ? "ON" : "OFF", obj->state, start_config); const auto traits = obj->get_traits(); if (traits.supports_speed()) { - root["speed_level"] = obj->speed; - root["speed_count"] = traits.supported_speed_count(); + root[ESPHOME_F("speed_level")] = obj->speed; + root[ESPHOME_F("speed_count")] = traits.supported_speed_count(); } if (obj->get_traits().supports_oscillation()) - root["oscillation"] = obj->oscillating; + root[ESPHOME_F("oscillation")] = obj->oscillating; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -766,32 +779,35 @@ void WebServer::handle_light_request(AsyncWebServerRequest *request, const UrlMa } else if (match.method_equals("toggle")) { this->defer([obj]() { obj->toggle().perform(); }); request->send(200); - } else if (match.method_equals("turn_on")) { - auto call = obj->turn_on(); - - // Parse color parameters - parse_light_param_(request, "brightness", call, &decltype(call)::set_brightness, 255.0f); - parse_light_param_(request, "r", call, &decltype(call)::set_red, 255.0f); - parse_light_param_(request, "g", call, &decltype(call)::set_green, 255.0f); - parse_light_param_(request, "b", call, &decltype(call)::set_blue, 255.0f); - parse_light_param_(request, "white_value", call, &decltype(call)::set_white, 255.0f); - parse_light_param_(request, "color_temp", call, &decltype(call)::set_color_temperature); - - // Parse timing parameters - parse_light_param_uint_(request, "flash", call, &decltype(call)::set_flash_length, 1000); - parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000); - - parse_string_param_(request, "effect", call, &decltype(call)::set_effect); - - this->defer([call]() mutable { call.perform(); }); - request->send(200); - } else if (match.method_equals("turn_off")) { - auto call = obj->turn_off(); - parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000); - this->defer([call]() mutable { call.perform(); }); - request->send(200); } else { - request->send(404); + bool is_on = match.method_equals("turn_on"); + bool is_off = match.method_equals("turn_off"); + if (!is_on && !is_off) { + request->send(404); + return; + } + auto call = is_on ? obj->turn_on() : obj->turn_off(); + + if (is_on) { + // Parse color parameters + parse_light_param_(request, "brightness", call, &decltype(call)::set_brightness, 255.0f); + parse_light_param_(request, "r", call, &decltype(call)::set_red, 255.0f); + parse_light_param_(request, "g", call, &decltype(call)::set_green, 255.0f); + parse_light_param_(request, "b", call, &decltype(call)::set_blue, 255.0f); + parse_light_param_(request, "white_value", call, &decltype(call)::set_white, 255.0f); + parse_light_param_(request, "color_temp", call, &decltype(call)::set_color_temperature); + + // Parse timing parameters + parse_light_param_uint_(request, "flash", call, &decltype(call)::set_flash_length, 1000); + } + parse_light_param_uint_(request, "transition", call, &decltype(call)::set_transition_length, 1000); + + if (is_on) { + parse_string_param_(request, "effect", call, &decltype(call)::set_effect); + } + + this->defer([call]() mutable { call.perform(); }); + request->send(200); } return; } @@ -811,7 +827,7 @@ std::string WebServer::light_json(light::LightState *obj, JsonDetail start_confi light::LightJSONSchema::dump_json(*obj, root); if (start_config == DETAIL_ALL) { - JsonArray opt = root["effects"].to(); + JsonArray opt = root[ESPHOME_F("effects")].to(); opt.add("None"); for (auto const &option : obj->get_effects()) { opt.add(option->get_name()); @@ -896,12 +912,13 @@ std::string WebServer::cover_json(cover::Cover *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "cover", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - root["current_operation"] = cover::cover_operation_to_str(obj->current_operation); + char buf[PSTR_LOCAL_SIZE]; + root[ESPHOME_F("current_operation")] = PSTR_LOCAL(cover::cover_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; + root[ESPHOME_F("position")] = obj->position; if (obj->get_traits().get_supports_tilt()) - root["tilt"] = obj->tilt; + root[ESPHOME_F("tilt")] = obj->tilt; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -962,14 +979,15 @@ std::string WebServer::number_json(number::Number *obj, float value, JsonDetail value, step_to_accuracy_decimals(obj->traits.get_step()), uom_ref); set_json_icon_state_value(root, obj, "number", state_str, val_str, start_config); if (start_config == DETAIL_ALL) { - root["min_value"] = + root[ESPHOME_F("min_value")] = value_accuracy_to_string(obj->traits.get_min_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["max_value"] = + root[ESPHOME_F("max_value")] = value_accuracy_to_string(obj->traits.get_max_value(), step_to_accuracy_decimals(obj->traits.get_step())); - root["step"] = value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); - root["mode"] = (int) obj->traits.get_mode(); + root[ESPHOME_F("step")] = + value_accuracy_to_string(obj->traits.get_step(), step_to_accuracy_decimals(obj->traits.get_step())); + root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); if (!uom_ref.empty()) - root["uom"] = uom_ref; + root[ESPHOME_F("uom")] = uom_ref; this->add_sorting_info_(root, obj); } @@ -1191,11 +1209,11 @@ std::string WebServer::text_json(text::Text *obj, const std::string &value, Json std::string state = obj->traits.get_mode() == text::TextMode::TEXT_MODE_PASSWORD ? "********" : value; set_json_icon_state_value(root, obj, "text", state, value, start_config); - root["min_length"] = obj->traits.get_min_length(); - root["max_length"] = obj->traits.get_max_length(); - root["pattern"] = obj->traits.get_pattern(); + root[ESPHOME_F("min_length")] = obj->traits.get_min_length(); + root[ESPHOME_F("max_length")] = obj->traits.get_max_length(); + root[ESPHOME_F("pattern")] = obj->traits.get_pattern(); if (start_config == DETAIL_ALL) { - root["mode"] = (int) obj->traits.get_mode(); + root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); } @@ -1249,7 +1267,7 @@ std::string WebServer::select_json(select::Select *obj, const char *value, JsonD set_json_icon_state_value(root, obj, "select", value, value, start_config); if (start_config == DETAIL_ALL) { - JsonArray opt = root["option"].to(); + JsonArray opt = root[ESPHOME_F("option")].to(); for (auto &option : obj->traits.get_options()) { opt.add(option); } @@ -1260,9 +1278,6 @@ std::string WebServer::select_json(select::Select *obj, const char *value, JsonD } #endif -// Longest: HORIZONTAL -#define PSTR_LOCAL(mode_s) ESPHOME_strncpy_P(buf, (ESPHOME_PGM_P) ((mode_s)), 15) - #ifdef USE_CLIMATE void WebServer::on_climate_update(climate::Climate *obj) { if (!this->include_internal_ && obj->is_internal()) @@ -1320,35 +1335,35 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf const auto traits = obj->get_traits(); int8_t target_accuracy = traits.get_target_temperature_accuracy_decimals(); int8_t current_accuracy = traits.get_current_temperature_accuracy_decimals(); - char buf[16]; + char buf[PSTR_LOCAL_SIZE]; if (start_config == DETAIL_ALL) { - JsonArray opt = root["modes"].to(); + JsonArray opt = root[ESPHOME_F("modes")].to(); for (climate::ClimateMode m : traits.get_supported_modes()) opt.add(PSTR_LOCAL(climate::climate_mode_to_string(m))); if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["fan_modes"].to(); + JsonArray opt = root[ESPHOME_F("fan_modes")].to(); for (climate::ClimateFanMode m : traits.get_supported_fan_modes()) opt.add(PSTR_LOCAL(climate::climate_fan_mode_to_string(m))); } if (!traits.get_supported_custom_fan_modes().empty()) { - JsonArray opt = root["custom_fan_modes"].to(); + JsonArray opt = root[ESPHOME_F("custom_fan_modes")].to(); for (auto const &custom_fan_mode : traits.get_supported_custom_fan_modes()) opt.add(custom_fan_mode); } if (traits.get_supports_swing_modes()) { - JsonArray opt = root["swing_modes"].to(); + JsonArray opt = root[ESPHOME_F("swing_modes")].to(); for (auto swing_mode : traits.get_supported_swing_modes()) opt.add(PSTR_LOCAL(climate::climate_swing_mode_to_string(swing_mode))); } if (traits.get_supports_presets() && obj->preset.has_value()) { - JsonArray opt = root["presets"].to(); + JsonArray opt = root[ESPHOME_F("presets")].to(); for (climate::ClimatePreset m : traits.get_supported_presets()) opt.add(PSTR_LOCAL(climate::climate_preset_to_string(m))); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - JsonArray opt = root["custom_presets"].to(); + JsonArray opt = root[ESPHOME_F("custom_presets")].to(); for (auto const &custom_preset : traits.get_supported_custom_presets()) opt.add(custom_preset); } @@ -1356,49 +1371,50 @@ std::string WebServer::climate_json(climate::Climate *obj, JsonDetail start_conf } bool has_state = false; - root["mode"] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); - root["max_temp"] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); - root["min_temp"] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); - root["step"] = traits.get_visual_target_temperature_step(); + root[ESPHOME_F("mode")] = PSTR_LOCAL(climate_mode_to_string(obj->mode)); + root[ESPHOME_F("max_temp")] = value_accuracy_to_string(traits.get_visual_max_temperature(), target_accuracy); + root[ESPHOME_F("min_temp")] = value_accuracy_to_string(traits.get_visual_min_temperature(), target_accuracy); + root[ESPHOME_F("step")] = traits.get_visual_target_temperature_step(); if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_ACTION)) { - root["action"] = PSTR_LOCAL(climate_action_to_string(obj->action)); - root["state"] = root["action"]; + root[ESPHOME_F("action")] = PSTR_LOCAL(climate_action_to_string(obj->action)); + root[ESPHOME_F("state")] = root[ESPHOME_F("action")]; has_state = true; } if (traits.get_supports_fan_modes() && obj->fan_mode.has_value()) { - root["fan_mode"] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); + root[ESPHOME_F("fan_mode")] = PSTR_LOCAL(climate_fan_mode_to_string(obj->fan_mode.value())); } if (!traits.get_supported_custom_fan_modes().empty() && obj->has_custom_fan_mode()) { - root["custom_fan_mode"] = obj->get_custom_fan_mode(); + root[ESPHOME_F("custom_fan_mode")] = obj->get_custom_fan_mode(); } if (traits.get_supports_presets() && obj->preset.has_value()) { - root["preset"] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); + root[ESPHOME_F("preset")] = PSTR_LOCAL(climate_preset_to_string(obj->preset.value())); } if (!traits.get_supported_custom_presets().empty() && obj->has_custom_preset()) { - root["custom_preset"] = obj->get_custom_preset(); + root[ESPHOME_F("custom_preset")] = obj->get_custom_preset(); } if (traits.get_supports_swing_modes()) { - root["swing_mode"] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); + root[ESPHOME_F("swing_mode")] = PSTR_LOCAL(climate_swing_mode_to_string(obj->swing_mode)); } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_CURRENT_TEMPERATURE)) { if (!std::isnan(obj->current_temperature)) { - root["current_temperature"] = value_accuracy_to_string(obj->current_temperature, current_accuracy); + root[ESPHOME_F("current_temperature")] = value_accuracy_to_string(obj->current_temperature, current_accuracy); } else { - root["current_temperature"] = "NA"; + root[ESPHOME_F("current_temperature")] = "NA"; } } if (traits.has_feature_flags(climate::CLIMATE_SUPPORTS_TWO_POINT_TARGET_TEMPERATURE | climate::CLIMATE_REQUIRES_TWO_POINT_TARGET_TEMPERATURE)) { - root["target_temperature_low"] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); - root["target_temperature_high"] = value_accuracy_to_string(obj->target_temperature_high, target_accuracy); + root[ESPHOME_F("target_temperature_low")] = value_accuracy_to_string(obj->target_temperature_low, target_accuracy); + root[ESPHOME_F("target_temperature_high")] = + value_accuracy_to_string(obj->target_temperature_high, target_accuracy); if (!has_state) { - root["state"] = value_accuracy_to_string((obj->target_temperature_high + obj->target_temperature_low) / 2.0f, - target_accuracy); + root[ESPHOME_F("state")] = value_accuracy_to_string( + (obj->target_temperature_high + obj->target_temperature_low) / 2.0f, target_accuracy); } } else { - root["target_temperature"] = value_accuracy_to_string(obj->target_temperature, target_accuracy); + root[ESPHOME_F("target_temperature")] = value_accuracy_to_string(obj->target_temperature, target_accuracy); if (!has_state) - root["state"] = root["target_temperature"]; + root[ESPHOME_F("state")] = root[ESPHOME_F("target_temperature")]; } return builder.serialize(); @@ -1470,7 +1486,8 @@ std::string WebServer::lock_json(lock::Lock *obj, lock::LockState value, JsonDet json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "lock", lock::lock_state_to_string(value), value, start_config); + char buf[PSTR_LOCAL_SIZE]; + set_json_icon_state_value(root, obj, "lock", PSTR_LOCAL(lock::lock_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1550,10 +1567,11 @@ std::string WebServer::valve_json(valve::Valve *obj, JsonDetail start_config) { set_json_icon_state_value(root, obj, "valve", obj->is_fully_closed() ? "CLOSED" : "OPEN", obj->position, start_config); - root["current_operation"] = valve::valve_operation_to_str(obj->current_operation); + char buf[PSTR_LOCAL_SIZE]; + root[ESPHOME_F("current_operation")] = PSTR_LOCAL(valve::valve_operation_to_str(obj->current_operation)); if (obj->get_traits().get_supports_position()) - root["position"] = obj->position; + root[ESPHOME_F("position")] = obj->position; if (start_config == DETAIL_ALL) { this->add_sorting_info_(root, obj); } @@ -1631,7 +1649,7 @@ std::string WebServer::alarm_control_panel_json(alarm_control_panel::AlarmContro json::JsonBuilder builder; JsonObject root = builder.root(); - char buf[16]; + char buf[PSTR_LOCAL_SIZE]; set_json_icon_state_value(root, obj, "alarm-control-panel", PSTR_LOCAL(alarm_control_panel_state_to_string(value)), value, start_config); if (start_config == DETAIL_ALL) { @@ -1674,6 +1692,7 @@ std::string WebServer::event_state_json_generator(WebServer *web_server, void *s auto *event = static_cast(source); return web_server->event_json(event, get_event_type(event), DETAIL_STATE); } +// NOLINTBEGIN(clang-analyzer-cplusplus.NewDeleteLeaks) false positive with ArduinoJson std::string WebServer::event_all_json_generator(WebServer *web_server, void *source) { auto *event = static_cast(source); return web_server->event_json(event, get_event_type(event), DETAIL_ALL); @@ -1684,32 +1703,33 @@ std::string WebServer::event_json(event::Event *obj, const std::string &event_ty set_json_id(root, obj, "event", start_config); if (!event_type.empty()) { - root["event_type"] = event_type; + root[ESPHOME_F("event_type")] = event_type; } if (start_config == DETAIL_ALL) { - JsonArray event_types = root["event_types"].to(); + JsonArray event_types = root[ESPHOME_F("event_types")].to(); for (const char *event_type : obj->get_event_types()) { event_types.add(event_type); } - root["device_class"] = obj->get_device_class_ref(); + root[ESPHOME_F("device_class")] = obj->get_device_class_ref(); this->add_sorting_info_(root, obj); } return builder.serialize(); } +// NOLINTEND(clang-analyzer-cplusplus.NewDeleteLeaks) #endif #ifdef USE_UPDATE -static const char *update_state_to_string(update::UpdateState state) { +static const LogString *update_state_to_string(update::UpdateState state) { switch (state) { case update::UPDATE_STATE_NO_UPDATE: - return "NO UPDATE"; + return LOG_STR("NO UPDATE"); case update::UPDATE_STATE_AVAILABLE: - return "UPDATE AVAILABLE"; + return LOG_STR("UPDATE AVAILABLE"); case update::UPDATE_STATE_INSTALLING: - return "INSTALLING"; + return LOG_STR("INSTALLING"); default: - return "UNKNOWN"; + return LOG_STR("UNKNOWN"); } } @@ -1752,13 +1772,14 @@ std::string WebServer::update_json(update::UpdateEntity *obj, JsonDetail start_c json::JsonBuilder builder; JsonObject root = builder.root(); - set_json_icon_state_value(root, obj, "update", update_state_to_string(obj->state), obj->update_info.latest_version, - start_config); + char buf[PSTR_LOCAL_SIZE]; + set_json_icon_state_value(root, obj, "update", PSTR_LOCAL(update_state_to_string(obj->state)), + obj->update_info.latest_version, start_config); if (start_config == DETAIL_ALL) { - root["current_version"] = obj->update_info.current_version; - root["title"] = obj->update_info.title; - root["summary"] = obj->update_info.summary; - root["release_url"] = obj->update_info.release_url; + root[ESPHOME_F("current_version")] = obj->update_info.current_version; + root[ESPHOME_F("title")] = obj->update_info.title; + root[ESPHOME_F("summary")] = obj->update_info.summary; + root[ESPHOME_F("release_url")] = obj->update_info.release_url; this->add_sorting_info_(root, obj); } @@ -1933,83 +1954,110 @@ void WebServer::handleRequest(AsyncWebServerRequest *request) { // Parse URL for component routing UrlMatch match = match_url(url.c_str(), url.length(), false); - // Component routing using minimal code repetition - struct ComponentRoute { - const char *domain; - void (WebServer::*handler)(AsyncWebServerRequest *, const UrlMatch &); - }; - - static const ComponentRoute ROUTES[] = { + // Route to appropriate handler based on domain + // NOLINTNEXTLINE(readability-simplify-boolean-expr) + if (false) { // Start chain for else-if macro pattern + } #ifdef USE_SENSOR - {"sensor", &WebServer::handle_sensor_request}, + else if (match.domain_equals("sensor")) { + this->handle_sensor_request(request, match); + } #endif #ifdef USE_SWITCH - {"switch", &WebServer::handle_switch_request}, + else if (match.domain_equals("switch")) { + this->handle_switch_request(request, match); + } #endif #ifdef USE_BUTTON - {"button", &WebServer::handle_button_request}, + else if (match.domain_equals("button")) { + this->handle_button_request(request, match); + } #endif #ifdef USE_BINARY_SENSOR - {"binary_sensor", &WebServer::handle_binary_sensor_request}, + else if (match.domain_equals("binary_sensor")) { + this->handle_binary_sensor_request(request, match); + } #endif #ifdef USE_FAN - {"fan", &WebServer::handle_fan_request}, + else if (match.domain_equals("fan")) { + this->handle_fan_request(request, match); + } #endif #ifdef USE_LIGHT - {"light", &WebServer::handle_light_request}, + else if (match.domain_equals("light")) { + this->handle_light_request(request, match); + } #endif #ifdef USE_TEXT_SENSOR - {"text_sensor", &WebServer::handle_text_sensor_request}, + else if (match.domain_equals("text_sensor")) { + this->handle_text_sensor_request(request, match); + } #endif #ifdef USE_COVER - {"cover", &WebServer::handle_cover_request}, + else if (match.domain_equals("cover")) { + this->handle_cover_request(request, match); + } #endif #ifdef USE_NUMBER - {"number", &WebServer::handle_number_request}, + else if (match.domain_equals("number")) { + this->handle_number_request(request, match); + } #endif #ifdef USE_DATETIME_DATE - {"date", &WebServer::handle_date_request}, + else if (match.domain_equals("date")) { + this->handle_date_request(request, match); + } #endif #ifdef USE_DATETIME_TIME - {"time", &WebServer::handle_time_request}, + else if (match.domain_equals("time")) { + this->handle_time_request(request, match); + } #endif #ifdef USE_DATETIME_DATETIME - {"datetime", &WebServer::handle_datetime_request}, + else if (match.domain_equals("datetime")) { + this->handle_datetime_request(request, match); + } #endif #ifdef USE_TEXT - {"text", &WebServer::handle_text_request}, + else if (match.domain_equals("text")) { + this->handle_text_request(request, match); + } #endif #ifdef USE_SELECT - {"select", &WebServer::handle_select_request}, + else if (match.domain_equals("select")) { + this->handle_select_request(request, match); + } #endif #ifdef USE_CLIMATE - {"climate", &WebServer::handle_climate_request}, + else if (match.domain_equals("climate")) { + this->handle_climate_request(request, match); + } #endif #ifdef USE_LOCK - {"lock", &WebServer::handle_lock_request}, + else if (match.domain_equals("lock")) { + this->handle_lock_request(request, match); + } #endif #ifdef USE_VALVE - {"valve", &WebServer::handle_valve_request}, + else if (match.domain_equals("valve")) { + this->handle_valve_request(request, match); + } #endif #ifdef USE_ALARM_CONTROL_PANEL - {"alarm_control_panel", &WebServer::handle_alarm_control_panel_request}, + else if (match.domain_equals("alarm_control_panel")) { + this->handle_alarm_control_panel_request(request, match); + } #endif #ifdef USE_UPDATE - {"update", &WebServer::handle_update_request}, -#endif - }; - - // Check each route - for (const auto &route : ROUTES) { - if (match.domain_equals(route.domain)) { - (this->*route.handler)(request, match); - return; - } + else if (match.domain_equals("update")) { + this->handle_update_request(request, match); + } +#endif + else { + // No matching handler found - send 404 + ESP_LOGV(TAG, "Request for unknown URL: %s", url.c_str()); + request->send(404, "text/plain", "Not Found"); } - - // No matching handler found - send 404 - ESP_LOGV(TAG, "Request for unknown URL: %s", url.c_str()); - request->send(404, "text/plain", "Not Found"); } bool WebServer::isRequestHandlerTrivial() const { return false; } @@ -2017,9 +2065,9 @@ bool WebServer::isRequestHandlerTrivial() const { return false; } void WebServer::add_sorting_info_(JsonObject &root, EntityBase *entity) { #ifdef USE_WEBSERVER_SORTING if (this->sorting_entitys_.find(entity) != this->sorting_entitys_.end()) { - root["sorting_weight"] = this->sorting_entitys_[entity].weight; + root[ESPHOME_F("sorting_weight")] = this->sorting_entitys_[entity].weight; if (this->sorting_groups_.find(this->sorting_entitys_[entity].group_id) != this->sorting_groups_.end()) { - root["sorting_group"] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; + root[ESPHOME_F("sorting_group")] = this->sorting_groups_[this->sorting_entitys_[entity].group_id].name; } } #endif diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 7e1af88645..52cf0bedea 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -7,6 +7,9 @@ #include "esphome/core/component.h" #include "esphome/core/controller.h" #include "esphome/core/entity_base.h" +#ifdef USE_LOGGER +#include "esphome/components/logger/logger.h" +#endif #include #include @@ -170,7 +173,14 @@ class DeferredUpdateEventSourceList : public std::list #include "esphome/core/component.h" - -// Platform-agnostic macros for web server components -// On ESP32 (both Arduino and IDF): Use plain strings (no PROGMEM) -// On ESP8266: Use Arduino's F() macro for PROGMEM strings -#ifdef USE_ESP32 -#define ESPHOME_F(string_literal) (string_literal) -#define ESPHOME_PGM_P const char * -#define ESPHOME_strncpy_P strncpy -#else -// ESP8266 uses Arduino macros -#define ESPHOME_F(string_literal) F(string_literal) -#define ESPHOME_PGM_P PGM_P -#define ESPHOME_strncpy_P strncpy_P -#endif +#include "esphome/core/progmem.h" #if USE_ESP32 #include "esphome/core/hal.h" @@ -111,7 +98,7 @@ class WebServerBase : public Component { this->initialized_++; return; } - this->server_ = std::make_shared(this->port_); + this->server_ = std::make_unique(this->port_); // All content is controlled and created by user - so allowing all origins is fine here. DefaultHeaders::Instance().addHeader("Access-Control-Allow-Origin", "*"); this->server_->begin(); @@ -127,7 +114,7 @@ class WebServerBase : public Component { this->server_ = nullptr; } } - std::shared_ptr get_server() const { return server_; } + AsyncWebServer *get_server() const { return this->server_.get(); } float get_setup_priority() const override; #ifdef USE_WEBSERVER_AUTH @@ -143,7 +130,7 @@ class WebServerBase : public Component { protected: int initialized_{0}; uint16_t port_{80}; - std::shared_ptr server_{nullptr}; + std::unique_ptr server_{nullptr}; std::vector handlers_; #ifdef USE_WEBSERVER_AUTH internal::Credentials credentials_; diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index 0dab5e7e8c..af99b85e53 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -87,6 +87,29 @@ int nonblocking_send(httpd_handle_t hd, int sockfd, const char *buf, size_t buf_ } } // namespace +void AsyncWebServer::safe_close_with_shutdown(httpd_handle_t hd, int sockfd) { + // CRITICAL: Shut down receive BEFORE closing to prevent lwIP race conditions + // + // The race condition occurs because close() initiates lwIP teardown while + // the TCP/IP thread can still receive packets, causing assertions when + // recv_tcp() sees partially-torn-down state. + // + // By shutting down receive first, we tell lwIP to stop accepting new data BEFORE + // the teardown begins, eliminating the race window. We only shutdown RD (not RDWR) + // to allow the FIN packet to be sent cleanly during close(). + // + // Note: This function may be called with an already-closed socket if the network + // stack closed it. In that case, shutdown() will fail but close() is safe to call. + // + // See: https://github.com/esphome/esphome-webserver/issues/163 + + // Attempt shutdown - ignore errors as socket may already be closed + shutdown(sockfd, SHUT_RD); + + // Always close - safe even if socket is already closed by network stack + close(sockfd); +} + void AsyncWebServer::end() { if (this->server_) { httpd_stop(this->server_); @@ -94,6 +117,18 @@ void AsyncWebServer::end() { } } +void AsyncWebServer::set_lru_purge_enable(bool enable) { + if (this->lru_purge_enable_ == enable) { + return; // No change needed + } + this->lru_purge_enable_ = enable; + // If server is already running, restart it with new config + if (this->server_) { + this->end(); + this->begin(); + } +} + void AsyncWebServer::begin() { if (this->server_) { this->end(); @@ -101,6 +136,10 @@ void AsyncWebServer::begin() { httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = this->port_; config.uri_match_fn = [](const char * /*unused*/, const char * /*unused*/, size_t /*unused*/) { return true; }; + // Enable LRU purging if requested (e.g., by captive portal to handle probe bursts) + config.lru_purge_enable = this->lru_purge_enable_; + // Use custom close function that shuts down before closing to prevent lwIP race conditions + config.close_fn = AsyncWebServer::safe_close_with_shutdown; if (httpd_start(&this->server_, &config) == ESP_OK) { const httpd_uri_t handler_get = { .uri = "", @@ -242,6 +281,7 @@ void AsyncWebServerRequest::send(int code, const char *content_type, const char void AsyncWebServerRequest::redirect(const std::string &url) { httpd_resp_set_status(*this, "302 Found"); httpd_resp_set_hdr(*this, "Location", url.c_str()); + httpd_resp_set_hdr(*this, "Connection", "close"); httpd_resp_send(*this, nullptr, 0); } @@ -489,10 +529,12 @@ AsyncEventSourceResponse::AsyncEventSourceResponse(const AsyncWebServerRequest * void AsyncEventSourceResponse::destroy(void *ptr) { auto *rsp = static_cast(ptr); - ESP_LOGD(TAG, "Event source connection closed (fd: %d)", rsp->fd_.load()); - // Mark as dead by setting fd to 0 - will be cleaned up in the main loop - rsp->fd_.store(0); + int fd = rsp->fd_.exchange(0); // Atomically get and clear fd + ESP_LOGD(TAG, "Event source connection closed (fd: %d)", fd); + // Mark as dead - will be cleaned up in the main loop // Note: We don't delete or remove from set here to avoid race conditions + // httpd will call our custom close_fn (safe_close_with_shutdown) which handles + // shutdown() before close() to prevent lwIP race conditions } // helper for allowing only unique entries in the queue @@ -622,17 +664,92 @@ bool AsyncEventSourceResponse::try_send_nodefer(const char *message, const char event_buffer_.append(CRLF_STR, CRLF_LEN); } - if (message && *message) { - event_buffer_.append("data: ", sizeof("data: ") - 1); - event_buffer_.append(message); - event_buffer_.append(CRLF_STR, CRLF_LEN); + // Match ESPAsyncWebServer: null message means no data lines and no terminating blank line + if (message) { + // SSE spec requires each line of a multi-line message to have its own "data:" prefix + // Handle \n, \r, and \r\n line endings (matching ESPAsyncWebServer behavior) + + // Fast path: check if message contains any newlines at all + // Most SSE messages (JSON state updates) have no newlines + const char *first_n = strchr(message, '\n'); + const char *first_r = strchr(message, '\r'); + + if (first_n == nullptr && first_r == nullptr) { + // No newlines - fast path (most common case) + event_buffer_.append("data: ", sizeof("data: ") - 1); + event_buffer_.append(message); + event_buffer_.append(CRLF_STR CRLF_STR, CRLF_LEN * 2); // data line + blank line terminator + } else { + // Has newlines - handle multi-line message + const char *line_start = message; + size_t msg_len = strlen(message); + const char *msg_end = message + msg_len; + + // Reuse the first search results + const char *next_n = first_n; + const char *next_r = first_r; + + while (line_start <= msg_end) { + const char *line_end; + const char *next_line; + + if (next_n == nullptr && next_r == nullptr) { + // No more line breaks - output remaining text as final line + event_buffer_.append("data: ", sizeof("data: ") - 1); + event_buffer_.append(line_start); + event_buffer_.append(CRLF_STR, CRLF_LEN); + break; + } + + // Determine line ending type and next line start + if (next_n != nullptr && next_r != nullptr) { + if (next_r + 1 == next_n) { + // \r\n sequence + line_end = next_r; + next_line = next_n + 1; + } else { + // Mixed \n and \r - use whichever comes first + line_end = (next_r < next_n) ? next_r : next_n; + next_line = line_end + 1; + } + } else if (next_n != nullptr) { + // Unix LF + line_end = next_n; + next_line = next_n + 1; + } else { + // Old Mac CR + line_end = next_r; + next_line = next_r + 1; + } + + // Output this line + event_buffer_.append("data: ", sizeof("data: ") - 1); + event_buffer_.append(line_start, line_end - line_start); + event_buffer_.append(CRLF_STR, CRLF_LEN); + + line_start = next_line; + + // Check if we've consumed all content + if (line_start >= msg_end) { + break; + } + + // Search for next newlines only in remaining string + next_n = strchr(line_start, '\n'); + next_r = strchr(line_start, '\r'); + } + + // Terminate message with blank line + event_buffer_.append(CRLF_STR, CRLF_LEN); + } } - if (event_buffer_.empty()) { + if (event_buffer_.size() == static_cast(chunk_len_header_len)) { + // Nothing was added, reset buffer + event_buffer_.resize(0); return true; } - event_buffer_.append(CRLF_STR, CRLF_LEN); event_buffer_.append(CRLF_STR, CRLF_LEN); // chunk length header itself and the final chunk terminating CRLF are not counted as part of the chunk diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index 5ec6fec009..a139e9e4df 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -199,12 +199,17 @@ class AsyncWebServer { return *handler; } + void set_lru_purge_enable(bool enable); + httpd_handle_t get_server() { return this->server_; } + protected: uint16_t port_{}; httpd_handle_t server_{}; + bool lru_purge_enable_{false}; static esp_err_t request_handler(httpd_req_t *r); static esp_err_t request_post_handler(httpd_req_t *r); esp_err_t request_handler_(AsyncWebServerRequest *request) const; + static void safe_close_with_shutdown(httpd_handle_t hd, int sockfd); #ifdef USE_WEBSERVER_OTA esp_err_t handle_multipart_upload_(httpd_req_t *r, const char *content_type); #endif diff --git a/esphome/components/wifi/__init__.py b/esphome/components/wifi/__init__.py index 5f4190a933..2c10506011 100644 --- a/esphome/components/wifi/__init__.py +++ b/esphome/components/wifi/__init__.py @@ -1,12 +1,17 @@ +import logging + from esphome import automation from esphome.automation import Condition import esphome.codegen as cg from esphome.components.const import CONF_USE_PSRAM from esphome.components.esp32 import add_idf_sdkconfig_option, const, get_esp32_variant -from esphome.components.network import ip_address_literal +from esphome.components.network import ( + has_high_performance_networking, + ip_address_literal, +) +from esphome.components.psram import is_guaranteed as psram_is_guaranteed from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv -from esphome.config_validation import only_with_esp_idf from esphome.const import ( CONF_AP, CONF_BSSID, @@ -42,6 +47,7 @@ from esphome.const import ( CONF_TTLS_PHASE_2, CONF_USE_ADDRESS, CONF_USERNAME, + Platform, PlatformFramework, ) from esphome.core import CORE, CoroPriority, HexInt, coroutine_with_priority @@ -49,15 +55,26 @@ import esphome.final_validate as fv from . import wpa2_eap +_LOGGER = logging.getLogger(__name__) + AUTO_LOAD = ["network"] +_LOGGER = logging.getLogger(__name__) + NO_WIFI_VARIANTS = [const.VARIANT_ESP32H2, const.VARIANT_ESP32P4] CONF_SAVE = "save" +CONF_MIN_AUTH_MODE = "min_auth_mode" # Maximum number of WiFi networks that can be configured # Limited to 127 because selected_sta_index_ is int8_t in C++ MAX_WIFI_NETWORKS = 127 +# Default AP timeout - allows sufficient time to try all BSSIDs during initial connection +# After AP starts, WiFi scanning is skipped to avoid disrupting the AP, so we only +# get best-effort connection attempts. Longer timeout ensures we exhaust all options +# before falling back to AP mode. Aligned with improv wifi_timeout default. +DEFAULT_AP_TIMEOUT = "90s" + wifi_ns = cg.esphome_ns.namespace("wifi") EAPAuth = wifi_ns.struct("EAPAuth") ManualIP = wifi_ns.struct("ManualIP") @@ -70,8 +87,17 @@ WIFI_POWER_SAVE_MODES = { "LIGHT": WiFiPowerSaveMode.WIFI_POWER_SAVE_LIGHT, "HIGH": WiFiPowerSaveMode.WIFI_POWER_SAVE_HIGH, } + +WifiMinAuthMode = wifi_ns.enum("WifiMinAuthMode") +WIFI_MIN_AUTH_MODES = { + "WPA": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA, + "WPA2": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA2, + "WPA3": WifiMinAuthMode.WIFI_MIN_AUTH_MODE_WPA3, +} +VALIDATE_WIFI_MIN_AUTH_MODE = cv.enum(WIFI_MIN_AUTH_MODES, upper=True) WiFiConnectedCondition = wifi_ns.class_("WiFiConnectedCondition", Condition) WiFiEnabledCondition = wifi_ns.class_("WiFiEnabledCondition", Condition) +WiFiAPActiveCondition = wifi_ns.class_("WiFiAPActiveCondition", Condition) WiFiEnableAction = wifi_ns.class_("WiFiEnableAction", automation.Action) WiFiDisableAction = wifi_ns.class_("WiFiDisableAction", automation.Action) WiFiConfigureAction = wifi_ns.class_( @@ -158,7 +184,7 @@ CONF_AP_TIMEOUT = "ap_timeout" WIFI_NETWORK_AP = WIFI_NETWORK_BASE.extend( { cv.Optional( - CONF_AP_TIMEOUT, default="1min" + CONF_AP_TIMEOUT, default=DEFAULT_AP_TIMEOUT ): cv.positive_time_period_milliseconds, } ) @@ -174,7 +200,7 @@ WIFI_NETWORK_STA = WIFI_NETWORK_BASE.extend( { cv.Optional(CONF_BSSID): cv.mac_address, cv.Optional(CONF_HIDDEN): cv.boolean, - cv.Optional(CONF_PRIORITY, default=0.0): cv.float_, + cv.Optional(CONF_PRIORITY, default=0): cv.int_range(min=-128, max=127), cv.Optional(CONF_EAP): EAP_AUTH_SCHEMA, } ) @@ -187,6 +213,27 @@ def validate_variant(_): raise cv.Invalid(f"WiFi requires component esp32_hosted on {variant}") +def _apply_min_auth_mode_default(config): + """Apply platform-specific default for min_auth_mode and warn ESP8266 users.""" + # Only apply defaults for platforms that support min_auth_mode + if CONF_MIN_AUTH_MODE not in config and (CORE.is_esp8266 or CORE.is_esp32): + if CORE.is_esp8266: + _LOGGER.warning( + "The minimum WiFi authentication mode (wifi -> min_auth_mode) is not set. " + "This controls the weakest encryption your device will accept when connecting to WiFi. " + "Currently defaults to WPA (less secure), but will change to WPA2 (more secure) in 2026.6.0. " + "WPA uses TKIP encryption which has known security vulnerabilities and should be avoided. " + "WPA2 uses AES encryption which is significantly more secure. " + "To silence this warning, explicitly set min_auth_mode under 'wifi:'. " + "If your router supports WPA2 or WPA3, set 'min_auth_mode: WPA2'. " + "If your router only supports WPA, set 'min_auth_mode: WPA'." + ) + config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA") + elif CORE.is_esp32: + config[CONF_MIN_AUTH_MODE] = VALIDATE_WIFI_MIN_AUTH_MODE("WPA2") + return config + + def final_validate(config): has_sta = bool(config.get(CONF_NETWORKS, True)) has_ap = CONF_AP in config @@ -287,6 +334,10 @@ CONFIG_SCHEMA = cv.All( ): cv.enum(WIFI_POWER_SAVE_MODES, upper=True), cv.Optional(CONF_FAST_CONNECT, default=False): cv.boolean, cv.Optional(CONF_USE_ADDRESS): cv.string_strict, + cv.Optional(CONF_MIN_AUTH_MODE): cv.All( + VALIDATE_WIFI_MIN_AUTH_MODE, + cv.only_on([Platform.ESP32, Platform.ESP8266]), + ), cv.SplitDefault(CONF_OUTPUT_POWER, esp8266=20.0): cv.All( cv.decibel, cv.float_range(min=8.5, max=20.5) ), @@ -307,10 +358,11 @@ CONFIG_SCHEMA = cv.All( single=True ), cv.Optional(CONF_USE_PSRAM): cv.All( - only_with_esp_idf, cv.requires_component("psram"), cv.boolean + cv.only_on_esp32, cv.requires_component("psram"), cv.boolean ), } ), + _apply_min_auth_mode_default, _validate, ) @@ -385,6 +437,8 @@ async def to_code(config): # Track if any network uses Enterprise authentication has_eap = False + # Track if any network uses manual IP + has_manual_ip = False # Initialize FixedVector with the count of networks networks = config.get(CONF_NETWORKS, []) @@ -398,11 +452,15 @@ async def to_code(config): for network in networks: if CONF_EAP in network: has_eap = True + if network.get(CONF_MANUAL_IP) or config.get(CONF_MANUAL_IP): + has_manual_ip = True cg.with_local_variable(network[CONF_ID], WiFiAP(), add_sta, network) if CONF_AP in config: conf = config[CONF_AP] ip_config = conf.get(CONF_MANUAL_IP) + if ip_config: + has_manual_ip = True cg.with_local_variable( conf[CONF_ID], WiFiAP(), @@ -418,15 +476,24 @@ async def to_code(config): if CORE.is_esp32: add_idf_sdkconfig_option("CONFIG_ESP_WIFI_ENTERPRISE_SUPPORT", has_eap) + # Only define USE_WIFI_MANUAL_IP if any AP uses manual IP + if has_manual_ip: + cg.add_define("USE_WIFI_MANUAL_IP") + cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_power_save_mode(config[CONF_POWER_SAVE_MODE])) + if CONF_MIN_AUTH_MODE in config: + cg.add(var.set_min_auth_mode(config[CONF_MIN_AUTH_MODE])) if config[CONF_FAST_CONNECT]: cg.add_define("USE_WIFI_FAST_CONNECT") - cg.add(var.set_passive_scan(config[CONF_PASSIVE_SCAN])) + # passive_scan defaults to false in C++ - only set if true + if config[CONF_PASSIVE_SCAN]: + cg.add(var.set_passive_scan(True)) if CONF_OUTPUT_POWER in config: cg.add(var.set_output_power(config[CONF_OUTPUT_POWER])) - - cg.add(var.set_enable_on_boot(config[CONF_ENABLE_ON_BOOT])) + # enable_on_boot defaults to true in C++ - only set if false + if not config[CONF_ENABLE_ON_BOOT]: + cg.add(var.set_enable_on_boot(False)) if CORE.is_esp8266: cg.add_library("ESP8266WiFi", None) @@ -444,6 +511,56 @@ async def to_code(config): if config.get(CONF_USE_PSRAM): add_idf_sdkconfig_option("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP", True) + + # Apply high performance WiFi settings if high performance networking is enabled + if CORE.is_esp32 and CORE.using_esp_idf and has_high_performance_networking(): + # Check if PSRAM is guaranteed (set by psram component during final validation) + psram_guaranteed = psram_is_guaranteed() + + # Always allocate WiFi buffers in PSRAM if available + add_idf_sdkconfig_option("CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP", True) + + if psram_guaranteed: + _LOGGER.info( + "Applying high-performance WiFi settings (PSRAM guaranteed): 512 RX buffers, 32 TX buffers" + ) + # PSRAM is guaranteed - use aggressive settings + # Higher maximum values are allowed because CONFIG_LWIP_WND_SCALE is set to true in networking component + # Based on https://github.com/espressif/esp-adf/issues/297#issuecomment-783811702 + + # Large dynamic RX buffers (requires PSRAM) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM", 16) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM", 512) + + # Static TX buffers for better performance + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STATIC_TX_BUFFER", True) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_TX_BUFFER_TYPE", 0) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_CACHE_TX_BUFFER_NUM", 32) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STATIC_TX_BUFFER_NUM", 8) + + # AMPDU settings optimized for PSRAM + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_TX_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_TX_BA_WIN", 16) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_RX_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_RX_BA_WIN", 32) + else: + _LOGGER.info( + "Applying optimized WiFi settings: 64 RX buffers, 64 TX buffers" + ) + # PSRAM not guaranteed - use more conservative, but still optimized settings + # Based on https://github.com/espressif/esp-idf/blob/release/v5.4/examples/wifi/iperf/sdkconfig.defaults.esp32 + + # Standard buffer counts + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM", 16) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM", 64) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_DYNAMIC_TX_BUFFER_NUM", 64) + + # Standard AMPDU settings + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_TX_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_TX_BA_WIN", 32) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_AMPDU_RX_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_ESP_WIFI_RX_BA_WIN", 32) + cg.add_define("USE_WIFI") # must register before OTA safe mode check @@ -474,6 +591,11 @@ async def wifi_enabled_to_code(config, condition_id, template_arg, args): return cg.new_Pvariable(condition_id, template_arg) +@automation.register_condition("wifi.ap_active", WiFiAPActiveCondition, cv.Schema({})) +async def wifi_ap_active_to_code(config, condition_id, template_arg, args): + return cg.new_Pvariable(condition_id, template_arg) + + @automation.register_action("wifi.enable", WiFiEnableAction, cv.Schema({})) async def wifi_enable_to_code(config, action_id, template_arg, args): return cg.new_Pvariable(action_id, template_arg) @@ -485,6 +607,8 @@ async def wifi_disable_to_code(config, action_id, template_arg, args): KEEP_SCAN_RESULTS_KEY = "wifi_keep_scan_results" +RUNTIME_POWER_SAVE_KEY = "wifi_runtime_power_save" +WIFI_LISTENERS_KEY = "wifi_listeners" def request_wifi_scan_results(): @@ -497,13 +621,41 @@ def request_wifi_scan_results(): CORE.data[KEEP_SCAN_RESULTS_KEY] = True +def enable_runtime_power_save_control(): + """Enable runtime WiFi power save control. + + Components that need to dynamically switch WiFi power saving on/off for latency + performance (e.g., audio streaming, large data transfers) should call this + function during their code generation. This enables the request_high_performance() + and release_high_performance() APIs. + + Only supported on ESP32. + """ + CORE.data[RUNTIME_POWER_SAVE_KEY] = True + + +def request_wifi_listeners() -> None: + """Request that WiFi state listeners be compiled in. + + Components that need to be notified about WiFi state changes (IP address changes, + scan results, connection state) should call this function during their code generation. + This enables the add_ip_state_listener(), add_scan_results_listener(), + and add_connect_state_listener() APIs. + """ + CORE.data[WIFI_LISTENERS_KEY] = True + + @coroutine_with_priority(CoroPriority.FINAL) async def final_step(): - """Final code generation step to configure scan result retention.""" + """Final code generation step to configure optional WiFi features.""" if CORE.data.get(KEEP_SCAN_RESULTS_KEY, False): cg.add( cg.RawExpression("wifi::global_wifi_component->set_keep_scan_results(true)") ) + if CORE.data.get(RUNTIME_POWER_SAVE_KEY, False): + cg.add_define("USE_WIFI_RUNTIME_POWER_SAVE") + if CORE.data.get(WIFI_LISTENERS_KEY, False): + cg.add_define("USE_WIFI_LISTENERS") @automation.register_action( diff --git a/esphome/components/wifi/automation.h b/esphome/components/wifi/automation.h new file mode 100644 index 0000000000..7997baff65 --- /dev/null +++ b/esphome/components/wifi/automation.h @@ -0,0 +1,116 @@ +#pragma once + +#include "esphome/core/defines.h" +#ifdef USE_WIFI +#include "wifi_component.h" + +namespace esphome::wifi { + +template class WiFiConnectedCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_wifi_component->is_connected(); } +}; + +template class WiFiEnabledCondition : public Condition { + public: + bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } +}; + +template class WiFiAPActiveCondition : public Condition { + public: + bool check(const Ts &...x) override { return global_wifi_component->is_ap_active(); } +}; + +template class WiFiEnableAction : public Action { + public: + void play(const Ts &...x) override { global_wifi_component->enable(); } +}; + +template class WiFiDisableAction : public Action { + public: + void play(const Ts &...x) override { global_wifi_component->disable(); } +}; + +template class WiFiConfigureAction : public Action, public Component { + public: + TEMPLATABLE_VALUE(std::string, ssid) + TEMPLATABLE_VALUE(std::string, password) + TEMPLATABLE_VALUE(bool, save) + TEMPLATABLE_VALUE(uint32_t, connection_timeout) + + void play(const Ts &...x) override { + auto ssid = this->ssid_.value(x...); + auto password = this->password_.value(x...); + // Avoid multiple calls + if (this->connecting_) + return; + // If already connected to the same AP, do nothing + if (global_wifi_component->wifi_ssid() == ssid) { + // Callback to notify the user that the connection was successful + this->connect_trigger_->trigger(); + return; + } + // Create a new WiFiAP object with the new SSID and password + this->new_sta_.set_ssid(ssid); + this->new_sta_.set_password(password); + // Save the current STA + this->old_sta_ = global_wifi_component->get_sta(); + // Disable WiFi + global_wifi_component->disable(); + // Set the state to connecting + this->connecting_ = true; + // Store the new STA so once the WiFi is enabled, it will connect to it + // This is necessary because the WiFiComponent will raise an error and fallback to the saved STA + // if trying to connect to a new STA while already connected to another one + if (this->save_.value(x...)) { + global_wifi_component->save_wifi_sta(new_sta_.get_ssid(), new_sta_.get_password()); + } else { + global_wifi_component->set_sta(new_sta_); + } + // Enable WiFi + global_wifi_component->enable(); + // Set timeout for the connection + this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this, x...]() { + // If the timeout is reached, stop connecting and revert to the old AP + global_wifi_component->disable(); + global_wifi_component->save_wifi_sta(old_sta_.get_ssid(), old_sta_.get_password()); + global_wifi_component->enable(); + // Start a timeout for the fallback if the connection to the old AP fails + this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() { + this->connecting_ = false; + this->error_trigger_->trigger(); + }); + }); + } + + Trigger<> *get_connect_trigger() const { return this->connect_trigger_; } + Trigger<> *get_error_trigger() const { return this->error_trigger_; } + + void loop() override { + if (!this->connecting_) + return; + if (global_wifi_component->is_connected()) { + // The WiFi is connected, stop the timeout and reset the connecting flag + this->cancel_timeout("wifi-connect-timeout"); + this->cancel_timeout("wifi-fallback-timeout"); + this->connecting_ = false; + if (global_wifi_component->wifi_ssid() == this->new_sta_.get_ssid()) { + // Callback to notify the user that the connection was successful + this->connect_trigger_->trigger(); + } else { + // Callback to notify the user that the connection failed + this->error_trigger_->trigger(); + } + } + } + + protected: + bool connecting_{false}; + WiFiAP new_sta_; + WiFiAP old_sta_; + Trigger<> *connect_trigger_{new Trigger<>()}; + Trigger<> *error_trigger_{new Trigger<>()}; +}; + +} // namespace esphome::wifi +#endif diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 789c22bae1..317507f242 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -37,11 +37,275 @@ #include "esphome/components/esp32_improv/esp32_improv_component.h" #endif -namespace esphome { -namespace wifi { +namespace esphome::wifi { static const char *const TAG = "wifi"; +/// WiFi Retry Logic - Priority-Based BSSID Selection +/// +/// The WiFi component uses a state machine with priority degradation to handle connection failures +/// and automatically cycle through different BSSIDs in mesh networks or multiple configured networks. +/// +/// Connection Flow: +/// ┌──────────────────────────────────────────────────────────────────────┐ +/// │ Fast Connect Path (Optional) │ +/// ├──────────────────────────────────────────────────────────────────────┤ +/// │ Entered if: configuration has 'fast_connect: true' │ +/// │ Optimization to skip scanning when possible: │ +/// │ │ +/// │ 1. INITIAL_CONNECT → Try one of: │ +/// │ a) Saved BSSID+channel (from previous boot) │ +/// │ b) First configured non-hidden network (any BSSID) │ +/// │ ↓ │ +/// │ [FAILED] → Check if more configured networks available │ +/// │ ↓ │ +/// │ 2. FAST_CONNECT_CYCLING_APS → Try remaining configured networks │ +/// │ (1 attempt each, any BSSID) │ +/// │ ↓ │ +/// │ [All Failed] → Fall through to explicit hidden or scanning │ +/// │ │ +/// │ Note: Fast connect data saved from previous successful connection │ +/// └──────────────────────────────────────────────────────────────────────┘ +/// ↓ +/// ┌──────────────────────────────────────────────────────────────────────┐ +/// │ Explicit Hidden Networks Path (Optional) │ +/// ├──────────────────────────────────────────────────────────────────────┤ +/// │ Entered if: first configured network has 'hidden: true' │ +/// │ │ +/// │ 1. EXPLICIT_HIDDEN → Try consecutive hidden networks (1 attempt) │ +/// │ Stop when visible network reached │ +/// │ ↓ │ +/// │ Example: Hidden1, Hidden2, Visible1, Hidden3, Visible2 │ +/// │ Try: Hidden1, Hidden2 (stop at Visible1) │ +/// │ ↓ │ +/// │ [All Failed] → Fall back to scan-based connection │ +/// │ │ +/// │ Note: Fast connect saves BSSID after first successful connection, │ +/// │ so subsequent boots use fast path instead of hidden mode │ +/// └──────────────────────────────────────────────────────────────────────┘ +/// ↓ +/// ┌──────────────────────────────────────────────────────────────────────┐ +/// │ Scan-Based Connection Path │ +/// ├──────────────────────────────────────────────────────────────────────┤ +/// │ │ +/// │ 1. SCAN → Sort by priority (highest first), then RSSI │ +/// │ ┌─────────────────────────────────────────────────┐ │ +/// │ │ scan_result_[0] = Best BSSID (highest priority) │ │ +/// │ │ scan_result_[1] = Second best │ │ +/// │ │ scan_result_[2] = Third best │ │ +/// │ └─────────────────────────────────────────────────┘ │ +/// │ ↓ │ +/// │ 2. SCAN_CONNECTING → Try scan_result_[0] (2 attempts) │ +/// │ (Visible1, Visible2 from example above) │ +/// │ ↓ │ +/// │ 3. FAILED → Decrease priority: 0.0 → -1.0 → -2.0 │ +/// │ (stored in persistent sta_priorities_) │ +/// │ ↓ │ +/// │ 4. Check for hidden networks: │ +/// │ - If found → RETRY_HIDDEN (try SSIDs not in scan, 1 attempt) │ +/// │ Skip hidden networks before first visible one │ +/// │ (Skip Hidden1/Hidden2, try Hidden3 from example) │ +/// │ - If none → Skip RETRY_HIDDEN, go to step 5 │ +/// │ ↓ │ +/// │ 5. FAILED → RESTARTING_ADAPTER (skipped if AP/improv active) │ +/// │ ↓ │ +/// │ 6. Loop back to start: │ +/// │ - If first network is hidden → EXPLICIT_HIDDEN (retry cycle) │ +/// │ - Otherwise → SCAN_CONNECTING (rescan) │ +/// │ ↓ │ +/// │ 7. RESCAN → Apply stored priorities, sort again │ +/// │ ┌─────────────────────────────────────────────────┐ │ +/// │ │ scan_result_[0] = BSSID B (priority 0.0) ← NEW │ │ +/// │ │ scan_result_[1] = BSSID C (priority 0.0) │ │ +/// │ │ scan_result_[2] = BSSID A (priority -2.0) ← OLD │ │ +/// │ └─────────────────────────────────────────────────┘ │ +/// │ ↓ │ +/// │ 8. SCAN_CONNECTING → Try scan_result_[0] (next best) │ +/// │ │ +/// │ Key: Priority system cycles through BSSIDs ACROSS scan cycles │ +/// │ Full retry cycle: EXPLICIT_HIDDEN → SCAN → RETRY_HIDDEN │ +/// │ Always try best available BSSID (scan_result_[0]) │ +/// └──────────────────────────────────────────────────────────────────────┘ +/// +/// Retry Phases: +/// - INITIAL_CONNECT: Try saved BSSID+channel (fast_connect), or fall back to normal flow +/// - FAST_CONNECT_CYCLING_APS: Cycle through remaining configured networks (1 attempt each, fast_connect only) +/// - EXPLICIT_HIDDEN: Try consecutive networks marked hidden:true before scanning (1 attempt per SSID) +/// - SCAN_CONNECTING: Connect using scan results (2 attempts per BSSID) +/// - RETRY_HIDDEN: Try networks not found in scan (1 attempt per SSID, skipped if none found) +/// - RESTARTING_ADAPTER: Restart WiFi adapter to clear stuck state +/// +/// Hidden Network Handling: +/// - Networks marked 'hidden: true' before first non-hidden → Tried in EXPLICIT_HIDDEN phase +/// - Networks marked 'hidden: true' after first non-hidden → Tried in RETRY_HIDDEN phase +/// - After successful connection, fast_connect saves BSSID → subsequent boots use fast path +/// - Networks not in scan results → Tried in RETRY_HIDDEN phase +/// - Networks visible in scan + not marked hidden → Skipped in RETRY_HIDDEN phase +/// - Networks marked 'hidden: true' always use hidden mode, even if broadcasting SSID + +static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) { + switch (phase) { + case WiFiRetryPhase::INITIAL_CONNECT: + return LOG_STR("INITIAL_CONNECT"); +#ifdef USE_WIFI_FAST_CONNECT + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: + return LOG_STR("FAST_CONNECT_CYCLING"); +#endif + case WiFiRetryPhase::EXPLICIT_HIDDEN: + return LOG_STR("EXPLICIT_HIDDEN"); + case WiFiRetryPhase::SCAN_CONNECTING: + return LOG_STR("SCAN_CONNECTING"); + case WiFiRetryPhase::RETRY_HIDDEN: + return LOG_STR("RETRY_HIDDEN"); + case WiFiRetryPhase::RESTARTING_ADAPTER: + return LOG_STR("RESTARTING"); + default: + return LOG_STR("UNKNOWN"); + } +} + +bool WiFiComponent::went_through_explicit_hidden_phase_() const { + // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase + // This means those networks were already tried and should be skipped in RETRY_HIDDEN + return !this->sta_.empty() && this->sta_[0].get_hidden(); +} + +int8_t WiFiComponent::find_first_non_hidden_index_() const { + // Find the first network that is NOT marked hidden:true + // This is where EXPLICIT_HIDDEN phase would have stopped + for (size_t i = 0; i < this->sta_.size(); i++) { + if (!this->sta_[i].get_hidden()) { + return static_cast(i); + } + } + return -1; // All networks are hidden +} + +// 2 attempts per BSSID in SCAN_CONNECTING phase +// Rationale: This is the ONLY phase where we decrease BSSID priority, so we must be very sure. +// Auth failures are common immediately after scan due to WiFi stack state transitions. +// Trying twice filters out false positives and prevents unnecessarily marking a good BSSID as bad. +// After 2 genuine failures, priority degradation ensures we skip this BSSID on subsequent scans. +static constexpr uint8_t WIFI_RETRY_COUNT_PER_BSSID = 2; + +// 1 attempt per SSID in RETRY_HIDDEN phase +// Rationale: Try hidden mode once, then rescan to get next best BSSID via priority system +static constexpr uint8_t WIFI_RETRY_COUNT_PER_SSID = 1; + +// 1 attempt per AP in fast_connect mode (INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS) +// Rationale: Fast connect prioritizes speed - try each AP once to find a working one quickly +static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1; + +/// Cooldown duration in milliseconds after adapter restart or repeated failures +/// Allows WiFi hardware to stabilize before next connection attempt +static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; + +/// Cooldown duration when fallback AP is active and captive portal may be running +/// Longer interval gives users time to configure WiFi without constant connection attempts +/// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown +static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000; + +static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { + switch (phase) { + case WiFiRetryPhase::INITIAL_CONNECT: +#ifdef USE_WIFI_FAST_CONNECT + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: +#endif + // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS both use 1 attempt per AP (fast_connect mode) + return WIFI_RETRY_COUNT_PER_AP; + case WiFiRetryPhase::EXPLICIT_HIDDEN: + // Explicitly hidden network: 1 attempt (user marked as hidden, try once then scan) + return WIFI_RETRY_COUNT_PER_SSID; + case WiFiRetryPhase::SCAN_CONNECTING: + // Scan-based phase: 2 attempts per BSSID (handles transient auth failures after scan) + return WIFI_RETRY_COUNT_PER_BSSID; + case WiFiRetryPhase::RETRY_HIDDEN: + // Hidden network mode: 1 attempt per SSID + return WIFI_RETRY_COUNT_PER_SSID; + default: + return WIFI_RETRY_COUNT_PER_BSSID; + } +} + +static void apply_scan_result_to_params(WiFiAP ¶ms, const WiFiScanResult &scan) { + params.set_hidden(false); + params.set_ssid(scan.get_ssid()); + params.set_bssid(scan.get_bssid()); + params.set_channel(scan.get_channel()); +} + +bool WiFiComponent::needs_scan_results_() const { + // Only SCAN_CONNECTING phase needs scan results + if (this->retry_phase_ != WiFiRetryPhase::SCAN_CONNECTING) { + return false; + } + // Need scan if we have no results or no matching networks + return this->scan_result_.empty() || !this->scan_result_[0].get_matches(); +} + +bool WiFiComponent::ssid_was_seen_in_scan_(const std::string &ssid) const { + // Check if this SSID is configured as hidden + // If explicitly marked hidden, we should always try hidden mode regardless of scan results + for (const auto &conf : this->sta_) { + if (conf.get_ssid() == ssid && conf.get_hidden()) { + return false; // Treat as not seen - force hidden mode attempt + } + } + + // Otherwise, check if we saw it in scan results + for (const auto &scan : this->scan_result_) { + if (scan.get_ssid() == ssid) { + return true; + } + } + return false; +} + +int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) { + // Find next SSID that wasn't in scan results (might be hidden) + bool include_explicit_hidden = !this->went_through_explicit_hidden_phase_(); + // Start searching from start_index + 1 + for (size_t i = start_index + 1; i < this->sta_.size(); i++) { + const auto &sta = this->sta_[i]; + + // Skip networks that were already tried in EXPLICIT_HIDDEN phase + // Those are: networks marked hidden:true that appear before the first non-hidden network + // If all networks are hidden (first_non_hidden_idx == -1), skip all of them + if (!include_explicit_hidden && sta.get_hidden()) { + int8_t first_non_hidden_idx = this->find_first_non_hidden_index_(); + if (first_non_hidden_idx < 0 || static_cast(i) < first_non_hidden_idx) { + ESP_LOGD(TAG, "Skipping " LOG_SECRET("'%s'") " (explicit hidden, already tried)", sta.get_ssid().c_str()); + continue; + } + } + + // If we didn't scan this cycle, treat all networks as potentially hidden + // Otherwise, only retry networks that weren't seen in the scan + if (!this->did_scan_this_cycle_ || !this->ssid_was_seen_in_scan_(sta.get_ssid())) { + ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.get_ssid().c_str(), static_cast(i)); + return static_cast(i); + } + ESP_LOGD(TAG, "Skipping hidden retry for visible network " LOG_SECRET("'%s'"), sta.get_ssid().c_str()); + } + // No hidden SSIDs found + return -1; +} + +void WiFiComponent::start_initial_connection_() { + // If first network (highest priority) is explicitly marked hidden, try it first before scanning + // This respects user's priority order when they explicitly configure hidden networks + if (!this->sta_.empty() && this->sta_[0].get_hidden()) { + ESP_LOGI(TAG, "Starting with explicit hidden network (highest priority)"); + this->selected_sta_index_ = 0; + this->retry_phase_ = WiFiRetryPhase::EXPLICIT_HIDDEN; + WiFiAP params = this->build_params_for_current_phase_(); + this->start_connecting(params); + } else { + ESP_LOGI(TAG, "Starting scan"); + this->start_scanning(); + } +} + #if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) { switch (type) { @@ -65,6 +329,19 @@ float WiFiComponent::get_setup_priority() const { return setup_priority::WIFI; } void WiFiComponent::setup() { this->wifi_pre_setup_(); + +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) + // Create semaphore for high-performance mode requests + // Start at 0, increment on request, decrement on release + this->high_performance_semaphore_ = xSemaphoreCreateCounting(UINT32_MAX, 0); + if (this->high_performance_semaphore_ == nullptr) { + ESP_LOGE(TAG, "Failed semaphore"); + } + + // Store the configured power save mode as baseline + this->configured_power_save_ = this->power_save_; +#endif + if (this->enable_on_boot_) { this->start(); } else { @@ -76,10 +353,11 @@ void WiFiComponent::setup() { } void WiFiComponent::start() { + char mac_s[18]; ESP_LOGCONFIG(TAG, "Starting\n" " Local MAC: %s", - get_mac_address_pretty().c_str()); + get_mac_address_pretty_into_buffer(mac_s)); this->last_connected_ = millis(); uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time()) : 88491487UL; @@ -105,22 +383,45 @@ void WiFiComponent::start() { ESP_LOGV(TAG, "Setting Output Power Option failed"); } +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) + // Synchronize power_save_ with semaphore state before applying + if (this->high_performance_semaphore_ != nullptr) { + UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_); + if (semaphore_count > 0) { + this->power_save_ = WIFI_POWER_SAVE_NONE; + this->is_high_performance_mode_ = true; + } else { + this->power_save_ = this->configured_power_save_; + this->is_high_performance_mode_ = false; + } + } +#endif if (!this->wifi_apply_power_save_()) { ESP_LOGV(TAG, "Setting Power Save Option failed"); } + this->transition_to_phase_(WiFiRetryPhase::INITIAL_CONNECT); #ifdef USE_WIFI_FAST_CONNECT WiFiAP params; - this->trying_loaded_ap_ = this->load_fast_connect_settings_(params); - if (!this->trying_loaded_ap_) { - // FAST CONNECT FALLBACK: No saved settings available - // Use first config (will use SSID from config) + bool loaded_fast_connect = this->load_fast_connect_settings_(params); + // Fast connect optimization: only use when we have saved BSSID+channel data + // Without saved data, try first configured network or use normal flow + if (loaded_fast_connect) { + ESP_LOGI(TAG, "Starting fast_connect (saved) " LOG_SECRET("'%s'"), params.get_ssid().c_str()); + this->start_connecting(params); + } else if (!this->sta_.empty() && !this->sta_[0].get_hidden()) { + // No saved data, but have configured networks - try first non-hidden network + ESP_LOGI(TAG, "Starting fast_connect (config) " LOG_SECRET("'%s'"), this->sta_[0].get_ssid().c_str()); this->selected_sta_index_ = 0; - params = this->build_wifi_ap_from_selected_(); + params = this->build_params_for_current_phase_(); + this->start_connecting(params); + } else { + // No saved data and (no networks OR first is hidden) - use normal flow + this->start_initial_connection_(); } - this->start_connecting(params, false); #else - this->start_scanning(); + // Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden) + this->start_initial_connection_(); #endif #ifdef USE_WIFI_AP } else if (this->has_ap()) { @@ -149,9 +450,7 @@ void WiFiComponent::start() { void WiFiComponent::restart_adapter() { ESP_LOGW(TAG, "Restarting adapter"); this->wifi_mode_(false, {}); - delay(100); // NOLINT - this->num_retried_ = 0; - this->retry_hidden_ = false; + this->error_from_callback_ = false; } void WiFiComponent::loop() { @@ -171,22 +470,21 @@ void WiFiComponent::loop() { switch (this->state_) { case WIFI_COMPONENT_STATE_COOLDOWN: { this->status_set_warning(LOG_STR("waiting to reconnect")); - if (millis() - this->action_started_ > 5000) { -#ifdef USE_WIFI_FAST_CONNECT - // Safety check: Ensure selected_sta_index_ is valid before retrying - // (should already be set by retry_connect(), but check for robustness) - this->reset_selected_ap_to_first_if_invalid_(); - WiFiAP params = this->build_wifi_ap_from_selected_(); - this->start_connecting(params, false); -#else - if (this->retry_hidden_) { - this->reset_selected_ap_to_first_if_invalid_(); - WiFiAP params = this->build_wifi_ap_from_selected_(); - this->start_connecting(params, false); - } else { - this->start_scanning(); - } -#endif + // Skip cooldown if new credentials were provided while connecting + if (this->skip_cooldown_next_cycle_) { + this->skip_cooldown_next_cycle_ = false; + this->check_connecting_finished(); + break; + } + // Use longer cooldown when captive portal/improv is active to avoid disrupting user config + bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_(); + uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS; + if (now - this->action_started_ > cooldown_duration) { + // After cooldown we either restarted the adapter because of + // a failure, or something tried to connect over and over + // so we entered cooldown. In both cases we call + // check_connecting_finished to continue the state machine. + this->check_connecting_finished(); } break; } @@ -195,8 +493,7 @@ void WiFiComponent::loop() { this->check_scanning_finished(); break; } - case WIFI_COMPONENT_STATE_STA_CONNECTING: - case WIFI_COMPONENT_STATE_STA_CONNECTING_2: { + case WIFI_COMPONENT_STATE_STA_CONNECTING: { this->status_set_warning(LOG_STR("associating to network")); this->check_connecting_finished(); break; @@ -206,6 +503,8 @@ void WiFiComponent::loop() { if (!this->is_connected()) { ESP_LOGW(TAG, "Connection lost; reconnecting"); this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; + // Clear error flag before reconnecting so first attempt is not seen as immediate failure + this->error_from_callback_ = false; this->retry_connect(); } else { this->status_clear_warning(); @@ -234,7 +533,8 @@ void WiFiComponent::loop() { #endif // USE_WIFI_AP #ifdef USE_IMPROV - if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active()) { + if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active() && + !esp32_improv::global_improv_component->should_start()) { if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) { if (this->wifi_mode_(true, {})) esp32_improv::global_improv_component->start(); @@ -250,11 +550,37 @@ void WiFiComponent::loop() { } } } + +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) + // Check if power save mode needs to be updated based on high-performance requests + if (this->high_performance_semaphore_ != nullptr) { + // Semaphore count directly represents active requests (starts at 0, increments on request) + UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_); + + if (semaphore_count > 0 && !this->is_high_performance_mode_) { + // Transition to high-performance mode (no power save) + ESP_LOGV(TAG, "Switching to high-performance mode (%" PRIu32 " active %s)", (uint32_t) semaphore_count, + semaphore_count == 1 ? "request" : "requests"); + this->power_save_ = WIFI_POWER_SAVE_NONE; + if (this->wifi_apply_power_save_()) { + this->is_high_performance_mode_ = true; + } + } else if (semaphore_count == 0 && this->is_high_performance_mode_) { + // Restore to configured power save mode + ESP_LOGV(TAG, "Restoring power save mode to configured setting"); + this->power_save_ = this->configured_power_save_; + if (this->wifi_apply_power_save_()) { + this->is_high_performance_mode_ = false; + } + } + } +#endif } WiFiComponent::WiFiComponent() { global_wifi_component = this; } bool WiFiComponent::has_ap() const { return this->has_ap_; } +bool WiFiComponent::is_ap_active() const { return this->ap_started_; } bool WiFiComponent::has_sta() const { return !this->sta_.empty(); } #ifdef USE_WIFI_11KV_SUPPORT void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; } @@ -310,6 +636,7 @@ void WiFiComponent::setup_ap_config_() { " IP Address: %s", this->ap_.get_ssid().c_str(), this->ap_.get_password().c_str(), ip_address.c_str()); +#ifdef USE_WIFI_MANUAL_IP auto manual_ip = this->ap_.get_manual_ip(); if (manual_ip.has_value()) { ESP_LOGCONFIG(TAG, @@ -319,6 +646,7 @@ void WiFiComponent::setup_ap_config_() { manual_ip->static_ip.str().c_str(), manual_ip->gateway.str().c_str(), manual_ip->subnet.str().c_str()); } +#endif if (!this->has_sta()) { this->state_ = WIFI_COMPONENT_STATE_AP; @@ -342,32 +670,48 @@ void WiFiComponent::set_sta(const WiFiAP &ap) { this->init_sta(1); this->add_sta(ap); this->selected_sta_index_ = 0; + // When new credentials are set (e.g., from improv), skip cooldown to retry immediately + this->skip_cooldown_next_cycle_ = true; } -WiFiAP WiFiComponent::build_wifi_ap_from_selected_() const { - // PRECONDITION: selected_sta_index_ must be valid (ensured by all callers) +WiFiAP WiFiComponent::build_params_for_current_phase_() { const WiFiAP *config = this->get_selected_sta_(); - assert(config != nullptr); + if (config == nullptr) { + ESP_LOGE(TAG, "No valid network config (selected_sta_index_=%d, sta_.size()=%zu)", + static_cast(this->selected_sta_index_), this->sta_.size()); + // Return empty params - caller should handle this gracefully + return WiFiAP(); + } + WiFiAP params = *config; - // SYNCHRONIZATION: selected_sta_index_ and scan_result_[0] are kept in sync after wifi_scan_done(): - // - wifi_scan_done() sorts all scan results by priority/RSSI (best first) - // - It then finds which sta_[i] config matches scan_result_[0] - // - Sets selected_sta_index_ = i to record that matching config - // This sync holds until scan_result_ is cleared (e.g., after connection or in reset_for_next_ap_attempt_()) - if (!this->scan_result_.empty()) { - // Override with scan data - network is visible - const WiFiScanResult &scan = this->scan_result_[0]; - params.set_hidden(false); - params.set_ssid(scan.get_ssid()); - params.set_bssid(scan.get_bssid()); - params.set_channel(scan.get_channel()); - } else if (params.get_hidden()) { - // Hidden network - clear BSSID and channel even if set in config - // There might be multiple hidden networks with same SSID but we can't know which is correct - // Rely on probe-req with just SSID. Empty channel triggers ALL_CHANNEL_SCAN. - params.set_bssid(optional{}); - params.set_channel(optional{}); + switch (this->retry_phase_) { + case WiFiRetryPhase::INITIAL_CONNECT: +#ifdef USE_WIFI_FAST_CONNECT + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: +#endif + // Fast connect phases: use config-only (no scan results) + // BSSID/channel from config if user specified them, otherwise empty + break; + + case WiFiRetryPhase::EXPLICIT_HIDDEN: + case WiFiRetryPhase::RETRY_HIDDEN: + // Hidden network mode: clear BSSID/channel to trigger probe request + // (both explicit hidden and retry hidden use same behavior) + params.set_bssid(optional{}); + params.set_channel(optional{}); + break; + + case WiFiRetryPhase::SCAN_CONNECTING: + // Scan-based phase: always use best scan result (index 0 - highest priority after sorting) + if (!this->scan_result_.empty()) { + apply_scan_result_to_params(params, this->scan_result_[0]); + } + break; + + case WiFiRetryPhase::RESTARTING_ADAPTER: + // Should not be building params during restart + break; } return params; @@ -389,15 +733,40 @@ void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &pa sta.set_ssid(ssid); sta.set_password(password); this->set_sta(sta); + + // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected) + this->connect_soon_(); } -void WiFiComponent::start_connecting(const WiFiAP &ap, bool two) { - ESP_LOGI(TAG, "Connecting to '%s'", ap.get_ssid().c_str()); +void WiFiComponent::connect_soon_() { + // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing + if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) { + ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials"); + this->retry_connect(); + } +} + +void WiFiComponent::start_connecting(const WiFiAP &ap) { + // Log connection attempt at INFO level with priority + char bssid_s[18]; + int8_t priority = 0; + + if (ap.get_bssid().has_value()) { + format_mac_addr_upper(ap.get_bssid().value().data(), bssid_s); + priority = this->get_sta_priority(ap.get_bssid().value()); + } + + ESP_LOGI(TAG, + "Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...", + ap.get_ssid().c_str(), ap.get_bssid().has_value() ? bssid_s : LOG_STR_LITERAL("any"), priority, + this->num_retried_ + 1, get_max_retries_for_phase(this->retry_phase_), + LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); + #ifdef ESPHOME_LOG_HAS_VERBOSE ESP_LOGV(TAG, "Connection Params:"); ESP_LOGV(TAG, " SSID: '%s'", ap.get_ssid().c_str()); if (ap.get_bssid().has_value()) { - ESP_LOGV(TAG, " BSSID: %s", format_mac_address_pretty(ap.get_bssid()->data()).c_str()); + ESP_LOGV(TAG, " BSSID: %s", bssid_s); } else { ESP_LOGV(TAG, " BSSID: Not Set"); } @@ -429,11 +798,14 @@ void WiFiComponent::start_connecting(const WiFiAP &ap, bool two) { } else { ESP_LOGV(TAG, " Channel not set"); } +#ifdef USE_WIFI_MANUAL_IP if (ap.get_manual_ip().has_value()) { ManualIP m = *ap.get_manual_ip(); ESP_LOGV(TAG, " Manual IP: Static IP=%s Gateway=%s Subnet=%s DNS1=%s DNS2=%s", m.static_ip.str().c_str(), m.gateway.str().c_str(), m.subnet.str().c_str(), m.dns1.str().c_str(), m.dns2.str().c_str()); - } else { + } else +#endif + { ESP_LOGV(TAG, " Using DHCP IP"); } ESP_LOGV(TAG, " Hidden: %s", YESNO(ap.get_hidden())); @@ -441,19 +813,24 @@ void WiFiComponent::start_connecting(const WiFiAP &ap, bool two) { if (!this->wifi_sta_connect_(ap)) { ESP_LOGE(TAG, "wifi_sta_connect_ failed"); - this->retry_connect(); - return; - } - - if (!two) { - this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; + // Enter cooldown to allow WiFi hardware to stabilize + // (immediate failure suggests hardware not ready, different from connection timeout) + this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; } else { - this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING_2; + this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING; } this->action_started_ = millis(); } const LogString *get_signal_bars(int8_t rssi) { + // Check for disconnected sentinel value first + if (rssi == WIFI_RSSI_DISCONNECTED) { + // MULTIPLICATION SIGN + // Unicode: U+00D7, UTF-8: C3 97 + return LOG_STR("\033[0;31m" // red + "\xc3\x97\xc3\x97\xc3\x97\xc3\x97" + "\033[0m"); + } // LOWER ONE QUARTER BLOCK // Unicode: U+2582, UTF-8: E2 96 82 // LOWER HALF BLOCK @@ -498,8 +875,11 @@ const LogString *get_signal_bars(int8_t rssi) { void WiFiComponent::print_connect_params_() { bssid_t bssid = wifi_bssid(); + char bssid_s[18]; + format_mac_addr_upper(bssid.data(), bssid_s); - ESP_LOGCONFIG(TAG, " Local MAC: %s", get_mac_address_pretty().c_str()); + char mac_s[18]; + ESP_LOGCONFIG(TAG, " Local MAC: %s", get_mac_address_pretty_into_buffer(mac_s)); if (this->is_disabled()) { ESP_LOGCONFIG(TAG, " Disabled"); return; @@ -520,12 +900,12 @@ void WiFiComponent::print_connect_params_() { " Gateway: %s\n" " DNS1: %s\n" " DNS2: %s", - wifi_ssid().c_str(), format_mac_address_pretty(bssid.data()).c_str(), App.get_name().c_str(), rssi, - LOG_STR_ARG(get_signal_bars(rssi)), get_wifi_channel(), wifi_subnet_mask_().str().c_str(), - wifi_gateway_ip_().str().c_str(), wifi_dns_ip_(0).str().c_str(), wifi_dns_ip_(1).str().c_str()); + wifi_ssid().c_str(), bssid_s, App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi)), + get_wifi_channel(), wifi_subnet_mask_().str().c_str(), wifi_gateway_ip_().str().c_str(), + wifi_dns_ip_(0).str().c_str(), wifi_dns_ip_(1).str().c_str()); #ifdef ESPHOME_LOG_HAS_VERBOSE if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid().has_value()) { - ESP_LOGV(TAG, " Priority: %.1f", this->get_sta_priority(*config->get_bssid())); + ESP_LOGV(TAG, " Priority: %d", this->get_sta_priority(*config->get_bssid())); } #endif #ifdef USE_WIFI_11KV_SUPPORT @@ -565,8 +945,39 @@ void WiFiComponent::start_scanning() { this->state_ = WIFI_COMPONENT_STATE_STA_SCANNING; } -// Helper function for WiFi scan result comparison -// Returns true if 'a' should be placed before 'b' in the sorted order +/// Comparator for WiFi scan result sorting - determines which network should be tried first +/// Returns true if 'a' should be placed before 'b' in the sorted order (a is "better" than b) +/// +/// Sorting logic (in priority order): +/// 1. Matching networks always ranked before non-matching networks +/// 2. For matching networks: Priority first (CRITICAL - tracks failure history) +/// 3. RSSI as tiebreaker for equal priority or non-matching networks +/// +/// WHY PRIORITY MUST BE CHECKED FIRST: +/// The priority field tracks connection failure history via priority degradation: +/// - Initial priority: 0.0 (from config or default) +/// - Each connection failure: priority -= 1.0 (becomes -1.0, -2.0, -3.0, etc.) +/// - Failed BSSIDs sorted lower → naturally try different BSSID on next scan +/// +/// This enables automatic BSSID cycling for various real-world failure scenarios: +/// - Crashed/hung AP (visible but not responding) +/// - Misconfigured mesh node (accepts auth but no DHCP/routing) +/// - Capacity limits (AP refuses new clients) +/// - Rogue AP (same SSID, wrong password or malicious) +/// - Intermittent hardware issues (flaky radio, overheating) +/// +/// Example mesh network: 3 APs with same SSID "home", all at priority 0.0 initially +/// - Try strongest BSSID A (sorted by RSSI) → fails → priority A becomes -1.0 +/// - Next scan: BSSID B and C (priority 0.0) sorted BEFORE A (priority -1.0) +/// - Try next strongest BSSID B → succeeds or fails and gets deprioritized +/// - System naturally cycles through all BSSIDs via priority degradation +/// - Eventually finds working AP or tries all options before restarting adapter +/// +/// If we checked RSSI first (Bug in PR #9963): +/// - Same failed BSSID would keep being selected if it has strongest signal +/// - Device stuck connecting to crashed AP with -30dBm while working AP at -50dBm ignored +/// - Priority degradation would be useless +/// - Mesh networks would never recover from single AP failure [[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) { // Matching networks always come before non-matching if (a.get_matches() && !b.get_matches()) @@ -574,21 +985,13 @@ void WiFiComponent::start_scanning() { if (!a.get_matches() && b.get_matches()) return false; - if (a.get_matches() && b.get_matches()) { - // For APs with the same SSID, always prefer stronger signal - // This helps with mesh networks and multiple APs - if (a.get_ssid() == b.get_ssid()) { - return a.get_rssi() > b.get_rssi(); - } - - // For different SSIDs, check priority first - if (a.get_priority() != b.get_priority()) - return a.get_priority() > b.get_priority(); - // If priorities are equal, prefer stronger signal - return a.get_rssi() > b.get_rssi(); + // Both matching: check priority first (tracks connection failures via priority degradation) + // Priority is decreased when a BSSID fails to connect, so lower priority = previously failed + if (a.get_matches() && b.get_matches() && a.get_priority() != b.get_priority()) { + return a.get_priority() > b.get_priority(); } - // Both don't match - sort by signal strength + // Use RSSI as tiebreaker (for equal-priority matching networks or all non-matching networks) return a.get_rssi() > b.get_rssi(); } @@ -623,10 +1026,7 @@ __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s, LOG_STR_ARG(get_signal_bars(res.get_rssi()))); - ESP_LOGD(TAG, - " Channel: %u\n" - " RSSI: %d dB", - res.get_channel(), res.get_rssi()); + ESP_LOGD(TAG, " Channel: %2u, RSSI: %3d dB, Priority: %4d", res.get_channel(), res.get_rssi(), res.get_priority()); } else { ESP_LOGD(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(), bssid_s, LOG_STR_ARG(get_signal_bars(res.get_rssi()))); @@ -642,6 +1042,7 @@ void WiFiComponent::check_scanning_finished() { return; } this->scan_done_ = false; + this->did_scan_this_cycle_ = true; if (this->scan_result_.empty()) { ESP_LOGW(TAG, "No networks found"); @@ -675,39 +1076,48 @@ void WiFiComponent::check_scanning_finished() { // SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_ // After sorting, scan_result_[0] contains the best network. Now find which sta_[i] config // matches that network and record it in selected_sta_index_. This keeps the two indices - // synchronized so build_wifi_ap_from_selected_() can safely use both to build connection parameters. + // synchronized so build_params_for_current_phase_() can safely use both to build connection parameters. const WiFiScanResult &scan_res = this->scan_result_[0]; - if (!scan_res.get_matches()) { - ESP_LOGW(TAG, "No matching network found"); - this->retry_connect(); - return; - } - bool found_match = false; - for (size_t i = 0; i < this->sta_.size(); i++) { - if (scan_res.matches(this->sta_[i])) { - // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation - // No overflow check needed - YAML validation prevents >127 networks - this->selected_sta_index_ = static_cast(i); // Links scan_result_[0] with sta_[i] - found_match = true; - break; + if (scan_res.get_matches()) { + for (size_t i = 0; i < this->sta_.size(); i++) { + if (scan_res.matches(this->sta_[i])) { + // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation + // No overflow check needed - YAML validation prevents >127 networks + this->selected_sta_index_ = static_cast(i); // Links scan_result_[0] with sta_[i] + found_match = true; + break; + } } } if (!found_match) { ESP_LOGW(TAG, "No matching network found"); - this->retry_connect(); - return; + // No scan results matched our configured networks - transition directly to hidden mode + // Don't call retry_connect() since we never attempted a connection (no BSSID to penalize) + this->transition_to_phase_(WiFiRetryPhase::RETRY_HIDDEN); + // If no hidden networks to try, skip connection attempt (will be handled on next loop) + if (this->selected_sta_index_ == -1) { + return; + } + // Now start connection attempt in hidden mode + } else if (this->transition_to_phase_(WiFiRetryPhase::SCAN_CONNECTING)) { + return; // scan started, wait for next loop iteration } yield(); - WiFiAP params = this->build_wifi_ap_from_selected_(); - this->start_connecting(params, false); + WiFiAP params = this->build_params_for_current_phase_(); + // Ensure we're in SCAN_CONNECTING phase when connecting with scan results + // (needed when scan was started directly without transition_to_phase_, e.g., initial scan) + this->start_connecting(params); } void WiFiComponent::dump_config() { - ESP_LOGCONFIG(TAG, "WiFi:"); + ESP_LOGCONFIG(TAG, + "WiFi:\n" + " Connected: %s", + YESNO(this->is_connected())); this->print_connect_params_(); } @@ -724,11 +1134,18 @@ void WiFiComponent::check_connecting_finished() { ESP_LOGI(TAG, "Connected"); // Warn if we had to retry with hidden network mode for a network that's not marked hidden // Only warn if we actually connected without scan data (SSID only), not if scan succeeded on retry - if (const WiFiAP *config = this->get_selected_sta_(); - this->retry_hidden_ && config && !config->get_hidden() && this->scan_result_.empty()) { - ESP_LOGW(TAG, "Network '%s' should be marked as hidden", config->get_ssid().c_str()); + if (const WiFiAP *config = this->get_selected_sta_(); this->retry_phase_ == WiFiRetryPhase::RETRY_HIDDEN && + config && !config->get_hidden() && + this->scan_result_.empty()) { + ESP_LOGW(TAG, LOG_SECRET("'%s'") " should be marked hidden", config->get_ssid().c_str()); } - this->retry_hidden_ = false; + // Reset to initial phase on successful connection (don't log transition, just reset state) + this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT; + this->num_retried_ = 0; + // Ensure next connection attempt does not inherit error state + // so when WiFi disconnects later we start fresh and don't see + // the first connection as a failure. + this->error_from_callback_ = false; this->print_connect_params_(); @@ -750,6 +1167,9 @@ void WiFiComponent::check_connecting_finished() { this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED; this->num_retried_ = 0; + // Clear priority tracking if all priorities are at minimum + this->clear_priorities_if_all_min_(); + #ifdef USE_WIFI_FAST_CONNECT this->save_fast_connect_settings_(); #endif @@ -771,7 +1191,7 @@ void WiFiComponent::check_connecting_finished() { } if (this->error_from_callback_) { - ESP_LOGW(TAG, "Connecting to network failed"); + ESP_LOGW(TAG, "Connecting to network failed (callback)"); this->retry_connect(); return; } @@ -796,76 +1216,427 @@ void WiFiComponent::check_connecting_finished() { this->retry_connect(); } -void WiFiComponent::retry_connect() { - if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid()) { - auto bssid = *config->get_bssid(); - float priority = this->get_sta_priority(bssid); - this->set_sta_priority(bssid, priority - 1.0f); +/// Determine the next retry phase based on current state and failure conditions +/// This function examines the current retry phase, number of retries, and failure reasons +/// to decide what phase to move to next. It does not modify any state - it only returns +/// the recommended next phase. +/// +/// @return The next WiFiRetryPhase to transition to (may be same as current phase if should retry) +WiFiRetryPhase WiFiComponent::determine_next_phase_() { + switch (this->retry_phase_) { + case WiFiRetryPhase::INITIAL_CONNECT: +#ifdef USE_WIFI_FAST_CONNECT + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: + // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS: no retries, try next AP or fall back to scan + if (this->selected_sta_index_ < static_cast(this->sta_.size()) - 1) { + return WiFiRetryPhase::FAST_CONNECT_CYCLING_APS; // Move to next AP + } +#endif + // Check if we should try explicit hidden networks before scanning + // This handles reconnection after connection loss where first network is hidden + if (!this->sta_.empty() && this->sta_[0].get_hidden()) { + return WiFiRetryPhase::EXPLICIT_HIDDEN; + } + // No more APs to try, fall back to scan + return WiFiRetryPhase::SCAN_CONNECTING; + + case WiFiRetryPhase::EXPLICIT_HIDDEN: { + // Try all explicitly hidden networks before scanning + if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) { + return WiFiRetryPhase::EXPLICIT_HIDDEN; // Keep retrying same SSID + } + + // Exhausted retries on current SSID - check for more explicitly hidden networks + // Stop when we reach a visible network (proceed to scanning) + size_t next_index = this->selected_sta_index_ + 1; + if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) { + // Found another explicitly hidden network + return WiFiRetryPhase::EXPLICIT_HIDDEN; + } + + // No more consecutive explicitly hidden networks + // If ALL networks are hidden, skip scanning and go directly to restart + if (this->find_first_non_hidden_index_() < 0) { + return WiFiRetryPhase::RESTARTING_ADAPTER; + } + // Otherwise proceed to scanning for non-hidden networks + return WiFiRetryPhase::SCAN_CONNECTING; + } + + case WiFiRetryPhase::SCAN_CONNECTING: + // If scan found no matching networks, skip to hidden network mode + if (!this->scan_result_.empty() && !this->scan_result_[0].get_matches()) { + return WiFiRetryPhase::RETRY_HIDDEN; + } + + if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_BSSID) { + return WiFiRetryPhase::SCAN_CONNECTING; // Keep retrying same BSSID + } + + // Exhausted retries on current BSSID (scan_result_[0]) + // Its priority has been decreased, so on next scan it will be sorted lower + // and we'll try the next best BSSID. + // Check if there are any potentially hidden networks to try + if (this->find_next_hidden_sta_(-1) >= 0) { + return WiFiRetryPhase::RETRY_HIDDEN; // Found hidden networks to try + } + // No hidden networks - always go through RESTARTING_ADAPTER phase + // This ensures num_retried_ gets reset and a fresh scan is triggered + // The actual adapter restart will be skipped if captive portal/improv is active + return WiFiRetryPhase::RESTARTING_ADAPTER; + + case WiFiRetryPhase::RETRY_HIDDEN: + // If no hidden SSIDs to try (selected_sta_index_ == -1), skip directly to rescan + if (this->selected_sta_index_ >= 0) { + if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) { + return WiFiRetryPhase::RETRY_HIDDEN; // Keep retrying same SSID + } + + // Exhausted retries on current SSID - check if there are more potentially hidden SSIDs to try + if (this->selected_sta_index_ < static_cast(this->sta_.size()) - 1) { + // Check if find_next_hidden_sta_() would actually find another hidden SSID + // as it might have been seen in the scan results and we want to skip those + // otherwise we will get stuck in RETRY_HIDDEN phase + if (this->find_next_hidden_sta_(this->selected_sta_index_) != -1) { + // More hidden SSIDs available - stay in RETRY_HIDDEN, advance will happen in retry_connect() + return WiFiRetryPhase::RETRY_HIDDEN; + } + } + } + // Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER + // This ensures num_retried_ gets reset and a fresh scan is triggered + // The actual adapter restart will be skipped if captive portal/improv is active + return WiFiRetryPhase::RESTARTING_ADAPTER; + + case WiFiRetryPhase::RESTARTING_ADAPTER: + // After restart, go back to explicit hidden if we went through it initially + if (this->went_through_explicit_hidden_phase_()) { + return WiFiRetryPhase::EXPLICIT_HIDDEN; + } + // Skip scanning when captive portal/improv is active to avoid disrupting AP + // Even passive scans can cause brief AP disconnections on ESP32 + if (this->is_captive_portal_active_() || this->is_esp32_improv_active_()) { + return WiFiRetryPhase::RETRY_HIDDEN; + } + return WiFiRetryPhase::SCAN_CONNECTING; } - delay(10); - if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_() && - (this->num_retried_ > 3 || this->error_from_callback_)) { -#ifdef USE_WIFI_FAST_CONNECT - // No empty check needed - YAML validation requires at least one network for fast_connect - if (this->trying_loaded_ap_) { - this->trying_loaded_ap_ = false; - this->selected_sta_index_ = 0; // Retry from the first configured AP - this->reset_for_next_ap_attempt_(); - } else if (this->selected_sta_index_ >= static_cast(this->sta_.size()) - 1) { - // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation - // Exhausted all configured APs, restart adapter and cycle back to first - // Restart clears any stuck WiFi driver state - // Each AP is tried with config data only (SSID + optional BSSID/channel if user configured them) - // Typically SSID only, which triggers ESP-IDF internal scanning - ESP_LOGW(TAG, "No more APs to try"); - this->selected_sta_index_ = 0; - this->reset_for_next_ap_attempt_(); - this->restart_adapter(); - } else { - // Try next AP - this->selected_sta_index_++; - this->reset_for_next_ap_attempt_(); - } -#else - if (this->num_retried_ > 5) { - // If retry failed for more than 5 times, let's restart STA - this->restart_adapter(); - } else { - // Try hidden networks after 3 failed retries - ESP_LOGD(TAG, "Retrying with hidden networks"); - this->retry_hidden_ = true; - this->num_retried_++; - } -#endif - } else { - this->num_retried_++; + // Should never reach here + return WiFiRetryPhase::SCAN_CONNECTING; +} + +/// Transition from current retry phase to a new phase with logging and phase-specific setup +/// This function handles the actual state change, including: +/// - Logging the phase transition +/// - Resetting the retry counter +/// - Performing phase-specific initialization (e.g., advancing AP index, starting scans) +/// +/// @param new_phase The phase we're transitioning TO +/// @return true if connection attempt should be skipped (scan started or no networks to try) +/// false if caller can proceed with connection attempt +bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { + WiFiRetryPhase old_phase = this->retry_phase_; + + // No-op if staying in same phase + if (old_phase == new_phase) { + return false; } - this->error_from_callback_ = false; - if (this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTING) { - yield(); - this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING_2; - WiFiAP params = this->build_wifi_ap_from_selected_(); - this->start_connecting(params, true); + + ESP_LOGD(TAG, "Retry phase: %s → %s", LOG_STR_ARG(retry_phase_to_log_string(old_phase)), + LOG_STR_ARG(retry_phase_to_log_string(new_phase))); + + this->retry_phase_ = new_phase; + this->num_retried_ = 0; // Reset retry counter on phase change + + // Phase-specific setup + switch (new_phase) { +#ifdef USE_WIFI_FAST_CONNECT + case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: + // Move to next configured AP - clear old scan data so new AP is tried with config only + this->selected_sta_index_++; + this->scan_result_.clear(); + break; +#endif + + case WiFiRetryPhase::EXPLICIT_HIDDEN: + // Starting explicit hidden phase - reset to first network + this->selected_sta_index_ = 0; + break; + + case WiFiRetryPhase::SCAN_CONNECTING: + // Transitioning to scan-based connection +#ifdef USE_WIFI_FAST_CONNECT + if (old_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) { + ESP_LOGI(TAG, "Fast connect exhausted, falling back to scan"); + } +#endif + // Trigger scan if we don't have scan results OR if transitioning from phases that need fresh scan + if (this->scan_result_.empty() || old_phase == WiFiRetryPhase::EXPLICIT_HIDDEN || + old_phase == WiFiRetryPhase::RETRY_HIDDEN || old_phase == WiFiRetryPhase::RESTARTING_ADAPTER) { + this->selected_sta_index_ = -1; // Will be set after scan completes + this->start_scanning(); + return true; // Started scan, wait for completion + } + // Already have scan results - selected_sta_index_ should already be synchronized + // (set in check_scanning_finished() when scan completed) + // No need to reset it here + break; + + case WiFiRetryPhase::RETRY_HIDDEN: + // Starting hidden mode - find first SSID that wasn't in scan results + if (old_phase == WiFiRetryPhase::SCAN_CONNECTING) { + // Keep scan results so we can skip SSIDs that were visible in the scan + // Don't clear scan_result_ - we need it to know which SSIDs are NOT hidden + + // If first network is marked hidden, we went through EXPLICIT_HIDDEN phase + // In that case, skip networks marked hidden:true (already tried) + // Otherwise, include them (they haven't been tried yet) + this->selected_sta_index_ = this->find_next_hidden_sta_(-1); + + if (this->selected_sta_index_ == -1) { + ESP_LOGD(TAG, "All SSIDs visible or already tried, skipping hidden mode"); + } + } + break; + + case WiFiRetryPhase::RESTARTING_ADAPTER: + // Skip actual adapter restart if captive portal/improv is active + // This allows state machine to reset num_retried_ and trigger fresh scan + // without disrupting the captive portal/improv connection + if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { + this->restart_adapter(); + } + // Clear scan flag - we're starting a new retry cycle + this->did_scan_this_cycle_ = false; + // Always enter cooldown after restart (or skip-restart) to allow stabilization + // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS + this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; + this->action_started_ = millis(); + // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting + return true; + + default: + break; + } + + return false; // Did not start scan, can proceed with connection +} + +/// Clear BSSID priority tracking if all priorities are at minimum (saves memory) +/// At minimum priority, all BSSIDs are equally bad, so priority tracking is useless +/// Called after successful connection or after failed connection attempts +void WiFiComponent::clear_priorities_if_all_min_() { + if (this->sta_priorities_.empty()) { return; } - this->state_ = WIFI_COMPONENT_STATE_COOLDOWN; - this->action_started_ = millis(); + int8_t first_priority = this->sta_priorities_[0].priority; + + // Only clear if all priorities have been decremented to the minimum value + // At this point, all BSSIDs have been equally penalized and priority info is useless + if (first_priority != std::numeric_limits::min()) { + return; + } + + for (const auto &pri : this->sta_priorities_) { + if (pri.priority != first_priority) { + return; // Not all same, nothing to do + } + } + + // All priorities are at minimum - clear the vector to save memory and reset + ESP_LOGD(TAG, "Clearing BSSID priorities (all at minimum)"); + this->sta_priorities_.clear(); + this->sta_priorities_.shrink_to_fit(); } +/// Log failed connection attempt and decrease BSSID priority to avoid repeated failures +/// This function identifies which BSSID was attempted (from scan results or config), +/// decreases its priority by 1.0 to discourage future attempts, and logs the change. +/// +/// The priority degradation system ensures that failed BSSIDs are automatically sorted +/// lower in subsequent scans, naturally cycling through different APs without explicit +/// BSSID tracking within a scan cycle. +/// +/// Priority sources: +/// - SCAN_CONNECTING phase: Uses BSSID from scan_result_[0] (best match after sorting) +/// - Other phases: Uses BSSID from config if explicitly specified by user or fast_connect +/// +/// If no BSSID is available (SSID-only connection), priority adjustment is skipped. +/// +/// IMPORTANT: Priority is only decreased on the LAST attempt for a BSSID in SCAN_CONNECTING phase. +/// This prevents false positives from transient WiFi stack state issues after scanning. +/// Single failures don't necessarily mean the AP is bad - two genuine failures provide +/// higher confidence before degrading priority and skipping the BSSID in future scans. +void WiFiComponent::log_and_adjust_priority_for_failed_connect_() { + // Determine which BSSID we tried to connect to + optional failed_bssid; + + if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) { + // Scan-based phase: always use best result (index 0) + failed_bssid = this->scan_result_[0].get_bssid(); + } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_bssid()) { + // Config has specific BSSID (fast_connect or user-specified) + failed_bssid = *config->get_bssid(); + } + + if (!failed_bssid.has_value()) { + return; // No BSSID to penalize + } + + // Get SSID for logging + std::string ssid; + if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) { + ssid = this->scan_result_[0].get_ssid(); + } else if (const WiFiAP *config = this->get_selected_sta_()) { + ssid = config->get_ssid(); + } + + // Only decrease priority on the last attempt for this phase + // This prevents false positives from transient WiFi stack issues + uint8_t max_retries = get_max_retries_for_phase(this->retry_phase_); + bool is_last_attempt = (this->num_retried_ + 1 >= max_retries); + + // Decrease priority only on last attempt to avoid false positives from transient failures + int8_t old_priority = this->get_sta_priority(failed_bssid.value()); + int8_t new_priority = old_priority; + + if (is_last_attempt) { + // Decrease priority, but clamp to int8_t::min to prevent overflow + new_priority = + (old_priority > std::numeric_limits::min()) ? (old_priority - 1) : std::numeric_limits::min(); + this->set_sta_priority(failed_bssid.value(), new_priority); + } + char bssid_s[18]; + format_mac_addr_upper(failed_bssid.value().data(), bssid_s); + ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid.c_str(), bssid_s, + old_priority, new_priority); + + // After adjusting priority, check if all priorities are now at minimum + // If so, clear the vector to save memory and reset for fresh start + this->clear_priorities_if_all_min_(); +} + +/// Handle target advancement or retry counter increment when staying in the same phase +/// This function is called when a connection attempt fails and determine_next_phase_() indicates +/// we should stay in the current phase. It decides whether to: +/// - Advance to the next target (AP in fast_connect, SSID in hidden mode) +/// - Or increment the retry counter to try the same target again +/// +/// Phase-specific behavior: +/// - FAST_CONNECT_CYCLING_APS: Always advance to next AP (no retries per AP) +/// - RETRY_HIDDEN: Advance to next SSID after exhausting retries on current SSID +/// - Other phases: Increment retry counter (will retry same target) +void WiFiComponent::advance_to_next_target_or_increment_retry_() { + WiFiRetryPhase current_phase = this->retry_phase_; + + // Check if we need to advance to next AP/SSID within the same phase +#ifdef USE_WIFI_FAST_CONNECT + if (current_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) { + // Fast connect: always advance to next AP (no retries per AP) + this->selected_sta_index_++; + this->num_retried_ = 0; + ESP_LOGD(TAG, "Next AP in %s", LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); + return; + } +#endif + + if (current_phase == WiFiRetryPhase::EXPLICIT_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) { + // Explicit hidden: exhausted retries on current SSID, find next explicitly hidden network + // Stop when we reach a visible network (proceed to scanning) + size_t next_index = this->selected_sta_index_ + 1; + if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) { + this->selected_sta_index_ = static_cast(next_index); + this->num_retried_ = 0; + ESP_LOGD(TAG, "Next explicit hidden network at index %d", static_cast(next_index)); + return; + } + // No more consecutive explicit hidden networks found - fall through to trigger phase change + } + + if (current_phase == WiFiRetryPhase::RETRY_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) { + // Hidden mode: exhausted retries on current SSID, find next potentially hidden SSID + // If first network is marked hidden, we went through EXPLICIT_HIDDEN phase + // In that case, skip networks marked hidden:true (already tried) + // Otherwise, include them (they haven't been tried yet) + int8_t next_index = this->find_next_hidden_sta_(this->selected_sta_index_); + if (next_index != -1) { + // Found another potentially hidden SSID + this->selected_sta_index_ = next_index; + this->num_retried_ = 0; + return; + } + // No more potentially hidden SSIDs - set selected_sta_index_ to -1 to trigger phase change + // This ensures determine_next_phase_() will skip the RETRY_HIDDEN logic and transition out + this->selected_sta_index_ = -1; + // Return early - phase change will happen on next wifi_loop() iteration + return; + } + + // Don't increment retry counter if we're in a scan phase with no valid targets + if (this->needs_scan_results_()) { + return; + } + + // Increment retry counter to try the same target again + this->num_retried_++; + ESP_LOGD(TAG, "Retry attempt %u/%u in phase %s", this->num_retried_ + 1, + get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_))); +} + +void WiFiComponent::retry_connect() { + this->log_and_adjust_priority_for_failed_connect_(); + + // Determine next retry phase based on current state + WiFiRetryPhase current_phase = this->retry_phase_; + WiFiRetryPhase next_phase = this->determine_next_phase_(); + + // Handle phase transitions (transition_to_phase_ handles same-phase no-op internally) + if (this->transition_to_phase_(next_phase)) { + return; // Scan started or adapter restarted (which sets its own state) + } + + if (next_phase == current_phase) { + this->advance_to_next_target_or_increment_retry_(); + } + + this->error_from_callback_ = false; + + yield(); + // Check if we have a valid target before building params + // After exhausting all networks in a phase, selected_sta_index_ may be -1 + // In that case, skip connection and let next wifi_loop() handle phase transition + if (this->selected_sta_index_ >= 0) { + WiFiAP params = this->build_params_for_current_phase_(); + this->start_connecting(params); + } +} + +#ifdef USE_RP2040 +// RP2040's mDNS library (LEAmDNS) relies on LwipIntf::stateUpCB() to restart +// mDNS when the network interface reconnects. However, this callback is disabled +// in the arduino-pico framework. As a workaround, we block component setup until +// WiFi is connected, ensuring mDNS.begin() is called with an active connection. + bool WiFiComponent::can_proceed() { if (!this->has_sta() || this->state_ == WIFI_COMPONENT_STATE_DISABLED || this->ap_setup_) { return true; } return this->is_connected(); } +#endif + void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; } bool WiFiComponent::is_connected() { return this->state_ == WIFI_COMPONENT_STATE_STA_CONNECTED && this->wifi_sta_connect_status_() == WiFiSTAConnectStatus::CONNECTED && !this->error_from_callback_; } -void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { this->power_save_ = power_save; } +void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) { + this->power_save_ = power_save; +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) + this->configured_power_save_ = power_save; +#endif +} void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; } @@ -884,6 +1655,38 @@ bool WiFiComponent::is_esp32_improv_active_() { #endif } +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) +bool WiFiComponent::request_high_performance() { + // Already configured for high performance - request satisfied + if (this->configured_power_save_ == WIFI_POWER_SAVE_NONE) { + return true; + } + + // Semaphore initialization failed + if (this->high_performance_semaphore_ == nullptr) { + return false; + } + + // Give the semaphore (non-blocking). This increments the count. + return xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE; +} + +bool WiFiComponent::release_high_performance() { + // Already configured for high performance - nothing to release + if (this->configured_power_save_ == WIFI_POWER_SAVE_NONE) { + return true; + } + + // Semaphore initialization failed + if (this->high_performance_semaphore_ == nullptr) { + return false; + } + + // Take the semaphore (non-blocking). This decrements the count. + return xSemaphoreTake(this->high_performance_semaphore_, 0) == pdTRUE; +} +#endif // USE_ESP32 && USE_WIFI_RUNTIME_POWER_SAVE + #ifdef USE_WIFI_FAST_CONNECT bool WiFiComponent::load_fast_connect_settings_(WiFiAP ¶ms) { SavedWifiFastConnectSettings fast_connect_save{}; @@ -949,7 +1752,9 @@ void WiFiAP::set_password(const std::string &password) { this->password_ = passw void WiFiAP::set_eap(optional eap_auth) { this->eap_ = std::move(eap_auth); } #endif void WiFiAP::set_channel(optional channel) { this->channel_ = channel; } +#ifdef USE_WIFI_MANUAL_IP void WiFiAP::set_manual_ip(optional manual_ip) { this->manual_ip_ = manual_ip; } +#endif void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; } const std::string &WiFiAP::get_ssid() const { return this->ssid_; } const optional &WiFiAP::get_bssid() const { return this->bssid_; } @@ -958,15 +1763,17 @@ const std::string &WiFiAP::get_password() const { return this->password_; } const optional &WiFiAP::get_eap() const { return this->eap_; } #endif const optional &WiFiAP::get_channel() const { return this->channel_; } +#ifdef USE_WIFI_MANUAL_IP const optional &WiFiAP::get_manual_ip() const { return this->manual_ip_; } +#endif bool WiFiAP::get_hidden() const { return this->hidden_; } WiFiScanResult::WiFiScanResult(const bssid_t &bssid, std::string ssid, uint8_t channel, int8_t rssi, bool with_auth, bool is_hidden) : bssid_(bssid), - ssid_(std::move(ssid)), channel_(channel), rssi_(rssi), + ssid_(std::move(ssid)), with_auth_(with_auth), is_hidden_(is_hidden) {} bool WiFiScanResult::matches(const WiFiAP &config) const { @@ -1019,6 +1826,5 @@ bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this-> WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace wifi -} // namespace esphome +} // namespace esphome::wifi #endif diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index cb75edf5a0..2148f2d4c7 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -49,8 +49,15 @@ extern "C" { #include #endif -namespace esphome { -namespace wifi { +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) +#include +#include +#endif + +namespace esphome::wifi { + +/// Sentinel value for RSSI when WiFi is not connected +static constexpr int8_t WIFI_RSSI_DISCONNECTED = -127; struct SavedWifiSettings { char ssid[33]; @@ -74,12 +81,6 @@ enum WiFiComponentState : uint8_t { WIFI_COMPONENT_STATE_STA_SCANNING, /** WiFi is in STA(+AP) mode and currently connecting to an AP. */ WIFI_COMPONENT_STATE_STA_CONNECTING, - /** WiFi is in STA(+AP) mode and currently connecting to an AP a second time. - * - * This is required because for some reason ESPs don't like to connect to WiFi APs directly after - * a scan. - * */ - WIFI_COMPONENT_STATE_STA_CONNECTING_2, /** WiFi is in STA(+AP) mode and successfully connected. */ WIFI_COMPONENT_STATE_STA_CONNECTED, /** WiFi is in AP-only mode and internal AP is already enabled. */ @@ -94,6 +95,24 @@ enum class WiFiSTAConnectStatus : int { ERROR_CONNECT_FAILED, }; +/// Tracks the current retry strategy/phase for WiFi connection attempts +enum class WiFiRetryPhase : uint8_t { + /// Initial connection attempt (varies based on fast_connect setting) + INITIAL_CONNECT, +#ifdef USE_WIFI_FAST_CONNECT + /// Fast connect mode: cycling through configured APs (config-only, no scan) + FAST_CONNECT_CYCLING_APS, +#endif + /// Explicitly hidden networks (user marked as hidden, try before scanning) + EXPLICIT_HIDDEN, + /// Scan-based: connecting to best AP from scan results + SCAN_CONNECTING, + /// Retry networks not found in scan (might be hidden) + RETRY_HIDDEN, + /// Restarting WiFi adapter to clear stuck state + RESTARTING_ADAPTER, +}; + /// Struct for setting static IPs in WiFiComponent. struct ManualIP { network::IPAddress static_ip; @@ -139,8 +158,10 @@ class WiFiAP { void set_eap(optional eap_auth); #endif // USE_WIFI_WPA2_EAP void set_channel(optional channel); - void set_priority(float priority) { priority_ = priority; } + void set_priority(int8_t priority) { priority_ = priority; } +#ifdef USE_WIFI_MANUAL_IP void set_manual_ip(optional manual_ip); +#endif void set_hidden(bool hidden); const std::string &get_ssid() const; const optional &get_bssid() const; @@ -149,8 +170,10 @@ class WiFiAP { const optional &get_eap() const; #endif // USE_WIFI_WPA2_EAP const optional &get_channel() const; - float get_priority() const { return priority_; } + int8_t get_priority() const { return priority_; } +#ifdef USE_WIFI_MANUAL_IP const optional &get_manual_ip() const; +#endif bool get_hidden() const; protected: @@ -160,9 +183,11 @@ class WiFiAP { #ifdef USE_WIFI_WPA2_EAP optional eap_; #endif // USE_WIFI_WPA2_EAP +#ifdef USE_WIFI_MANUAL_IP optional manual_ip_; - float priority_{0}; +#endif optional channel_; + int8_t priority_{0}; bool hidden_{false}; }; @@ -180,17 +205,17 @@ class WiFiScanResult { int8_t get_rssi() const; bool get_with_auth() const; bool get_is_hidden() const; - float get_priority() const { return priority_; } - void set_priority(float priority) { priority_ = priority; } + int8_t get_priority() const { return priority_; } + void set_priority(int8_t priority) { priority_ = priority; } bool operator==(const WiFiScanResult &rhs) const; protected: bssid_t bssid_; - std::string ssid_; - float priority_{0.0f}; uint8_t channel_; int8_t rssi_; + std::string ssid_; + int8_t priority_{0}; bool matches_{false}; bool with_auth_; bool is_hidden_; @@ -198,7 +223,7 @@ class WiFiScanResult { struct WiFiSTAPriority { bssid_t bssid; - float priority; + int8_t priority; }; enum WiFiPowerSaveMode : uint8_t { @@ -207,10 +232,47 @@ enum WiFiPowerSaveMode : uint8_t { WIFI_POWER_SAVE_HIGH, }; +enum WifiMinAuthMode : uint8_t { + WIFI_MIN_AUTH_MODE_WPA = 0, + WIFI_MIN_AUTH_MODE_WPA2, + WIFI_MIN_AUTH_MODE_WPA3, +}; + #ifdef USE_ESP32 struct IDFWiFiEvent; #endif +/** Listener interface for WiFi IP state changes. + * + * Components can implement this interface to receive IP address updates + * without the overhead of std::function callbacks. + */ +class WiFiIPStateListener { + public: + virtual void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) = 0; +}; + +/** Listener interface for WiFi scan results. + * + * Components can implement this interface to receive scan results + * without the overhead of std::function callbacks. + */ +class WiFiScanResultsListener { + public: + virtual void on_wifi_scan_results(const wifi_scan_vector_t &results) = 0; +}; + +/** Listener interface for WiFi connection state changes. + * + * Components can implement this interface to receive connection updates + * without the overhead of std::function callbacks. + */ +class WiFiConnectStateListener { + public: + virtual void on_wifi_connect_state(const std::string &ssid, const bssid_t &bssid) = 0; +}; + /// This component is responsible for managing the ESP WiFi interface. class WiFiComponent : public Component { public: @@ -245,24 +307,30 @@ class WiFiComponent : public Component { bool is_disabled(); void start_scanning(); void check_scanning_finished(); - void start_connecting(const WiFiAP &ap, bool two); + void start_connecting(const WiFiAP &ap); + // Backward compatibility overload - ignores 'two' parameter + void start_connecting(const WiFiAP &ap, bool /* two */) { this->start_connecting(ap); } void check_connecting_finished(); void retry_connect(); +#ifdef USE_RP2040 bool can_proceed() override; +#endif void set_reboot_timeout(uint32_t reboot_timeout); bool is_connected(); void set_power_save_mode(WiFiPowerSaveMode power_save); + void set_min_auth_mode(WifiMinAuthMode min_auth_mode) { min_auth_mode_ = min_auth_mode; } void set_output_power(float output_power) { output_power_ = output_power; } void set_passive_scan(bool passive); void save_wifi_sta(const std::string &ssid, const std::string &password); + // ========== INTERNAL METHODS ========== // (In most use cases you won't need these) /// Setup WiFi interface. @@ -279,6 +347,7 @@ class WiFiComponent : public Component { bool has_sta() const; bool has_ap() const; + bool is_ap_active() const; #ifdef USE_WIFI_11KV_SUPPORT void set_btm(bool btm); @@ -301,14 +370,14 @@ class WiFiComponent : public Component { } return false; } - float get_sta_priority(const bssid_t bssid) { + int8_t get_sta_priority(const bssid_t bssid) { for (auto &it : this->sta_priorities_) { if (it.bssid == bssid) return it.priority; } - return 0.0f; + return 0; } - void set_sta_priority(const bssid_t bssid, float priority) { + void set_sta_priority(const bssid_t bssid, int8_t priority) { for (auto &it : this->sta_priorities_) { if (it.bssid == bssid) { it.priority = priority; @@ -335,14 +404,92 @@ class WiFiComponent : public Component { int32_t get_wifi_channel(); +#ifdef USE_WIFI_LISTENERS + /** Add a listener for IP state changes. + * Listener receives: IP addresses, DNS address 1, DNS address 2 + */ + void add_ip_state_listener(WiFiIPStateListener *listener) { this->ip_state_listeners_.push_back(listener); } + /// Add a listener for WiFi scan results + void add_scan_results_listener(WiFiScanResultsListener *listener) { + this->scan_results_listeners_.push_back(listener); + } + /** Add a listener for WiFi connection state changes. + * Listener receives: SSID, BSSID + */ + void add_connect_state_listener(WiFiConnectStateListener *listener) { + this->connect_state_listeners_.push_back(listener); + } +#endif // USE_WIFI_LISTENERS + +#ifdef USE_WIFI_RUNTIME_POWER_SAVE + /** Request high-performance mode (no power saving) for improved WiFi latency. + * + * Components that need maximum WiFi performance (e.g., audio streaming, large data transfers) + * can call this method to temporarily disable WiFi power saving. Multiple components can + * request high performance simultaneously using a counting semaphore. + * + * Power saving will be restored to the YAML-configured mode when all components have + * called release_high_performance(). + * + * Note: Only supported on ESP32. + * + * @return true if request was satisfied (high-performance mode active or already configured), + * false if operation failed (semaphore error) + */ + bool request_high_performance(); + + /** Release a high-performance mode request. + * + * Should be called when a component no longer needs maximum WiFi latency. + * When all requests are released (semaphore count reaches zero), WiFi power saving + * is restored to the YAML-configured mode. + * + * Note: Only supported on ESP32. + * + * @return true if release was successful (or already in high-performance config), + * false if operation failed (semaphore error) + */ + bool release_high_performance(); +#endif // USE_WIFI_RUNTIME_POWER_SAVE + protected: #ifdef USE_WIFI_AP void setup_ap_config_(); #endif // USE_WIFI_AP void print_connect_params_(); - WiFiAP build_wifi_ap_from_selected_() const; + WiFiAP build_params_for_current_phase_(); + /// Determine next retry phase based on current state and failure conditions + WiFiRetryPhase determine_next_phase_(); + /// Transition to a new retry phase with logging + /// Returns true if a scan was started (caller should wait), false otherwise + bool transition_to_phase_(WiFiRetryPhase new_phase); + /// Check if we need valid scan results for the current phase but don't have any + /// Returns true if the phase requires scan results but they're missing or don't match + bool needs_scan_results_() const; + /// Check if we went through EXPLICIT_HIDDEN phase (first network is marked hidden) + /// Used in RETRY_HIDDEN to determine whether to skip explicitly hidden networks + bool went_through_explicit_hidden_phase_() const; + /// Find the index of the first non-hidden network + /// Returns where EXPLICIT_HIDDEN phase would have stopped, or -1 if all networks are hidden + int8_t find_first_non_hidden_index_() const; + /// Check if an SSID was seen in the most recent scan results + /// Used to skip hidden mode for SSIDs we know are visible + bool ssid_was_seen_in_scan_(const std::string &ssid) const; + /// Find next SSID that wasn't in scan results (might be hidden) + /// Returns index of next potentially hidden SSID, or -1 if none found + /// @param start_index Start searching from index after this (-1 to start from beginning) + int8_t find_next_hidden_sta_(int8_t start_index); + /// Log failed connection and decrease BSSID priority to avoid repeated attempts + void log_and_adjust_priority_for_failed_connect_(); + /// Clear BSSID priority tracking if all priorities are at minimum (saves memory) + void clear_priorities_if_all_min_(); + /// Advance to next target (AP/SSID) within current phase, or increment retry counter + /// Called when staying in the same phase after a failed connection attempt + void advance_to_next_target_or_increment_retry_(); + /// Start initial connection - either scan or connect directly to hidden networks + void start_initial_connection_(); const WiFiAP *get_selected_sta_() const { if (this->selected_sta_index_ >= 0 && static_cast(this->selected_sta_index_) < this->sta_.size()) { return &this->sta_[this->selected_sta_index_]; @@ -356,21 +503,24 @@ class WiFiComponent : public Component { } } -#ifdef USE_WIFI_FAST_CONNECT - // Reset state for next fast connect AP attempt - // Clears old scan data so the new AP is tried with config only (SSID without specific BSSID/channel) - void reset_for_next_ap_attempt_() { - this->num_retried_ = 0; - this->scan_result_.clear(); + bool all_networks_hidden_() const { + if (this->sta_.empty()) + return false; + for (const auto &ap : this->sta_) { + if (!ap.get_hidden()) + return false; + } + return true; } -#endif + + void connect_soon_(); void wifi_loop_(); bool wifi_mode_(optional sta, optional ap); bool wifi_sta_pre_setup_(); bool wifi_apply_output_power_(float output_power); bool wifi_apply_power_save_(); - bool wifi_sta_ip_config_(optional manual_ip); + bool wifi_sta_ip_config_(const optional &manual_ip); bool wifi_apply_hostname_(); bool wifi_sta_connect_(const WiFiAP &ap); void wifi_pre_setup_(); @@ -378,7 +528,7 @@ class WiFiComponent : public Component { bool wifi_scan_start_(bool passive); #ifdef USE_WIFI_AP - bool wifi_ap_ip_config_(optional manual_ip); + bool wifi_ap_ip_config_(const optional &manual_ip); bool wifi_start_ap_(const WiFiAP &ap); #endif // USE_WIFI_AP @@ -427,6 +577,11 @@ class WiFiComponent : public Component { WiFiAP ap_; #endif optional output_power_; +#ifdef USE_WIFI_LISTENERS + std::vector ip_state_listeners_; + std::vector scan_results_listeners_; + std::vector connect_state_listeners_; +#endif // USE_WIFI_LISTENERS ESPPreferenceObject pref_; #ifdef USE_WIFI_FAST_CONNECT ESPPreferenceObject fast_connect_pref_; @@ -443,34 +598,42 @@ class WiFiComponent : public Component { // Group all 8-bit values together WiFiComponentState state_{WIFI_COMPONENT_STATE_OFF}; WiFiPowerSaveMode power_save_{WIFI_POWER_SAVE_NONE}; + WifiMinAuthMode min_auth_mode_{WIFI_MIN_AUTH_MODE_WPA2}; + WiFiRetryPhase retry_phase_{WiFiRetryPhase::INITIAL_CONNECT}; uint8_t num_retried_{0}; // Index into sta_ array for the currently selected AP configuration (-1 = none selected) // Used to access password, manual_ip, priority, EAP settings, and hidden flag // int8_t limits to 127 APs (enforced in __init__.py via MAX_WIFI_NETWORKS) int8_t selected_sta_index_{-1}; + #if USE_NETWORK_IPV6 uint8_t num_ipv6_addresses_{0}; #endif /* USE_NETWORK_IPV6 */ // Group all boolean values together -#ifdef USE_WIFI_FAST_CONNECT - bool trying_loaded_ap_{false}; -#endif - bool retry_hidden_{false}; bool has_ap_{false}; bool handled_connected_state_{false}; bool error_from_callback_{false}; bool scan_done_{false}; bool ap_setup_{false}; + bool ap_started_{false}; bool passive_scan_{false}; bool has_saved_wifi_settings_{false}; #ifdef USE_WIFI_11KV_SUPPORT bool btm_{false}; bool rrm_{false}; #endif - bool enable_on_boot_; + bool enable_on_boot_{true}; bool got_ipv4_address_{false}; bool keep_scan_results_{false}; + bool did_scan_this_cycle_{false}; + bool skip_cooldown_next_cycle_{false}; +#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE) + WiFiPowerSaveMode configured_power_save_{WIFI_POWER_SAVE_NONE}; + bool is_high_performance_mode_{false}; + + SemaphoreHandle_t high_performance_semaphore_{nullptr}; +#endif // Pointers at the end (naturally aligned) Trigger<> *connect_trigger_{new Trigger<>()}; @@ -484,107 +647,5 @@ class WiFiComponent : public Component { extern WiFiComponent *global_wifi_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -template class WiFiConnectedCondition : public Condition { - public: - bool check(const Ts &...x) override { return global_wifi_component->is_connected(); } -}; - -template class WiFiEnabledCondition : public Condition { - public: - bool check(const Ts &...x) override { return !global_wifi_component->is_disabled(); } -}; - -template class WiFiEnableAction : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->enable(); } -}; - -template class WiFiDisableAction : public Action { - public: - void play(const Ts &...x) override { global_wifi_component->disable(); } -}; - -template class WiFiConfigureAction : public Action, public Component { - public: - TEMPLATABLE_VALUE(std::string, ssid) - TEMPLATABLE_VALUE(std::string, password) - TEMPLATABLE_VALUE(bool, save) - TEMPLATABLE_VALUE(uint32_t, connection_timeout) - - void play(const Ts &...x) override { - auto ssid = this->ssid_.value(x...); - auto password = this->password_.value(x...); - // Avoid multiple calls - if (this->connecting_) - return; - // If already connected to the same AP, do nothing - if (global_wifi_component->wifi_ssid() == ssid) { - // Callback to notify the user that the connection was successful - this->connect_trigger_->trigger(); - return; - } - // Create a new WiFiAP object with the new SSID and password - this->new_sta_.set_ssid(ssid); - this->new_sta_.set_password(password); - // Save the current STA - this->old_sta_ = global_wifi_component->get_sta(); - // Disable WiFi - global_wifi_component->disable(); - // Set the state to connecting - this->connecting_ = true; - // Store the new STA so once the WiFi is enabled, it will connect to it - // This is necessary because the WiFiComponent will raise an error and fallback to the saved STA - // if trying to connect to a new STA while already connected to another one - if (this->save_.value(x...)) { - global_wifi_component->save_wifi_sta(new_sta_.get_ssid(), new_sta_.get_password()); - } else { - global_wifi_component->set_sta(new_sta_); - } - // Enable WiFi - global_wifi_component->enable(); - // Set timeout for the connection - this->set_timeout("wifi-connect-timeout", this->connection_timeout_.value(x...), [this, x...]() { - // If the timeout is reached, stop connecting and revert to the old AP - global_wifi_component->disable(); - global_wifi_component->save_wifi_sta(old_sta_.get_ssid(), old_sta_.get_password()); - global_wifi_component->enable(); - // Start a timeout for the fallback if the connection to the old AP fails - this->set_timeout("wifi-fallback-timeout", this->connection_timeout_.value(x...), [this]() { - this->connecting_ = false; - this->error_trigger_->trigger(); - }); - }); - } - - Trigger<> *get_connect_trigger() const { return this->connect_trigger_; } - Trigger<> *get_error_trigger() const { return this->error_trigger_; } - - void loop() override { - if (!this->connecting_) - return; - if (global_wifi_component->is_connected()) { - // The WiFi is connected, stop the timeout and reset the connecting flag - this->cancel_timeout("wifi-connect-timeout"); - this->cancel_timeout("wifi-fallback-timeout"); - this->connecting_ = false; - if (global_wifi_component->wifi_ssid() == this->new_sta_.get_ssid()) { - // Callback to notify the user that the connection was successful - this->connect_trigger_->trigger(); - } else { - // Callback to notify the user that the connection failed - this->error_trigger_->trigger(); - } - } - } - - protected: - bool connecting_{false}; - WiFiAP new_sta_; - WiFiAP old_sta_; - Trigger<> *connect_trigger_{new Trigger<>()}; - Trigger<> *error_trigger_{new Trigger<>()}; -}; - -} // namespace wifi -} // namespace esphome +} // namespace esphome::wifi #endif diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index 4e17c42f41..c1c0dd470f 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -38,8 +38,7 @@ extern "C" { #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace wifi { +namespace esphome::wifi { static const char *const TAG = "wifi_esp8266"; @@ -83,8 +82,11 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (!ret) { ESP_LOGW(TAG, "Set mode failed"); + return false; } + this->ap_started_ = target_ap; + return ret; } bool WiFiComponent::wifi_apply_power_save_() { @@ -117,7 +119,7 @@ void netif_set_addr(struct netif *netif, const ip4_addr_t *ip, const ip4_addr_t }; #endif -bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) return false; @@ -258,8 +260,17 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { if (ap.get_password().empty()) { conf.threshold.authmode = AUTH_OPEN; } else { - // Only allow auth modes with at least WPA - conf.threshold.authmode = AUTH_WPA_PSK; + // Set threshold based on configured minimum auth mode + // Note: ESP8266 doesn't support WPA3 + switch (this->min_auth_mode_) { + case WIFI_MIN_AUTH_MODE_WPA: + conf.threshold.authmode = AUTH_WPA_PSK; + break; + case WIFI_MIN_AUTH_MODE_WPA2: + case WIFI_MIN_AUTH_MODE_WPA3: // Fall back to WPA2 for ESP8266 + conf.threshold.authmode = AUTH_WPA2_PSK; + break; + } } conf.threshold.rssi = -127; #endif @@ -273,9 +284,15 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { return false; } +#ifdef USE_WIFI_MANUAL_IP if (!this->wifi_sta_ip_config_(ap.get_manual_ip())) { return false; } +#else + if (!this->wifi_sta_ip_config_({})) { + return false; + } +#endif // setup enterprise authentication if required #ifdef USE_WIFI_WPA2_EAP @@ -499,6 +516,11 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGV(TAG, "Connected ssid='%s' bssid=%s channel=%u", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel); s_sta_connected = true; +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->connect_state_listeners_) { + listener->on_wifi_connect_state(global_wifi_component->wifi_ssid(), global_wifi_component->wifi_bssid()); + } +#endif break; } case EVENT_STAMODE_DISCONNECTED: { @@ -510,12 +532,19 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf); s_sta_connect_not_found = true; } else { - ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, - format_mac_address_pretty(it.bssid).c_str(), LOG_STR_ARG(get_disconnect_reason_str(it.reason))); + char bssid_s[18]; + format_mac_addr_upper(it.bssid, bssid_s); + ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s, + LOG_STR_ARG(get_disconnect_reason_str(it.reason))); s_sta_connect_error = true; } s_sta_connected = false; s_sta_connecting = false; +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } +#endif break; } case EVENT_STAMODE_AUTHMODE_CHANGE: { @@ -538,6 +567,12 @@ void WiFiComponent::wifi_event_callback(System_Event_t *event) { ESP_LOGV(TAG, "static_ip=%s gateway=%s netmask=%s", format_ip_addr(it.ip).c_str(), format_ip_addr(it.gw).c_str(), format_ip_addr(it.mask).c_str()); s_sta_got_ip = true; +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->ip_state_listeners_) { + listener->on_ip_state(global_wifi_component->wifi_sta_ip_addresses(), global_wifi_component->get_dns_address(0), + global_wifi_component->get_dns_address(1)); + } +#endif break; } case EVENT_STAMODE_DHCP_TIMEOUT: { @@ -712,10 +747,15 @@ void WiFiComponent::wifi_scan_done_callback_(void *arg, STATUS status) { it->is_hidden != 0); } this->scan_done_ = true; +#ifdef USE_WIFI_LISTENERS + for (auto *listener : global_wifi_component->scan_results_listeners_) { + listener->on_wifi_scan_results(global_wifi_component->scan_result_); + } +#endif } #ifdef USE_WIFI_AP -bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { // enable AP if (!this->wifi_mode_({}, true)) return false; @@ -823,10 +863,17 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { return false; } +#ifdef USE_WIFI_MANUAL_IP if (!this->wifi_ap_ip_config_(ap.get_manual_ip())) { ESP_LOGV(TAG, "wifi_ap_ip_config_ failed"); return false; } +#else + if (!this->wifi_ap_ip_config_({})) { + ESP_LOGV(TAG, "wifi_ap_ip_config_ failed"); + return false; + } +#endif return true; } @@ -840,23 +887,26 @@ network::IPAddress WiFiComponent::wifi_soft_ap_ip() { bssid_t WiFiComponent::wifi_bssid() { bssid_t bssid{}; - uint8_t *raw_bssid = WiFi.BSSID(); - if (raw_bssid != nullptr) { - for (size_t i = 0; i < bssid.size(); i++) - bssid[i] = raw_bssid[i]; + struct station_config conf {}; + if (wifi_station_get_config(&conf)) { + std::copy_n(conf.bssid, bssid.size(), bssid.begin()); } return bssid; } std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } -int8_t WiFiComponent::wifi_rssi() { return WiFi.RSSI(); } +int8_t WiFiComponent::wifi_rssi() { + if (WiFi.status() != WL_CONNECTED) + return WIFI_RSSI_DISCONNECTED; + int8_t rssi = WiFi.RSSI(); + // Values >= 31 are error codes per NONOS SDK API, not valid RSSI readings + return rssi >= 31 ? WIFI_RSSI_DISCONNECTED : rssi; +} int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {(const ip_addr_t *) WiFi.subnetMask()}; } network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {(const ip_addr_t *) WiFi.gatewayIP()}; } network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return {(const ip_addr_t *) WiFi.dnsIP(num)}; } void WiFiComponent::wifi_loop_() {} -} // namespace wifi -} // namespace esphome - +} // namespace esphome::wifi #endif #endif diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index 08ecba3598..e1f8108892 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -41,8 +41,7 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace wifi { +namespace esphome::wifi { static const char *const TAG = "wifi_esp32"; @@ -54,7 +53,6 @@ static esp_netif_t *s_ap_netif = nullptr; // NOLINT(cppcoreguidelines-avoid- #endif // USE_WIFI_AP static bool s_sta_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -static bool s_ap_started = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connect_not_found = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connect_error = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) static bool s_sta_connecting = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) @@ -308,7 +306,18 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { if (ap.get_password().empty()) { conf.sta.threshold.authmode = WIFI_AUTH_OPEN; } else { - conf.sta.threshold.authmode = WIFI_AUTH_WPA_WPA2_PSK; + // Set threshold based on configured minimum auth mode + switch (this->min_auth_mode_) { + case WIFI_MIN_AUTH_MODE_WPA: + conf.sta.threshold.authmode = WIFI_AUTH_WPA_PSK; + break; + case WIFI_MIN_AUTH_MODE_WPA2: + conf.sta.threshold.authmode = WIFI_AUTH_WPA2_PSK; + break; + case WIFI_MIN_AUTH_MODE_WPA3: + conf.sta.threshold.authmode = WIFI_AUTH_WPA3_PSK; + break; + } } #ifdef USE_WIFI_WPA2_EAP @@ -347,8 +356,6 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { // The minimum rssi to accept in the fast scan mode conf.sta.threshold.rssi = -127; - conf.sta.threshold.authmode = WIFI_AUTH_OPEN; - wifi_config_t current_conf; esp_err_t err; err = esp_wifi_get_config(WIFI_IF_STA, ¤t_conf); @@ -371,9 +378,15 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { return false; } +#ifdef USE_WIFI_MANUAL_IP if (!this->wifi_sta_ip_config_(ap.get_manual_ip())) { return false; } +#else + if (!this->wifi_sta_ip_config_({})) { + return false; + } +#endif // setup enterprise authentication if required #ifdef USE_WIFI_WPA2_EAP @@ -472,7 +485,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { return true; } -bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) return false; @@ -588,10 +601,6 @@ const char *get_auth_mode_str(uint8_t mode) { } } -std::string format_ip4_addr(const esp_ip4_addr_t &ip) { return str_snprintf(IPSTR, 15, IP2STR(&ip)); } -#if LWIP_IPV6 -std::string format_ip6_addr(const esp_ip6_addr_t &ip) { return str_snprintf(IPV6STR, 39, IPV62STR(ip)); } -#endif /* LWIP_IPV6 */ const char *get_disconnect_reason_str(uint8_t reason) { switch (reason) { case WIFI_REASON_AUTH_EXPIRE: @@ -717,6 +726,11 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode)); s_sta_connected = true; +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); + } +#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_DISCONNECTED) { const auto &it = data->data.sta_disconnected; @@ -731,28 +745,44 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { ESP_LOGI(TAG, "Disconnected ssid='%s' reason='Station Roaming'", buf); return; } else { - ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, - format_mac_address_pretty(it.bssid).c_str(), get_disconnect_reason_str(it.reason)); + char bssid_s[18]; + format_mac_addr_upper(it.bssid, bssid_s); + ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s, + get_disconnect_reason_str(it.reason)); s_sta_connect_error = true; } s_sta_connected = false; s_sta_connecting = false; error_from_callback_ = true; +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } +#endif } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_GOT_IP) { const auto &it = data->data.ip_got_ip; #if USE_NETWORK_IPV6 esp_netif_create_ip6_linklocal(s_sta_netif); #endif /* USE_NETWORK_IPV6 */ - ESP_LOGV(TAG, "static_ip=%s gateway=%s", format_ip4_addr(it.ip_info.ip).c_str(), - format_ip4_addr(it.ip_info.gw).c_str()); + ESP_LOGV(TAG, "static_ip=" IPSTR " gateway=" IPSTR, IP2STR(&it.ip_info.ip), IP2STR(&it.ip_info.gw)); this->got_ipv4_address_ = true; +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } +#endif #if USE_NETWORK_IPV6 } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_GOT_IP6) { const auto &it = data->data.ip_got_ip6; - ESP_LOGV(TAG, "IPv6 address=%s", format_ip6_addr(it.ip6_info.ip).c_str()); + ESP_LOGV(TAG, "IPv6 address=" IPV6STR, IPV62STR(it.ip6_info.ip)); this->num_ipv6_addresses_++; +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } +#endif #endif /* USE_NETWORK_IPV6 */ } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_STA_LOST_IP) { @@ -792,14 +822,19 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { scan_result_.emplace_back(bssid, ssid, record.primary, record.rssi, record.authmode != WIFI_AUTH_OPEN, ssid.empty()); } +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->scan_results_listeners_) { + listener->on_wifi_scan_results(this->scan_result_); + } +#endif } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_START) { ESP_LOGV(TAG, "AP start"); - s_ap_started = true; + this->ap_started_ = true; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_STOP) { ESP_LOGV(TAG, "AP stop"); - s_ap_started = false; + this->ap_started_ = false; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_AP_PROBEREQRECVED) { const auto &it = data->data.ap_probe_req_rx; @@ -815,7 +850,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == IP_EVENT && data->event_id == IP_EVENT_AP_STAIPASSIGNED) { const auto &it = data->data.ip_ap_staipassigned; - ESP_LOGV(TAG, "AP client assigned IP %s", format_ip4_addr(it.ip).c_str()); + ESP_LOGV(TAG, "AP client assigned IP " IPSTR, IP2STR(&it.ip)); } } @@ -869,7 +904,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { } #ifdef USE_WIFI_AP -bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { esp_err_t err; // enable AP @@ -985,10 +1020,17 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { return false; } +#ifdef USE_WIFI_MANUAL_IP if (!this->wifi_ap_ip_config_(ap.get_manual_ip())) { ESP_LOGE(TAG, "wifi_ap_ip_config_ failed:"); return false; } +#else + if (!this->wifi_ap_ip_config_({})) { + ESP_LOGE(TAG, "wifi_ap_ip_config_ failed:"); + return false; + } +#endif return true; } @@ -1007,7 +1049,8 @@ bssid_t WiFiComponent::wifi_bssid() { wifi_ap_record_t info; esp_err_t err = esp_wifi_sta_get_ap_info(&info); if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); + // Very verbose only: this is expected during dump_config() before connection is established (PR #9823) + ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); return bssid; } std::copy(info.bssid, info.bssid + 6, bssid.begin()); @@ -1017,7 +1060,8 @@ std::string WiFiComponent::wifi_ssid() { wifi_ap_record_t info{}; esp_err_t err = esp_wifi_sta_get_ap_info(&info); if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); + // Very verbose only: this is expected during dump_config() before connection is established (PR #9823) + ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); return ""; } auto *ssid_s = reinterpret_cast(info.ssid); @@ -1028,8 +1072,9 @@ int8_t WiFiComponent::wifi_rssi() { wifi_ap_record_t info; esp_err_t err = esp_wifi_sta_get_ap_info(&info); if (err != ESP_OK) { - ESP_LOGW(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); - return 0; + // Very verbose only: this is expected during dump_config() before connection is established (PR #9823) + ESP_LOGVV(TAG, "esp_wifi_sta_get_ap_info failed: %s", esp_err_to_name(err)); + return WIFI_RSSI_DISCONNECTED; } return info.rssi; } @@ -1066,8 +1111,6 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return network::IPAddress(dns_ip); } -} // namespace wifi -} // namespace esphome - +} // namespace esphome::wifi #endif // USE_ESP32 #endif diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 45e2fba82a..0de7003899 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -15,8 +15,7 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace wifi { +namespace esphome::wifi { static const char *const TAG = "wifi_lt"; @@ -51,8 +50,11 @@ bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (!ret) { ESP_LOGW(TAG, "Setting mode failed"); + return false; } + this->ap_started_ = enable_ap; + return ret; } bool WiFiComponent::wifi_apply_output_power_(float output_power) { @@ -68,7 +70,7 @@ bool WiFiComponent::wifi_sta_pre_setup_() { return true; } bool WiFiComponent::wifi_apply_power_save_() { return WiFi.setSleep(this->power_save_ != WIFI_POWER_SAVE_NONE); } -bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) return false; @@ -112,9 +114,15 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { WiFi.disconnect(); } +#ifdef USE_WIFI_MANUAL_IP if (!this->wifi_sta_ip_config_(ap.get_manual_ip())) { return false; } +#else + if (!this->wifi_sta_ip_config_({})) { + return false; + } +#endif this->wifi_apply_hostname_(); @@ -282,7 +290,11 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ buf[it.ssid_len] = '\0'; ESP_LOGV(TAG, "Connected ssid='%s' bssid=" LOG_SECRET("%s") " channel=%u, authmode=%s", buf, format_mac_address_pretty(it.bssid).c_str(), it.channel, get_auth_mode_str(it.authmode)); - +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); + } +#endif break; } case ESPHOME_EVENT_ID_WIFI_STA_DISCONNECTED: { @@ -293,8 +305,10 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ if (it.reason == WIFI_REASON_NO_AP_FOUND) { ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf); } else { - ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, - format_mac_address_pretty(it.bssid).c_str(), get_disconnect_reason_str(it.reason)); + char bssid_s[18]; + format_mac_addr_upper(it.bssid, bssid_s); + ESP_LOGW(TAG, "Disconnected ssid='%s' bssid=" LOG_SECRET("%s") " reason='%s'", buf, bssid_s, + get_disconnect_reason_str(it.reason)); } uint8_t reason = it.reason; @@ -306,6 +320,11 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } s_sta_connecting = false; +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } +#endif break; } case ESPHOME_EVENT_ID_WIFI_STA_AUTHMODE_CHANGE: { @@ -327,11 +346,21 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ ESP_LOGV(TAG, "static_ip=%s gateway=%s", format_ip4_addr(WiFi.localIP()).c_str(), format_ip4_addr(WiFi.gatewayIP()).c_str()); s_sta_connecting = false; +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } +#endif break; } case ESPHOME_EVENT_ID_WIFI_STA_GOT_IP6: { // auto it = info.got_ip.ip_info; ESP_LOGV(TAG, "Got IPv6"); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } +#endif break; } case ESPHOME_EVENT_ID_WIFI_STA_LOST_IP: { @@ -425,10 +454,15 @@ void WiFiComponent::wifi_scan_done_callback_() { } WiFi.scanDelete(); this->scan_done_ = true; +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->scan_results_listeners_) { + listener->on_wifi_scan_results(this->scan_result_); + } +#endif } #ifdef USE_WIFI_AP -bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { // enable AP if (!this->wifi_mode_({}, true)) return false; @@ -445,10 +479,17 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { if (!this->wifi_mode_({}, true)) return false; +#ifdef USE_WIFI_MANUAL_IP if (!this->wifi_ap_ip_config_(ap.get_manual_ip())) { ESP_LOGV(TAG, "wifi_ap_ip_config_ failed"); return false; } +#else + if (!this->wifi_ap_ip_config_({})) { + ESP_LOGV(TAG, "wifi_ap_ip_config_ failed"); + return false; + } +#endif yield(); @@ -471,15 +512,13 @@ bssid_t WiFiComponent::wifi_bssid() { return bssid; } std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } -int8_t WiFiComponent::wifi_rssi() { return WiFi.RSSI(); } +int8_t WiFiComponent::wifi_rssi() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; } int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } network::IPAddress WiFiComponent::wifi_subnet_mask_() { return {WiFi.subnetMask()}; } network::IPAddress WiFiComponent::wifi_gateway_ip_() { return {WiFi.gatewayIP()}; } network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { return {WiFi.dnsIP(num)}; } void WiFiComponent::wifi_loop_() {} -} // namespace wifi -} // namespace esphome - +} // namespace esphome::wifi #endif // USE_LIBRETINY #endif diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index bf15892cd5..c7dc4120dd 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -1,4 +1,3 @@ - #include "wifi_component.h" #ifdef USE_WIFI @@ -15,22 +14,29 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace wifi { +namespace esphome::wifi { static const char *const TAG = "wifi_pico_w"; +// Track previous state for detecting changes +static bool s_sta_was_connected = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) +static bool s_sta_had_ip = false; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + bool WiFiComponent::wifi_mode_(optional sta, optional ap) { if (sta.has_value()) { if (sta.value()) { cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_STA, true, CYW43_COUNTRY_WORLDWIDE); } } + + bool ap_state = false; if (ap.has_value()) { if (ap.value()) { cyw43_wifi_set_up(&cyw43_state, CYW43_ITF_AP, true, CYW43_COUNTRY_WORLDWIDE); + ap_state = true; } } + this->ap_started_ = ap_state; return true; } @@ -51,12 +57,17 @@ bool WiFiComponent::wifi_apply_power_save_() { return ret == 0; } -// TODO: The driver doesnt seem to have an API for this +// TODO: The driver doesn't seem to have an API for this bool WiFiComponent::wifi_apply_output_power_(float output_power) { return true; } bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { +#ifdef USE_WIFI_MANUAL_IP if (!this->wifi_sta_ip_config_(ap.get_manual_ip())) return false; +#else + if (!this->wifi_sta_ip_config_({})) + return false; +#endif auto ret = WiFi.begin(ap.get_ssid().c_str(), ap.get_password().c_str()); if (ret != WL_CONNECTED) @@ -67,7 +78,7 @@ bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { bool WiFiComponent::wifi_sta_pre_setup_() { return this->wifi_mode_(true, {}); } -bool WiFiComponent::wifi_sta_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { if (!manual_ip.has_value()) { return true; } @@ -141,7 +152,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { } #ifdef USE_WIFI_AP -bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { +bool WiFiComponent::wifi_ap_ip_config_(const optional &manual_ip) { esphome::network::IPAddress ip_address, gateway, subnet, dns; if (manual_ip.has_value()) { ip_address = manual_ip->static_ip; @@ -161,10 +172,17 @@ bool WiFiComponent::wifi_ap_ip_config_(optional manual_ip) { bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { if (!this->wifi_mode_({}, true)) return false; +#ifdef USE_WIFI_MANUAL_IP if (!this->wifi_ap_ip_config_(ap.get_manual_ip())) { ESP_LOGV(TAG, "wifi_ap_ip_config_ failed"); return false; } +#else + if (!this->wifi_ap_ip_config_({})) { + ESP_LOGV(TAG, "wifi_ap_ip_config_ failed"); + return false; + } +#endif WiFi.beginAP(ap.get_ssid().c_str(), ap.get_password().c_str(), ap.get_channel().value_or(1)); @@ -188,7 +206,7 @@ bssid_t WiFiComponent::wifi_bssid() { return bssid; } std::string WiFiComponent::wifi_ssid() { return WiFi.SSID().c_str(); } -int8_t WiFiComponent::wifi_rssi() { return WiFi.RSSI(); } +int8_t WiFiComponent::wifi_rssi() { return WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : WIFI_RSSI_DISCONNECTED; } int32_t WiFiComponent::get_wifi_channel() { return WiFi.channel(); } network::IPAddresses WiFiComponent::wifi_sta_ip_addresses() { @@ -207,16 +225,69 @@ network::IPAddress WiFiComponent::wifi_dns_ip_(int num) { } void WiFiComponent::wifi_loop_() { + // Handle scan completion if (this->state_ == WIFI_COMPONENT_STATE_STA_SCANNING && !cyw43_wifi_scan_active(&cyw43_state)) { this->scan_done_ = true; ESP_LOGV(TAG, "Scan done"); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->scan_results_listeners_) { + listener->on_wifi_scan_results(this->scan_result_); + } +#endif + } + + // Poll for connection state changes + // The arduino-pico WiFi library doesn't have event callbacks like ESP8266/ESP32, + // so we need to poll the link status to detect state changes + auto status = cyw43_tcpip_link_status(&cyw43_state, CYW43_ITF_STA); + bool is_connected = (status == CYW43_LINK_UP); + + // Detect connection state change + if (is_connected && !s_sta_was_connected) { + // Just connected + s_sta_was_connected = true; + ESP_LOGV(TAG, "Connected"); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state(this->wifi_ssid(), this->wifi_bssid()); + } +#endif + } else if (!is_connected && s_sta_was_connected) { + // Just disconnected + s_sta_was_connected = false; + s_sta_had_ip = false; + ESP_LOGV(TAG, "Disconnected"); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->connect_state_listeners_) { + listener->on_wifi_connect_state("", bssid_t({0, 0, 0, 0, 0, 0})); + } +#endif + } + + // Detect IP address changes (only when connected) + if (is_connected) { + bool has_ip = false; + // Check for any IP address (IPv4 or IPv6) + for (auto addr : addrList) { + has_ip = true; + break; + } + + if (has_ip && !s_sta_had_ip) { + // Just got IP address + s_sta_had_ip = true; + ESP_LOGV(TAG, "Got IP address"); +#ifdef USE_WIFI_LISTENERS + for (auto *listener : this->ip_state_listeners_) { + listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1)); + } +#endif + } } } void WiFiComponent::wifi_pre_setup_() {} -} // namespace wifi -} // namespace esphome - +} // namespace esphome::wifi #endif #endif diff --git a/esphome/components/wifi_info/text_sensor.py b/esphome/components/wifi_info/text_sensor.py index a4da582c55..bc0c038f80 100644 --- a/esphome/components/wifi_info/text_sensor.py +++ b/esphome/components/wifi_info/text_sensor.py @@ -15,31 +15,27 @@ DEPENDENCIES = ["wifi"] wifi_info_ns = cg.esphome_ns.namespace("wifi_info") IPAddressWiFiInfo = wifi_info_ns.class_( - "IPAddressWiFiInfo", text_sensor.TextSensor, cg.PollingComponent + "IPAddressWiFiInfo", text_sensor.TextSensor, cg.Component ) ScanResultsWiFiInfo = wifi_info_ns.class_( - "ScanResultsWiFiInfo", text_sensor.TextSensor, cg.PollingComponent -) -SSIDWiFiInfo = wifi_info_ns.class_( - "SSIDWiFiInfo", text_sensor.TextSensor, cg.PollingComponent + "ScanResultsWiFiInfo", text_sensor.TextSensor, cg.Component ) +SSIDWiFiInfo = wifi_info_ns.class_("SSIDWiFiInfo", text_sensor.TextSensor, cg.Component) BSSIDWiFiInfo = wifi_info_ns.class_( - "BSSIDWiFiInfo", text_sensor.TextSensor, cg.PollingComponent + "BSSIDWiFiInfo", text_sensor.TextSensor, cg.Component ) MacAddressWifiInfo = wifi_info_ns.class_( "MacAddressWifiInfo", text_sensor.TextSensor, cg.Component ) DNSAddressWifiInfo = wifi_info_ns.class_( - "DNSAddressWifiInfo", text_sensor.TextSensor, cg.PollingComponent + "DNSAddressWifiInfo", text_sensor.TextSensor, cg.Component ) CONFIG_SCHEMA = cv.Schema( { cv.Optional(CONF_IP_ADDRESS): text_sensor.text_sensor_schema( IPAddressWiFiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ) - .extend(cv.polling_component_schema("1s")) - .extend( + ).extend( { cv.Optional(f"address_{x}"): text_sensor.text_sensor_schema( entity_category=ENTITY_CATEGORY_DIAGNOSTIC, @@ -49,22 +45,31 @@ CONFIG_SCHEMA = cv.Schema( ), cv.Optional(CONF_SCAN_RESULTS): text_sensor.text_sensor_schema( ScanResultsWiFiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ).extend(cv.polling_component_schema("60s")), + ), cv.Optional(CONF_SSID): text_sensor.text_sensor_schema( SSIDWiFiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ).extend(cv.polling_component_schema("1s")), + ), cv.Optional(CONF_BSSID): text_sensor.text_sensor_schema( BSSIDWiFiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ).extend(cv.polling_component_schema("1s")), + ), cv.Optional(CONF_MAC_ADDRESS): text_sensor.text_sensor_schema( MacAddressWifiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ), cv.Optional(CONF_DNS_ADDRESS): text_sensor.text_sensor_schema( DNSAddressWifiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC - ).extend(cv.polling_component_schema("1s")), + ), } ) +# Keys that require WiFi listeners +_NETWORK_INFO_KEYS = { + CONF_SSID, + CONF_BSSID, + CONF_IP_ADDRESS, + CONF_DNS_ADDRESS, + CONF_SCAN_RESULTS, +} + async def setup_conf(config, key): if key in config: @@ -74,6 +79,10 @@ async def setup_conf(config, key): async def to_code(config): + # Request WiFi listeners for any sensor that needs them + if _NETWORK_INFO_KEYS.intersection(config): + wifi.request_wifi_listeners() + await setup_conf(config, CONF_SSID) await setup_conf(config, CONF_BSSID) await setup_conf(config, CONF_MAC_ADDRESS) diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index 2612e4af8d..6c9d0c00e5 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -2,18 +2,111 @@ #ifdef USE_WIFI #include "esphome/core/log.h" -namespace esphome { -namespace wifi_info { +namespace esphome::wifi_info { static const char *const TAG = "wifi_info"; +#ifdef USE_WIFI_LISTENERS + +static constexpr size_t MAX_STATE_LENGTH = 255; + +/******************** + * IPAddressWiFiInfo + *******************/ + +void IPAddressWiFiInfo::setup() { wifi::global_wifi_component->add_ip_state_listener(this); } + void IPAddressWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "IP Address", this); } -void ScanResultsWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "Scan Results", this); } -void SSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "SSID", this); } -void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); } -void MacAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "MAC Address", this); } + +void IPAddressWiFiInfo::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) { + this->publish_state(ips[0].str()); + uint8_t sensor = 0; + for (const auto &ip : ips) { + if (ip.is_set()) { + if (this->ip_sensors_[sensor] != nullptr) { + this->ip_sensors_[sensor]->publish_state(ip.str()); + } + sensor++; + } + } +} + +/********************* + * DNSAddressWifiInfo + ********************/ + +void DNSAddressWifiInfo::setup() { wifi::global_wifi_component->add_ip_state_listener(this); } + void DNSAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "DNS Address", this); } -} // namespace wifi_info -} // namespace esphome +void DNSAddressWifiInfo::on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) { + std::string dns_results = dns1.str() + " " + dns2.str(); + this->publish_state(dns_results); +} + +/********************** + * ScanResultsWiFiInfo + *********************/ + +void ScanResultsWiFiInfo::setup() { wifi::global_wifi_component->add_scan_results_listener(this); } + +void ScanResultsWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "Scan Results", this); } + +void ScanResultsWiFiInfo::on_wifi_scan_results(const wifi::wifi_scan_vector_t &results) { + std::string scan_results; + for (const auto &scan : results) { + if (scan.get_is_hidden()) + continue; + + scan_results += scan.get_ssid(); + scan_results += ": "; + scan_results += esphome::to_string(scan.get_rssi()); + scan_results += "dB\n"; + } + // There's a limit of 255 characters per state; longer states just don't get sent so we truncate it + if (scan_results.length() > MAX_STATE_LENGTH) { + scan_results.resize(MAX_STATE_LENGTH); + } + this->publish_state(scan_results); +} + +/*************** + * SSIDWiFiInfo + **************/ + +void SSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_listener(this); } + +void SSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "SSID", this); } + +void SSIDWiFiInfo::on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) { + this->publish_state(ssid); +} + +/**************** + * BSSIDWiFiInfo + ***************/ + +void BSSIDWiFiInfo::setup() { wifi::global_wifi_component->add_connect_state_listener(this); } + +void BSSIDWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "BSSID", this); } + +void BSSIDWiFiInfo::on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) { + char buf[18] = "unknown"; + if (mac_address_is_valid(bssid.data())) { + format_mac_addr_upper(bssid.data(), buf); + } + this->publish_state(buf); +} + +#endif + +/********************* + * MacAddressWifiInfo + ********************/ + +void MacAddressWifiInfo::dump_config() { LOG_TEXT_SENSOR("", "MAC Address", this); } + +} // namespace esphome::wifi_info #endif diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index 04889d6bb3..f1f85c114f 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -7,129 +7,72 @@ #ifdef USE_WIFI #include -namespace esphome { -namespace wifi_info { +namespace esphome::wifi_info { -static constexpr size_t MAX_STATE_LENGTH = 255; - -class IPAddressWiFiInfo : public PollingComponent, public text_sensor::TextSensor { +#ifdef USE_WIFI_LISTENERS +class IPAddressWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiIPStateListener { public: - void update() override { - auto ips = wifi::global_wifi_component->wifi_sta_ip_addresses(); - if (ips != this->last_ips_) { - this->last_ips_ = ips; - this->publish_state(ips[0].str()); - uint8_t sensor = 0; - for (auto &ip : ips) { - if (ip.is_set()) { - if (this->ip_sensors_[sensor] != nullptr) { - this->ip_sensors_[sensor]->publish_state(ip.str()); - } - sensor++; - } - } - } - } - float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + void setup() override; void dump_config() override; void add_ip_sensors(uint8_t index, text_sensor::TextSensor *s) { this->ip_sensors_[index] = s; } + // WiFiIPStateListener interface + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; + protected: - network::IPAddresses last_ips_; std::array ip_sensors_; }; -class DNSAddressWifiInfo : public PollingComponent, public text_sensor::TextSensor { +class DNSAddressWifiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiIPStateListener { public: - void update() override { - auto dns_one = wifi::global_wifi_component->get_dns_address(0); - auto dns_two = wifi::global_wifi_component->get_dns_address(1); + void setup() override; + void dump_config() override; - std::string dns_results = dns_one.str() + " " + dns_two.str(); + // WiFiIPStateListener interface + void on_ip_state(const network::IPAddresses &ips, const network::IPAddress &dns1, + const network::IPAddress &dns2) override; +}; - if (dns_results != this->last_results_) { - this->last_results_ = dns_results; - this->publish_state(dns_results); - } - } +class ScanResultsWiFiInfo final : public Component, + public text_sensor::TextSensor, + public wifi::WiFiScanResultsListener { + public: + void setup() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void dump_config() override; - protected: - std::string last_results_; + // WiFiScanResultsListener interface + void on_wifi_scan_results(const wifi::wifi_scan_vector_t &results) override; }; -class ScanResultsWiFiInfo : public PollingComponent, public text_sensor::TextSensor { +class SSIDWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiConnectStateListener { public: - void update() override { - std::string scan_results; - for (auto &scan : wifi::global_wifi_component->get_scan_result()) { - if (scan.get_is_hidden()) - continue; - - scan_results += scan.get_ssid(); - scan_results += ": "; - scan_results += esphome::to_string(scan.get_rssi()); - scan_results += "dB\n"; - } - - // There's a limit of 255 characters per state. - // Longer states just don't get sent so we truncate it. - if (scan_results.length() > MAX_STATE_LENGTH) { - scan_results.resize(MAX_STATE_LENGTH); - } - if (this->last_scan_results_ != scan_results) { - this->last_scan_results_ = scan_results; - this->publish_state(scan_results); - } - } - float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + void setup() override; void dump_config() override; - protected: - std::string last_scan_results_; + // WiFiConnectStateListener interface + void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override; }; -class SSIDWiFiInfo : public PollingComponent, public text_sensor::TextSensor { +class BSSIDWiFiInfo final : public Component, public text_sensor::TextSensor, public wifi::WiFiConnectStateListener { public: - void update() override { - std::string ssid = wifi::global_wifi_component->wifi_ssid(); - if (this->last_ssid_ != ssid) { - this->last_ssid_ = ssid; - this->publish_state(this->last_ssid_); - } - } - float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + void setup() override; void dump_config() override; - protected: - std::string last_ssid_; + // WiFiConnectStateListener interface + void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override; }; - -class BSSIDWiFiInfo : public PollingComponent, public text_sensor::TextSensor { - public: - void update() override { - wifi::bssid_t bssid = wifi::global_wifi_component->wifi_bssid(); - if (memcmp(bssid.data(), last_bssid_.data(), 6) != 0) { - std::copy(bssid.begin(), bssid.end(), last_bssid_.begin()); - char buf[18]; - format_mac_addr_upper(bssid.data(), buf); - this->publish_state(buf); - } - } - float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } - void dump_config() override; - - protected: - wifi::bssid_t last_bssid_; -}; - -class MacAddressWifiInfo : public Component, public text_sensor::TextSensor { - public: - void setup() override { this->publish_state(get_mac_address_pretty()); } - void dump_config() override; -}; - -} // namespace wifi_info -} // namespace esphome +#endif + +class MacAddressWifiInfo final : public Component, public text_sensor::TextSensor { + public: + void setup() override { + char mac_s[18]; + this->publish_state(get_mac_address_pretty_into_buffer(mac_s)); + } + void dump_config() override; +}; + +} // namespace esphome::wifi_info #endif diff --git a/esphome/components/zwave_proxy/__init__.py b/esphome/components/zwave_proxy/__init__.py index d88f9f7041..5be05bb464 100644 --- a/esphome/components/zwave_proxy/__init__.py +++ b/esphome/components/zwave_proxy/__init__.py @@ -41,3 +41,6 @@ async def to_code(config): await cg.register_component(var, config) await uart.register_uart_device(var, config) cg.add_define("USE_ZWAVE_PROXY") + + # Request UART to wake the main loop when data arrives for low-latency processing + uart.request_wake_loop_on_rx() diff --git a/esphome/components/zwave_proxy/zwave_proxy.cpp b/esphome/components/zwave_proxy/zwave_proxy.cpp index a26a9b2335..e0ca5529b8 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.cpp +++ b/esphome/components/zwave_proxy/zwave_proxy.cpp @@ -5,8 +5,7 @@ #include "esphome/core/log.h" #include "esphome/core/util.h" -namespace esphome { -namespace zwave_proxy { +namespace esphome::zwave_proxy { static const char *const TAG = "zwave_proxy"; @@ -144,6 +143,7 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en this->api_connection_ = api_connection; ESP_LOGV(TAG, "API connection is now subscribed"); break; + case api::enums::ZWAVE_PROXY_REQUEST_TYPE_UNSUBSCRIBE: if (this->api_connection_ != api_connection) { ESP_LOGV(TAG, "API connection is not subscribed"); @@ -151,6 +151,7 @@ void ZWaveProxy::zwave_proxy_request(api::APIConnection *api_connection, api::en } this->api_connection_ = nullptr; break; + default: ESP_LOGW(TAG, "Unknown request type: %d", type); break; @@ -342,5 +343,4 @@ bool ZWaveProxy::response_handler_() { ZWaveProxy *global_zwave_proxy = nullptr; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace zwave_proxy -} // namespace esphome +} // namespace esphome::zwave_proxy diff --git a/esphome/components/zwave_proxy/zwave_proxy.h b/esphome/components/zwave_proxy/zwave_proxy.h index 20d9090d98..e23e202bea 100644 --- a/esphome/components/zwave_proxy/zwave_proxy.h +++ b/esphome/components/zwave_proxy/zwave_proxy.h @@ -8,8 +8,7 @@ #include -namespace esphome { -namespace zwave_proxy { +namespace esphome::zwave_proxy { static constexpr size_t MAX_ZWAVE_FRAME_SIZE = 257; // Maximum Z-Wave frame size @@ -89,5 +88,4 @@ class ZWaveProxy : public uart::UARTDevice, public Component { extern ZWaveProxy *global_zwave_proxy; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) -} // namespace zwave_proxy -} // namespace esphome +} // namespace esphome::zwave_proxy diff --git a/esphome/config.py b/esphome/config.py index e508ca585b..1c4cdd93c6 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -338,21 +338,46 @@ def check_replaceme(value): ) -def _build_list_index(lst): +def _get_item_id(item: Any) -> str | Extend | Remove | None: + """Attempts to get a list item's ID""" + if not isinstance(item, dict): + return None # not a dict, can't have ID + # 1.- Check regular case: + # - id: my_id + item_id = item.get(CONF_ID) + if item_id is None and len(item) == 1: + # 2.- Check single-key dict case: + # - obj: + # id: my_id + item = next(iter(item.values())) + if isinstance(item, dict): + item_id = item.get(CONF_ID) + if isinstance(item_id, Extend): + # Remove instances of Extend so they don't overwrite the original item when merging: + del item[CONF_ID] + elif not isinstance(item_id, (str, Remove)): + return None + return item_id + + +def _build_list_index( + lst: list[Any], +) -> tuple[ + OrderedDict[str | Extend | Remove, Any], list[tuple[int, str, Any]], set[str] +]: index = OrderedDict() extensions, removals = [], set() - for item in lst: + for pos, item in enumerate(lst): if item is None: removals.add(None) continue - item_id = None - if isinstance(item, dict) and (item_id := item.get(CONF_ID)): - if isinstance(item_id, Extend): - extensions.append(item) - continue - if isinstance(item_id, Remove): - removals.add(item_id.value) - continue + item_id = _get_item_id(item) + if isinstance(item_id, Extend): + extensions.append((pos, item_id.value, item)) + continue + if isinstance(item_id, Remove): + removals.add(item_id.value) + continue if not item_id or item_id in index: # no id or duplicate -> pass through with identity-based key item_id = id(item) @@ -360,7 +385,7 @@ def _build_list_index(lst): return index, extensions, removals -def resolve_extend_remove(value, is_key=None): +def resolve_extend_remove(value: Any, is_key: bool = False) -> None: if isinstance(value, ESPLiteralValue): return # do not check inside literal blocks if isinstance(value, list): @@ -368,26 +393,16 @@ def resolve_extend_remove(value, is_key=None): if extensions or removals: # Rebuild the original list after # processing all extensions and removals - for item in extensions: - item_id = item[CONF_ID].value + for pos, item_id, item in extensions: if item_id in removals: continue old = index.get(item_id) if old is None: # Failed to find source for extension - # Find index of item to show error at correct position - i = next( - ( - i - for i, d in enumerate(value) - if d.get(CONF_ID) == item[CONF_ID] - ) - ) - with cv.prepend_path(i): + with cv.prepend_path(pos): raise cv.Invalid( f"Source for extension of ID '{item_id}' was not found." ) - item[CONF_ID] = item_id index[item_id] = merge_config(old, item) for item_id in removals: index.pop(item_id, None) diff --git a/esphome/const.py b/esphome/const.py index d0d94ed283..59bf0e8b8a 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.11.0-dev" +__version__ = "2025.12.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( @@ -36,7 +36,30 @@ class Framework(StrEnum): class ThreadModel(StrEnum): - """Threading model identifiers for ESPHome scheduler.""" + """Threading model identifiers for ESPHome scheduler. + + ESPHome currently uses three threading models based on platform capabilities: + + SINGLE: + - Single-threaded platforms (ESP8266, RP2040) + - No RTOS task switching + - No concurrent access to scheduler data structures + - No atomics or locks required + - Minimal overhead + + MULTI_NO_ATOMICS: + - Multi-threaded platforms without hardware atomic RMW support (e.g. LibreTiny BK7231N) + - Uses FreeRTOS or another RTOS with multiple tasks + - CPU lacks exclusive load/store instructions (ARM968E-S has no LDREX/STREX) + - std::atomic cannot provide lock-free RMW; libatomic is avoided to save flash (4–8 KB) + - Scheduler uses explicit FreeRTOS mutexes for synchronization + + MULTI_ATOMICS: + - Multi-threaded platforms with hardware atomic RMW support (ESP32, Cortex-M, Host) + - CPU provides native atomic instructions (ESP32 S32C1I, ARM LDREX/STREX) + - std::atomic is used for lock-free synchronization + - Reduced contention and better performance + """ SINGLE = "ESPHOME_THREAD_SINGLE" MULTI_NO_ATOMICS = "ESPHOME_THREAD_MULTI_NO_ATOMICS" @@ -336,6 +359,7 @@ CONF_ENERGY = "energy" CONF_ENTITY_CATEGORY = "entity_category" CONF_ENTITY_ID = "entity_id" CONF_ENUM_DATAPOINT = "enum_datapoint" +CONF_ENVIRONMENT_VARIABLES = "environment_variables" CONF_EQUATION = "equation" CONF_ESP8266_DISABLE_SSL_SUPPORT = "esp8266_disable_ssl_support" CONF_ESPHOME = "esphome" @@ -1322,6 +1346,9 @@ STATE_CLASS_NONE = "" # The state represents a measurement in present time STATE_CLASS_MEASUREMENT = "measurement" +# The state represents a measurement in present time for angles measured in degrees (°) +STATE_CLASS_MEASUREMENT_ANGLE = "measurement_angle" + # The state represents a total that only increases, a decrease is considered a reset. STATE_CLASS_TOTAL_INCREASING = "total_increasing" diff --git a/esphome/core/application.h b/esphome/core/application.h index dae44d8902..14e800342e 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -105,11 +105,13 @@ class Application { arch_init(); this->name_add_mac_suffix_ = name_add_mac_suffix; if (name_add_mac_suffix) { + // MAC address length: 12 hex chars + null terminator + constexpr size_t mac_address_len = 13; // MAC address suffix length (last 6 characters of 12-char MAC address string) constexpr size_t mac_address_suffix_len = 6; - const std::string mac_addr = get_mac_address(); - // Use pointer + offset to avoid substr() allocation - const char *mac_suffix_ptr = mac_addr.c_str() + mac_address_suffix_len; + char mac_addr[mac_address_len]; + get_mac_address_into_buffer(mac_addr); + const char *mac_suffix_ptr = mac_addr + mac_address_suffix_len; this->name_ = make_name_with_suffix(name, '-', mac_suffix_ptr, mac_address_suffix_len); if (!friendly_name.empty()) { this->friendly_name_ = make_name_with_suffix(friendly_name, ' ', mac_suffix_ptr, mac_address_suffix_len); diff --git a/esphome/core/automation.h b/esphome/core/automation.h index 33e08c9c1c..dacadd35e8 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -11,10 +11,26 @@ namespace esphome { +// C++20 std::index_sequence is now used for tuple unpacking +// Legacy seq<>/gens<> pattern deprecated but kept for backwards compatibility // https://stackoverflow.com/questions/7858817/unpacking-a-tuple-to-call-a-matching-function-pointer/7858971#7858971 -template struct seq {}; // NOLINT -template struct gens : gens {}; // NOLINT -template struct gens<0, S...> { using type = seq; }; // NOLINT +// Remove before 2026.6.0 +// NOLINTBEGIN(readability-identifier-naming) +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" +#endif + +template struct ESPDEPRECATED("Use std::index_sequence instead. Removed in 2026.6.0", "2025.12.0") seq {}; +template +struct ESPDEPRECATED("Use std::make_index_sequence instead. Removed in 2026.6.0", "2025.12.0") gens + : gens {}; +template struct gens<0, S...> { using type = seq; }; + +#if defined(__GNUC__) || defined(__clang__) +#pragma GCC diagnostic pop +#endif +// NOLINTEND(readability-identifier-naming) #define TEMPLATABLE_VALUE_(type, name) \ protected: \ @@ -152,11 +168,11 @@ template class Condition { /// Call check with a tuple of values as parameter. bool check_tuple(const std::tuple &tuple) { - return this->check_tuple_(tuple, typename gens::type()); + return this->check_tuple_(tuple, std::make_index_sequence{}); } protected: - template bool check_tuple_(const std::tuple &tuple, seq /*unused*/) { + template bool check_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { return this->check(std::get(tuple)...); } }; @@ -231,11 +247,11 @@ template class Action { } } } - template void play_next_tuple_(const std::tuple &tuple, seq /*unused*/) { + template void play_next_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->play_next_(std::get(tuple)...); } void play_next_tuple_(const std::tuple &tuple) { - this->play_next_tuple_(tuple, typename gens::type()); + this->play_next_tuple_(tuple, std::make_index_sequence{}); } virtual void stop() {} @@ -277,7 +293,9 @@ template class ActionList { if (this->actions_begin_ != nullptr) this->actions_begin_->play_complex(x...); } - void play_tuple(const std::tuple &tuple) { this->play_tuple_(tuple, typename gens::type()); } + void play_tuple(const std::tuple &tuple) { + this->play_tuple_(tuple, std::make_index_sequence{}); + } void stop() { if (this->actions_begin_ != nullptr) this->actions_begin_->stop_complex(); @@ -298,7 +316,7 @@ template class ActionList { } protected: - template void play_tuple_(const std::tuple &tuple, seq /*unused*/) { + template void play_tuple_(const std::tuple &tuple, std::index_sequence /*unused*/) { this->play(std::get(tuple)...); } diff --git a/esphome/core/base_automation.h b/esphome/core/base_automation.h index 6f392c8959..e8878ac251 100644 --- a/esphome/core/base_automation.h +++ b/esphome/core/base_automation.h @@ -9,8 +9,8 @@ #include "esphome/core/application.h" #include "esphome/core/helpers.h" +#include #include -#include namespace esphome { @@ -178,7 +178,6 @@ template class DelayAction : public Action, public Compon TEMPLATABLE_VALUE(uint32_t, delay) void play_complex(const Ts &...x) override { - auto f = std::bind(&DelayAction::play_next_, this, x...); this->num_running_++; // If num_running_ > 1, we have multiple instances running in parallel @@ -187,9 +186,22 @@ template class DelayAction : public Action, public Compon // WARNING: This can accumulate delays if scripts are triggered faster than they complete! // Users should set max_runs on parallel scripts to limit concurrent executions. // Issue #10264: This is a workaround for parallel script delays interfering with each other. - App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, - /* is_static_string= */ true, "delay", this->delay_.value(x...), std::move(f), - /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + + // Optimization: For no-argument delays (most common case), use direct lambda + // instead of std::bind to avoid bind overhead (~16 bytes heap + faster execution) + if constexpr (sizeof...(Ts) == 0) { + App.scheduler.set_timer_common_( + this, Scheduler::SchedulerItem::TIMEOUT, + /* is_static_string= */ true, "delay", this->delay_.value(), [this]() { this->play_next_(); }, + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + } else { + // For delays with arguments, use std::bind to preserve argument values + // Arguments must be copied because original references may be invalid after delay + auto f = std::bind(&DelayAction::play_next_, this, x...); + App.scheduler.set_timer_common_(this, Scheduler::SchedulerItem::TIMEOUT, + /* is_static_string= */ true, "delay", this->delay_.value(x...), std::move(f), + /* is_retry= */ false, /* skip_cancel= */ this->num_running_ > 1); + } } float get_setup_priority() const override { return setup_priority::HARDWARE; } @@ -412,7 +424,12 @@ template class WaitUntilAction : public Action, public Co void setup() override { // Start with loop disabled - only enable when there's work to do - this->disable_loop(); + // IMPORTANT: Only disable if num_running_ is 0, otherwise play_complex() was already + // called before our setup() (e.g., from on_boot trigger at same priority level) + // and we must not undo its enable_loop() call + if (this->num_running_ == 0) { + this->disable_loop(); + } } void play_complex(const Ts &...x) override { @@ -428,9 +445,10 @@ template class WaitUntilAction : public Action, public Co // Store for later processing auto now = millis(); auto timeout = this->timeout_value_.optional_value(x...); - this->var_queue_.emplace_front(now, timeout, std::make_tuple(x...)); + this->var_queue_.emplace_back(now, timeout, std::make_tuple(x...)); - // Do immediate check with fresh timestamp + // Do immediate check with fresh timestamp - don't call loop() synchronously! + // Let the event loop call it to avoid reentrancy issues if (this->process_queue_(now)) { // Only enable loop if we still have pending items this->enable_loop(); @@ -482,7 +500,7 @@ template class WaitUntilAction : public Action, public Co } Condition *condition_; - std::forward_list, std::tuple>> var_queue_{}; + std::list, std::tuple>> var_queue_{}; }; template class UpdateComponentAction : public Action { diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index de3dd99d0c..b7c0cedb76 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -36,6 +36,9 @@ namespace { struct ComponentErrorMessage { const Component *component; const char *message; + // Track if message is flash pointer (needs LOG_STR_ARG) or RAM pointer + // Remove before 2026.6.0 when deprecated const char* API is removed + bool is_flash_ptr; }; struct ComponentPriorityOverride { @@ -49,6 +52,25 @@ std::unique_ptr> component_error_messages; // Setup priority overrides - freed after setup completes // NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) std::unique_ptr> setup_priority_overrides; + +// Helper to store error messages - reduces duplication between deprecated and new API +// Remove before 2026.6.0 when deprecated const char* API is removed +void store_component_error_message(const Component *component, const char *message, bool is_flash_ptr) { + // Lazy allocate the error messages vector if needed + if (!component_error_messages) { + component_error_messages = std::make_unique>(); + } + // Check if this component already has an error message + for (auto &entry : *component_error_messages) { + if (entry.component == component) { + entry.message = message; + entry.is_flash_ptr = is_flash_ptr; + return; + } + } + // Add new error message + component_error_messages->emplace_back(ComponentErrorMessage{component, message, is_flash_ptr}); +} } // namespace namespace setup_priority { @@ -143,16 +165,20 @@ void Component::call_dump_config() { if (this->is_failed()) { // Look up error message from global vector const char *error_msg = nullptr; + bool is_flash_ptr = false; if (component_error_messages) { for (const auto &entry : *component_error_messages) { if (entry.component == this) { error_msg = entry.message; + is_flash_ptr = entry.is_flash_ptr; break; } } } + // Log with appropriate format based on pointer type ESP_LOGE(TAG, " %s is marked FAILED: %s", LOG_STR_ARG(this->get_component_log_str()), - error_msg ? error_msg : LOG_STR_LITERAL("unspecified")); + error_msg ? (is_flash_ptr ? LOG_STR_ARG((const LogString *) error_msg) : error_msg) + : LOG_STR_LITERAL("unspecified")); } } @@ -307,6 +333,7 @@ void Component::status_set_warning(const LogString *message) { ESP_LOGW(TAG, "%s set Warning flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); } +void Component::status_set_error() { this->status_set_error((const LogString *) nullptr); } void Component::status_set_error(const char *message) { if ((this->component_state_ & STATUS_LED_ERROR) != 0) return; @@ -315,19 +342,19 @@ void Component::status_set_error(const char *message) { ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), message ? message : LOG_STR_LITERAL("unspecified")); if (message != nullptr) { - // Lazy allocate the error messages vector if needed - if (!component_error_messages) { - component_error_messages = std::make_unique>(); - } - // Check if this component already has an error message - for (auto &entry : *component_error_messages) { - if (entry.component == this) { - entry.message = message; - return; - } - } - // Add new error message - component_error_messages->emplace_back(ComponentErrorMessage{this, message}); + store_component_error_message(this, message, false); + } +} +void Component::status_set_error(const LogString *message) { + if ((this->component_state_ & STATUS_LED_ERROR) != 0) + return; + this->component_state_ |= STATUS_LED_ERROR; + App.app_state_ |= STATUS_LED_ERROR; + ESP_LOGE(TAG, "%s set Error flag: %s", LOG_STR_ARG(this->get_component_log_str()), + message ? LOG_STR_ARG(message) : LOG_STR_LITERAL("unspecified")); + if (message != nullptr) { + // Store the LogString pointer directly (safe because LogString is always in flash/static memory) + store_component_error_message(this, LOG_STR_ARG(message), true); } } void Component::status_clear_warning() { @@ -342,11 +369,11 @@ void Component::status_clear_error() { this->component_state_ &= ~STATUS_LED_ERROR; ESP_LOGE(TAG, "%s cleared Error flag", LOG_STR_ARG(this->get_component_log_str())); } -void Component::status_momentary_warning(const std::string &name, uint32_t length) { +void Component::status_momentary_warning(const char *name, uint32_t length) { this->status_set_warning(); this->set_timeout(name, length, [this]() { this->status_clear_warning(); }); } -void Component::status_momentary_error(const std::string &name, uint32_t length) { +void Component::status_momentary_error(const char *name, uint32_t length) { this->status_set_error(); this->set_timeout(name, length, [this]() { this->status_clear_error(); }); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 462e0e301c..3d45a020c4 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -5,6 +5,7 @@ #include #include +#include "esphome/core/helpers.h" #include "esphome/core/log.h" #include "esphome/core/optional.h" @@ -157,7 +158,19 @@ class Component { */ virtual void mark_failed(); + // Remove before 2026.6.0 + ESPDEPRECATED("Use mark_failed(LOG_STR(\"static string literal\")) instead. Do NOT use .c_str() from temporary " + "strings. Will stop working in 2026.6.0", + "2025.12.0") void mark_failed(const char *message) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdeprecated-declarations" + this->status_set_error(message); +#pragma GCC diagnostic pop + this->mark_failed(); + } + + void mark_failed(const LogString *message) { this->status_set_error(message); this->mark_failed(); } @@ -216,15 +229,35 @@ class Component { void status_set_warning(const char *message = nullptr); void status_set_warning(const LogString *message); - void status_set_error(const char *message = nullptr); + void status_set_error(); // Set error flag without message + // Remove before 2026.6.0 + ESPDEPRECATED("Use status_set_error(LOG_STR(\"static string literal\")) instead. Do NOT use .c_str() from temporary " + "strings. Will stop working in 2026.6.0", + "2025.12.0") + void status_set_error(const char *message); + void status_set_error(const LogString *message); void status_clear_warning(); void status_clear_error(); - void status_momentary_warning(const std::string &name, uint32_t length = 5000); + /** Set warning status flag and automatically clear it after a timeout. + * + * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). + * Must be a static string literal (stored in flash/rodata), not a temporary or dynamic string. + * This is NOT a message to display - use status_set_warning() with a message if logging is needed. + * @param length Duration in milliseconds before the warning is automatically cleared. + */ + void status_momentary_warning(const char *name, uint32_t length = 5000); - void status_momentary_error(const std::string &name, uint32_t length = 5000); + /** Set error status flag and automatically clear it after a timeout. + * + * @param name Identifier for the timeout (used to cancel/replace existing timeouts with the same name). + * Must be a static string literal (stored in flash/rodata), not a temporary or dynamic string. + * This is NOT a message to display - use status_set_error() with a message if logging is needed. + * @param length Duration in milliseconds before the error is automatically cleared. + */ + void status_momentary_error(const char *name, uint32_t length = 5000); bool has_overridden_loop() const; diff --git a/esphome/core/component_iterator.cpp b/esphome/core/component_iterator.cpp index 668c4a1fda..8c6a7b95b5 100644 --- a/esphome/core/component_iterator.cpp +++ b/esphome/core/component_iterator.cpp @@ -5,7 +5,7 @@ #ifdef USE_API #include "esphome/components/api/api_server.h" #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS #include "esphome/components/api/user_services.h" #endif @@ -81,7 +81,7 @@ void ComponentIterator::advance() { break; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS case IteratorState::SERVICE: this->process_platform_item_(api::global_api_server->get_user_services(), &ComponentIterator::on_service); break; @@ -185,7 +185,7 @@ void ComponentIterator::advance() { bool ComponentIterator::on_end() { return true; } bool ComponentIterator::on_begin() { return true; } -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS bool ComponentIterator::on_service(api::UserServiceDescriptor *service) { return true; } #endif #ifdef USE_CAMERA diff --git a/esphome/core/component_iterator.h b/esphome/core/component_iterator.h index 641d42898a..1b1bd80ac5 100644 --- a/esphome/core/component_iterator.h +++ b/esphome/core/component_iterator.h @@ -10,7 +10,7 @@ namespace esphome { -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS namespace api { class UserServiceDescriptor; } // namespace api @@ -45,7 +45,7 @@ class ComponentIterator { #ifdef USE_TEXT_SENSOR virtual bool on_text_sensor(text_sensor::TextSensor *text_sensor) = 0; #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS virtual bool on_service(api::UserServiceDescriptor *service); #endif #ifdef USE_CAMERA @@ -122,7 +122,7 @@ class ComponentIterator { #ifdef USE_TEXT_SENSOR TEXT_SENSOR, #endif -#ifdef USE_API_SERVICES +#ifdef USE_API_USER_DEFINED_ACTIONS SERVICE, #endif #ifdef USE_CAMERA diff --git a/esphome/core/config.py b/esphome/core/config.py index 763f9ebd9f..0a239c5f5e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -17,6 +17,7 @@ from esphome.const import ( CONF_COMPILE_PROCESS_LIMIT, CONF_DEBUG_SCHEDULER, CONF_DEVICES, + CONF_ENVIRONMENT_VARIABLES, CONF_ESPHOME, CONF_FRIENDLY_NAME, CONF_ID, @@ -215,6 +216,11 @@ CONFIG_SCHEMA = cv.All( cv.string_strict: cv.Any([cv.string], cv.string), } ), + cv.Optional(CONF_ENVIRONMENT_VARIABLES, default={}): cv.Schema( + { + cv.string_strict: cv.string, + } + ), cv.Optional(CONF_ON_BOOT): automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(StartupTrigger), @@ -426,6 +432,12 @@ async def _add_platformio_options(pio_options): cg.add_platformio_option(key, val) +@coroutine_with_priority(CoroPriority.FINAL) +async def _add_environment_variables(env_vars: dict[str, str]) -> None: + # Set environment variables for the build process + os.environ.update(env_vars) + + @coroutine_with_priority(CoroPriority.AUTOMATION) async def _add_automations(config): for conf in config.get(CONF_ON_BOOT, []): @@ -563,6 +575,9 @@ async def to_code(config: ConfigType) -> None: if config[CONF_PLATFORMIO_OPTIONS]: CORE.add_job(_add_platformio_options, config[CONF_PLATFORMIO_OPTIONS]) + if config[CONF_ENVIRONMENT_VARIABLES]: + CORE.add_job(_add_environment_variables, config[CONF_ENVIRONMENT_VARIABLES]) + # Process areas all_areas: list[dict[str, str | core.ID]] = [] if CONF_AREA in config: diff --git a/esphome/core/defines.h b/esphome/core/defines.h index ac725fbca9..12dfdba5ce 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -51,6 +51,7 @@ #define USE_LIGHT #define USE_LOCK #define USE_LOGGER +#define USE_LOGGER_LEVEL_LISTENERS #define USE_LOGGER_RUNTIME_TAG_LEVELS #define USE_LVGL #define USE_LVGL_ANIMIMG @@ -106,6 +107,7 @@ #define USE_TIME #define USE_TOUCHSCREEN #define USE_UART_DEBUGGER +#define USE_UART_WAKE_LOOP_ON_RX #define USE_UPDATE #define USE_VALVE #define USE_ZWAVE_PROXY @@ -124,7 +126,7 @@ #define USE_API_HOMEASSISTANT_STATES #define USE_API_NOISE #define USE_API_PLAINTEXT -#define USE_API_SERVICES +#define USE_API_USER_DEFINED_ACTIONS #define USE_API_CUSTOM_SERVICES #define API_MAX_SEND_QUEUE 8 #define USE_MD5 @@ -144,6 +146,7 @@ #define USE_TIME_TIMEZONE #define USE_WIFI #define USE_WIFI_AP +#define USE_WIFI_MANUAL_IP #define USE_WIREGUARD #endif @@ -209,12 +212,15 @@ #define USE_WEBSERVER_SORTING #define USE_WIFI_11KV_SUPPORT #define USE_WIFI_FAST_CONNECT +#define USE_WIFI_LISTENERS +#define USE_WIFI_RUNTIME_POWER_SAVE #define USB_HOST_MAX_REQUESTS 16 #ifdef USE_ARDUINO #define USE_ARDUINO_VERSION_CODE VERSION_CODE(3, 3, 2) #define USE_ETHERNET #define USE_ETHERNET_KSZ8081 +#define USE_ETHERNET_MANUAL_IP #endif #ifdef USE_ESP_IDF @@ -287,6 +293,8 @@ #ifdef USE_NRF52 #define USE_NRF52_DFU +#define USE_NRF52_REG0_VOUT 5 +#define USE_NRF52_UICR_ERASE #define USE_SOFTDEVICE_ID 7 #define USE_SOFTDEVICE_VERSION 1 #endif diff --git a/esphome/core/entity_base.cpp b/esphome/core/entity_base.cpp index 4883c72cf1..046f99d8cc 100644 --- a/esphome/core/entity_base.cpp +++ b/esphome/core/entity_base.cpp @@ -74,6 +74,12 @@ void EntityBase::set_object_id(const char *object_id) { this->calc_object_id_(); } +void EntityBase::set_name_and_object_id(const char *name, const char *object_id) { + this->set_name(name); + this->object_id_c_str_ = object_id; + this->calc_object_id_(); +} + // Calculate Object ID Hash from Entity Name void EntityBase::calc_object_id_() { this->object_id_hash_ = diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index 2b52d66f76..aa9b92877a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -41,6 +41,9 @@ class EntityBase { std::string get_object_id() const; void set_object_id(const char *object_id); + // Set both name and object_id in one call (reduces generated code size) + void set_name_and_object_id(const char *name, const char *object_id); + // Get the unique Object ID of this Entity uint32_t get_object_id_hash(); diff --git a/esphome/core/entity_helpers.py b/esphome/core/entity_helpers.py index 9b4786f835..f360b4d809 100644 --- a/esphome/core/entity_helpers.py +++ b/esphome/core/entity_helpers.py @@ -84,8 +84,6 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: # Get device name for object ID calculation device_name = device_id_obj.id - add(var.set_name(config[CONF_NAME])) - # Calculate base object_id using the same logic as C++ # This must match the C++ behavior in esphome/core/entity_base.cpp base_object_id = get_base_entity_object_id( @@ -97,8 +95,8 @@ async def setup_entity(var: MockObj, config: ConfigType, platform: str) -> None: "Entity has empty name, using '%s' as object_id base", base_object_id ) - # Set the object ID - add(var.set_object_id(base_object_id)) + # Set both name and object_id in one call to reduce generated code size + add(var.set_name_and_object_id(config[CONF_NAME], base_object_id)) _LOGGER.debug( "Setting object_id '%s' for entity '%s' on platform '%s'", base_object_id, diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 568acb9f1b..1f675563c7 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -238,9 +238,9 @@ std::string str_sprintf(const char *fmt, ...) { // Maximum size for name with suffix: 120 (max friendly name) + 1 (separator) + 6 (MAC suffix) + 1 (null term) static constexpr size_t MAX_NAME_WITH_SUFFIX_SIZE = 128; -std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len) { +std::string make_name_with_suffix(const char *name, size_t name_len, char sep, const char *suffix_ptr, + size_t suffix_len) { char buffer[MAX_NAME_WITH_SUFFIX_SIZE]; - size_t name_len = name.size(); size_t total_len = name_len + 1 + suffix_len; // Silently truncate if needed: prioritize keeping the full suffix @@ -252,13 +252,17 @@ std::string make_name_with_suffix(const std::string &name, char sep, const char total_len = name_len + 1 + suffix_len; } - memcpy(buffer, name.c_str(), name_len); + memcpy(buffer, name, name_len); buffer[name_len] = sep; memcpy(buffer + name_len + 1, suffix_ptr, suffix_len); buffer[total_len] = '\0'; return std::string(buffer, total_len); } +std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len) { + return make_name_with_suffix(name.c_str(), name.size(), sep, suffix_ptr, suffix_len); +} + // Parsing & formatting size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { @@ -638,9 +642,8 @@ std::string get_mac_address() { } std::string get_mac_address_pretty() { - uint8_t mac[6]; - get_mac_address_raw(mac); - return format_mac_address_pretty(mac); + char buf[18]; + return std::string(get_mac_address_pretty_into_buffer(buf)); } void get_mac_address_into_buffer(std::span buf) { @@ -649,6 +652,13 @@ void get_mac_address_into_buffer(std::span buf) { format_mac_addr_lower_no_sep(mac, buf.data()); } +const char *get_mac_address_pretty_into_buffer(std::span buf) { + uint8_t mac[6]; + get_mac_address_raw(mac); + format_mac_addr_upper(mac, buf.data()); + return buf.data(); +} + #ifndef USE_ESP32 bool has_custom_mac_address() { return false; } #endif diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index dbc1de7582..9a8132c53d 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -111,6 +111,23 @@ template<> constexpr int64_t byteswap(int64_t n) { return __builtin_bswap64(n); /// @name Container utilities ///@{ +/// Lightweight read-only view over a const array stored in RODATA (will typically be in flash memory) +/// Avoids copying data from flash to RAM by keeping a pointer to the flash data. +/// Similar to std::span but with minimal overhead for embedded systems. + +template class ConstVector { + public: + constexpr ConstVector(const T *data, size_t size) : data_(data), size_(size) {} + + const constexpr T &operator[](size_t i) const { return data_[i]; } + constexpr size_t size() const { return size_; } + constexpr bool empty() const { return size_ == 0; } + + protected: + const T *data_; + size_t size_; +}; + /// Minimal static vector - saves memory by avoiding std::vector overhead template class StaticVector { public: @@ -225,6 +242,9 @@ template class FixedVector { other.reset_(); } + // Allow conversion to std::vector + operator std::vector() const { return {data_, data_ + size_}; } + FixedVector &operator=(FixedVector &&other) noexcept { if (this != &other) { // Delete our current data @@ -495,6 +515,17 @@ std::string __attribute__((format(printf, 1, 2))) str_sprintf(const char *fmt, . /// @return The concatenated string: name + sep + suffix std::string make_name_with_suffix(const std::string &name, char sep, const char *suffix_ptr, size_t suffix_len); +/// Optimized string concatenation: name + separator + suffix (const char* overload) +/// Uses a fixed stack buffer to avoid heap allocations. +/// @param name The base name string +/// @param name_len Length of the name +/// @param sep Single character separator +/// @param suffix_ptr Pointer to the suffix characters +/// @param suffix_len Length of the suffix +/// @return The concatenated string: name + sep + suffix +std::string make_name_with_suffix(const char *name, size_t name_len, char sep, const char *suffix_ptr, + size_t suffix_len); + ///@} /// @name Parsing & formatting @@ -1102,6 +1133,11 @@ std::string get_mac_address_pretty(); /// Assumes buffer length is 13 (12 digits for hexadecimal representation followed by null terminator). void get_mac_address_into_buffer(std::span buf); +/// Get the device MAC address into the given buffer, in colon-separated uppercase hex notation. +/// Buffer must be exactly 18 bytes (17 for "XX:XX:XX:XX:XX:XX" + null terminator). +/// Returns pointer to the buffer for convenience. +const char *get_mac_address_pretty_into_buffer(std::span buf); + #ifdef USE_ESP32 /// Set the MAC address to use from the provided byte array (6 bytes). void set_mac_address(uint8_t *mac); @@ -1241,12 +1277,18 @@ template using ExternalRAMAllocator = RAMAllocator; * Functions to constrain the range of arithmetic values. */ -template T clamp_at_least(T value, T min) { +template +concept comparable_with = requires(T a, U b) { + { a > b } -> std::convertible_to; + { a < b } -> std::convertible_to; +}; + +template U> T clamp_at_least(T value, U min) { if (value < min) return min; return value; } -template T clamp_at_most(T value, T max) { +template U> T clamp_at_most(T value, U max) { if (value > max) return max; return value; diff --git a/esphome/core/macros.h b/esphome/core/macros.h index 8b2383321b..2e47453c40 100644 --- a/esphome/core/macros.h +++ b/esphome/core/macros.h @@ -6,3 +6,7 @@ #ifdef USE_ARDUINO #include #endif + +#ifdef USE_ZEPHYR +#define M_PI 3.14159265358979323846 +#endif diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h new file mode 100644 index 0000000000..67131fd113 --- /dev/null +++ b/esphome/core/progmem.h @@ -0,0 +1,16 @@ +#pragma once + +// Platform-agnostic macros for PROGMEM string handling +// On ESP32 (both Arduino and IDF): Use plain strings (no PROGMEM) +// On ESP8266/Arduino: Use Arduino's F() macro for PROGMEM strings + +#ifdef USE_ESP32 +#define ESPHOME_F(string_literal) (string_literal) +#define ESPHOME_PGM_P const char * +#define ESPHOME_strncpy_P strncpy +#else +// ESP8266 and other Arduino platforms use Arduino macros +#define ESPHOME_F(string_literal) F(string_literal) +#define ESPHOME_PGM_P PGM_P +#define ESPHOME_strncpy_P strncpy_P +#endif diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index d285af2d0e..352587bf10 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -154,8 +154,8 @@ void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type // For retries, check if there's a cancelled timeout first if (is_retry && name_cstr != nullptr && type == SchedulerItem::TIMEOUT && - (has_cancelled_timeout_in_container_(this->items_, component, name_cstr, /* match_retry= */ true) || - has_cancelled_timeout_in_container_(this->to_add_, component, name_cstr, /* match_retry= */ true))) { + (has_cancelled_timeout_in_container_locked_(this->items_, component, name_cstr, /* match_retry= */ true) || + has_cancelled_timeout_in_container_locked_(this->to_add_, component, name_cstr, /* match_retry= */ true))) { // Skip scheduling - the retry was cancelled #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item", name_cstr); @@ -359,8 +359,7 @@ void HOT Scheduler::call(uint32_t now) { std::unique_ptr item; { LockGuard guard{this->lock_}; - item = std::move(this->items_[0]); - this->pop_raw_(); + item = this->pop_raw_locked_(); } const char *name = item->get_name(); @@ -401,7 +400,7 @@ void HOT Scheduler::call(uint32_t now) { // Don't run on failed components if (item->component != nullptr && item->component->is_failed()) { LockGuard guard{this->lock_}; - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); continue; } @@ -414,7 +413,7 @@ void HOT Scheduler::call(uint32_t now) { { LockGuard guard{this->lock_}; if (is_item_removed_(item.get())) { - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -423,7 +422,7 @@ void HOT Scheduler::call(uint32_t now) { // Single-threaded or multi-threaded with atomics: can check without lock if (is_item_removed_(item.get())) { LockGuard guard{this->lock_}; - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -443,14 +442,14 @@ void HOT Scheduler::call(uint32_t now) { LockGuard guard{this->lock_}; - auto executed_item = std::move(this->items_[0]); // Only pop after function call, this ensures we were reachable // during the function call and know if we were cancelled. - this->pop_raw_(); + auto executed_item = this->pop_raw_locked_(); if (executed_item->remove) { - // We were removed/cancelled in the function call, stop + // We were removed/cancelled in the function call, recycle and continue this->to_remove_--; + this->recycle_item_(std::move(executed_item)); continue; } @@ -497,7 +496,7 @@ size_t HOT Scheduler::cleanup_() { return this->items_.size(); // We must hold the lock for the entire cleanup operation because: - // 1. We're modifying items_ (via pop_raw_) which requires exclusive access + // 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access // 2. We're decrementing to_remove_ which is also modified by other threads // (though all modifications are already under lock) // 3. Other threads read items_ when searching for items to cancel in cancel_item_locked_() @@ -510,17 +509,18 @@ size_t HOT Scheduler::cleanup_() { if (!item->remove) break; this->to_remove_--; - this->pop_raw_(); + this->recycle_item_(this->pop_raw_locked_()); } return this->items_.size(); } -void HOT Scheduler::pop_raw_() { +std::unique_ptr HOT Scheduler::pop_raw_locked_() { std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp); - // Instead of destroying, recycle the item - this->recycle_item_(std::move(this->items_.back())); + // Move the item out before popping - this is the item that was at the front of the heap + auto item = std::move(this->items_.back()); this->items_.pop_back(); + return item; } // Helper to execute a scheduler item @@ -556,7 +556,8 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c #ifndef ESPHOME_THREAD_SINGLE // Mark items in defer queue as cancelled (they'll be skipped when processed) if (type == SchedulerItem::TIMEOUT) { - total_cancelled += this->mark_matching_items_removed_(this->defer_queue_, component, name_cstr, type, match_retry); + total_cancelled += + this->mark_matching_items_removed_locked_(this->defer_queue_, component, name_cstr, type, match_retry); } #endif /* not ESPHOME_THREAD_SINGLE */ @@ -565,19 +566,20 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c // (removing the last element doesn't break heap structure) if (!this->items_.empty()) { auto &last_item = this->items_.back(); - if (this->matches_item_(last_item, component, name_cstr, type, match_retry)) { + if (this->matches_item_locked_(last_item, component, name_cstr, type, match_retry)) { this->recycle_item_(std::move(this->items_.back())); this->items_.pop_back(); total_cancelled++; } // For other items in heap, we can only mark for removal (can't remove from middle of heap) - size_t heap_cancelled = this->mark_matching_items_removed_(this->items_, component, name_cstr, type, match_retry); + size_t heap_cancelled = + this->mark_matching_items_removed_locked_(this->items_, component, name_cstr, type, match_retry); total_cancelled += heap_cancelled; this->to_remove_ += heap_cancelled; // Track removals for heap items } // Cancel items in to_add_ - total_cancelled += this->mark_matching_items_removed_(this->to_add_, component, name_cstr, type, match_retry); + total_cancelled += this->mark_matching_items_removed_locked_(this->to_add_, component, name_cstr, type, match_retry); return total_cancelled > 0; } @@ -609,13 +611,12 @@ uint64_t Scheduler::millis_64_(uint32_t now) { if (now < last && (last - now) > HALF_MAX_UINT32) { this->millis_major_++; major++; + this->last_millis_ = now; #ifdef ESPHOME_DEBUG_SCHEDULER ESP_LOGD(TAG, "Detected true 32-bit rollover at %" PRIu32 "ms (was %" PRIu32 ")", now, last); #endif /* ESPHOME_DEBUG_SCHEDULER */ - } - - // Only update if time moved forward - if (now > last) { + } else if (now > last) { + // Only update if time moved forward this->last_millis_ = now; } diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index fd16840240..08e003c9fb 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -219,7 +219,9 @@ class Scheduler { // Returns the number of items remaining after cleanup // IMPORTANT: This method should only be called from the main thread (loop task). size_t cleanup_(); - void pop_raw_(); + // Remove and return the front item from the heap + // IMPORTANT: Caller must hold the scheduler lock before calling this function. + std::unique_ptr pop_raw_locked_(); private: // Helper to cancel items by name - must be called with lock held @@ -243,8 +245,18 @@ class Scheduler { } // Helper function to check if item matches criteria for cancellation - inline bool HOT matches_item_(const std::unique_ptr &item, Component *component, const char *name_cstr, - SchedulerItem::Type type, bool match_retry, bool skip_removed = true) const { + // IMPORTANT: Must be called with scheduler lock held + inline bool HOT matches_item_locked_(const std::unique_ptr &item, Component *component, + const char *name_cstr, SchedulerItem::Type type, bool match_retry, + bool skip_removed = true) const { + // THREAD SAFETY: Check for nullptr first to prevent LoadProhibited crashes. On multi-threaded + // platforms, items can be moved out of defer_queue_ during processing, leaving nullptr entries. + // PR #11305 added nullptr checks in callers (mark_matching_items_removed_locked_() and + // has_cancelled_timeout_in_container_locked_()), but this check provides defense-in-depth: helper + // functions should be safe regardless of caller behavior. + // Fixes: https://github.com/esphome/esphome/issues/11940 + if (!item) + return false; if (item->component != component || item->type != type || (skip_removed && item->remove) || (match_retry && !item->is_retry)) { return false; @@ -304,8 +316,8 @@ class Scheduler { // SAFETY: Moving out the unique_ptr leaves a nullptr in the vector at defer_queue_front_. // This is intentional and safe because: // 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function - // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_ - // and has_cancelled_timeout_in_container_ in scheduler.h) + // 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_ + // and has_cancelled_timeout_in_container_locked_ in scheduler.h) // 3. The lock protects concurrent access, but the nullptr remains until cleanup item = std::move(this->defer_queue_[this->defer_queue_front_]); this->defer_queue_front_++; @@ -393,10 +405,10 @@ class Scheduler { // Helper to mark matching items in a container as removed // Returns the number of items marked for removal - // IMPORTANT: Caller must hold the scheduler lock before calling this function. + // IMPORTANT: Must be called with scheduler lock held template - size_t mark_matching_items_removed_(Container &container, Component *component, const char *name_cstr, - SchedulerItem::Type type, bool match_retry) { + size_t mark_matching_items_removed_locked_(Container &container, Component *component, const char *name_cstr, + SchedulerItem::Type type, bool match_retry) { size_t count = 0; for (auto &item : container) { // Skip nullptr items (can happen in defer_queue_ when items are being processed) @@ -405,7 +417,7 @@ class Scheduler { // the vector can still contain nullptr items from the processing loop. This check prevents crashes. if (!item) continue; - if (this->matches_item_(item, component, name_cstr, type, match_retry)) { + if (this->matches_item_locked_(item, component, name_cstr, type, match_retry)) { // Mark item for removal (platform-specific) this->set_item_removed_(item.get(), true); count++; @@ -415,9 +427,10 @@ class Scheduler { } // Template helper to check if any item in a container matches our criteria + // IMPORTANT: Must be called with scheduler lock held template - bool has_cancelled_timeout_in_container_(const Container &container, Component *component, const char *name_cstr, - bool match_retry) const { + bool has_cancelled_timeout_in_container_locked_(const Container &container, Component *component, + const char *name_cstr, bool match_retry) const { for (const auto &item : container) { // Skip nullptr items (can happen in defer_queue_ when items are being processed) // The defer_queue_ uses index-based processing: items are std::moved out but left in the @@ -426,8 +439,8 @@ class Scheduler { if (!item) continue; if (is_item_removed_(item.get()) && - this->matches_item_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry, - /* skip_removed= */ false)) { + this->matches_item_locked_(item, component, name_cstr, SchedulerItem::TIMEOUT, match_retry, + /* skip_removed= */ false)) { return true; } } diff --git a/esphome/cpp_generator.py b/esphome/cpp_generator.py index 6f1af01a5b..1a47b346b7 100644 --- a/esphome/cpp_generator.py +++ b/esphome/cpp_generator.py @@ -659,7 +659,7 @@ async def get_variable_with_full_id(id_: ID) -> tuple[ID, "MockObj"]: async def process_lambda( value: Lambda, parameters: TemplateArgsType, - capture: str = "=", + capture: str = "", return_type: SafeExpType = None, ) -> LambdaExpression | None: """Process the given lambda value into a LambdaExpression. @@ -702,12 +702,6 @@ async def process_lambda( parts[i * 3 + 1] = var parts[i * 3 + 2] = "" - # All id() references are global variables in generated C++ code. - # Global variables should not be captured - they're accessible everywhere. - # Use empty capture instead of capture-by-value. - if capture == "=": - capture = "" - if isinstance(value, ESPHomeDataBase) and value.esp_range is not None: location = value.esp_range.start_mark location.line += value.content_offset diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index fcb3a4f438..b27b6b8ed1 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -4,7 +4,7 @@ dependencies: espressif/esp32-camera: version: 2.1.1 espressif/mdns: - version: 1.8.2 + version: 1.9.1 espressif/esp_wifi_remote: version: 1.1.5 rules: diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 093ee64df4..0d50edbc2c 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -30,6 +30,7 @@ from esphome.const import ( from esphome.core import CORE, EsphomeError from esphome.helpers import get_int_env, get_str_env from esphome.log import AnsiFore, color +from esphome.types import ConfigType from esphome.util import safe_print _LOGGER = logging.getLogger(__name__) @@ -154,8 +155,12 @@ def show_discover(config, username=None, password=None, client_id=None): def get_esphome_device_ip( - config, username=None, password=None, client_id=None, timeout=25 -): + config: ConfigType, + username: str | None = None, + password: str | None = None, + client_id: str | None = None, + timeout: int | float = 25, +) -> list[str]: if CONF_MQTT not in config: raise EsphomeError( "Cannot discover IP via MQTT as the config does not include the mqtt: " @@ -166,6 +171,10 @@ def get_esphome_device_ip( "Cannot discover IP via MQTT as the config does not include the device name: " "component" ) + if not config[CONF_MQTT].get(CONF_BROKER): + raise EsphomeError( + "Cannot discover IP via MQTT as the broker is not configured" + ) dev_name = config[CONF_ESPHOME][CONF_NAME] dev_ip = None diff --git a/esphome/platformio_api.py b/esphome/platformio_api.py index d59523a74a..4d795ea5d9 100644 --- a/esphome/platformio_api.py +++ b/esphome/platformio_api.py @@ -107,9 +107,24 @@ FILTER_PLATFORMIO_LINES = [ r"Warning: DEPRECATED: 'esptool.py' is deprecated. Please use 'esptool' instead. The '.py' suffix will be removed in a future major release.", r"Warning: esp-idf-size exited with code 2", r"esp_idf_size: error: unrecognized arguments: --ng", + r"Package configuration completed successfully", ] +class PlatformioLogFilter(logging.Filter): + """Filter to suppress noisy platformio log messages.""" + + _PATTERN = re.compile( + r"|".join(r"(?:" + pattern + r")" for pattern in FILTER_PLATFORMIO_LINES) + ) + + def filter(self, record: logging.LogRecord) -> bool: + # Only filter messages from platformio-related loggers + if "platformio" not in record.name.lower(): + return True + return self._PATTERN.match(record.getMessage()) is None + + def run_platformio_cli(*args, **kwargs) -> str | int: os.environ["PLATFORMIO_FORCE_COLOR"] = "true" os.environ["PLATFORMIO_BUILD_DIR"] = str(CORE.relative_pioenvs_path().absolute()) @@ -130,7 +145,18 @@ def run_platformio_cli(*args, **kwargs) -> str | int: patch_structhash() patch_file_downloader() - return run_external_command(platformio.__main__.main, *cmd, **kwargs) + + # Add log filter to suppress noisy platformio messages + log_filter = PlatformioLogFilter() if not CORE.verbose else None + if log_filter: + for handler in logging.getLogger().handlers: + handler.addFilter(log_filter) + try: + return run_external_command(platformio.__main__.main, *cmd, **kwargs) + finally: + if log_filter: + for handler in logging.getLogger().handlers: + handler.removeFilter(log_filter) def run_platformio_cli_run(config, verbose, *args, **kwargs) -> str | int: diff --git a/esphome/writer.py b/esphome/writer.py index 8eee445cf1..721db07f96 100644 --- a/esphome/writer.py +++ b/esphome/writer.py @@ -1,8 +1,12 @@ +from collections.abc import Callable import importlib import logging import os from pathlib import Path import re +import shutil +import stat +from types import TracebackType from esphome import loader from esphome.config import iter_component_configs, iter_components @@ -121,7 +125,7 @@ def update_storage_json() -> None: ) else: _LOGGER.info("Core config or version changed, cleaning build files...") - clean_build() + clean_build(clear_pio_cache=False) elif storage_should_update_cmake_cache(old, new): _LOGGER.info("Integrations changed, cleaning cmake cache...") clean_cmake_cache() @@ -301,9 +305,24 @@ def clean_cmake_cache(): pioenvs_cmake_path.unlink() -def clean_build(): - import shutil +def _rmtree_error_handler( + func: Callable[[str], object], + path: str, + exc_info: tuple[type[BaseException], BaseException, TracebackType | None], +) -> None: + """Error handler for shutil.rmtree to handle read-only files on Windows. + On Windows, git pack files and other files may be marked read-only, + causing shutil.rmtree to fail with "Access is denied". This handler + removes the read-only flag and retries the deletion. + """ + if os.access(path, os.W_OK): + raise exc_info[1].with_traceback(exc_info[2]) + os.chmod(path, stat.S_IWUSR | stat.S_IRUSR) + func(path) + + +def clean_build(clear_pio_cache: bool = True): # 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)") @@ -312,16 +331,19 @@ def clean_build(): pioenvs = CORE.relative_pioenvs_path() if pioenvs.is_dir(): _LOGGER.info("Deleting %s", pioenvs) - shutil.rmtree(pioenvs) + shutil.rmtree(pioenvs, onerror=_rmtree_error_handler) piolibdeps = CORE.relative_piolibdeps_path() if piolibdeps.is_dir(): _LOGGER.info("Deleting %s", piolibdeps) - shutil.rmtree(piolibdeps) + shutil.rmtree(piolibdeps, onerror=_rmtree_error_handler) dependencies_lock = CORE.relative_build_path("dependencies.lock") if dependencies_lock.is_file(): _LOGGER.info("Deleting %s", dependencies_lock) dependencies_lock.unlink() + if not clear_pio_cache: + return + # Clean PlatformIO cache to resolve CMake compiler detection issues # This helps when toolchain paths change or get corrupted try: @@ -334,13 +356,17 @@ def clean_build(): cache_dir = Path(config.get("platformio", "cache_dir")) if cache_dir.is_dir(): _LOGGER.info("Deleting PlatformIO cache %s", cache_dir) - shutil.rmtree(cache_dir) + shutil.rmtree(cache_dir, onerror=_rmtree_error_handler) def clean_all(configuration: list[str]): - import shutil - - data_dirs = [Path(dir) / ".esphome" for dir in configuration] + data_dirs = [] + for config in configuration: + item = Path(config) + if item.is_file() and item.suffix in (".yaml", ".yml"): + data_dirs.append(item.parent / ".esphome") + else: + data_dirs.append(item / ".esphome") if is_ha_addon(): data_dirs.append(Path("/data")) if "ESPHOME_DATA_DIR" in os.environ: @@ -355,7 +381,7 @@ def clean_all(configuration: list[str]): if item.is_file() and not item.name.endswith(".json"): item.unlink() elif item.is_dir() and item.name != "storage": - shutil.rmtree(item) + shutil.rmtree(item, onerror=_rmtree_error_handler) # Clean PlatformIO project files try: @@ -369,7 +395,7 @@ def clean_all(configuration: list[str]): path = Path(config.get("platformio", pio_dir)) if path.is_dir(): _LOGGER.info("Deleting PlatformIO %s %s", pio_dir, path) - shutil.rmtree(path) + shutil.rmtree(path, onerror=_rmtree_error_handler) GITIGNORE_CONTENT = """# Gitignore settings for ESPHome diff --git a/requirements.txt b/requirements.txt index 40802422f2..5d824a6859 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,17 +12,17 @@ platformio==6.1.18 # When updating platformio, also update /docker/Dockerfile esptool==5.1.0 click==8.1.7 esphome-dashboard==20251013.0 -aioesphomeapi==42.7.0 +aioesphomeapi==42.10.0 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import -ruamel.yaml.clib==0.2.14 # dashboard_import +ruamel.yaml.clib==0.2.15 # dashboard_import esphome-glyphsets==0.2.0 pillow==11.3.0 cairosvg==2.8.2 freetype-py==2.5.1 jinja2==3.1.6 -bleak==1.1.1 +bleak==2.0.0 # esp-idf >= 5.0 requires this pyparsing >= 3.0 diff --git a/requirements_test.txt b/requirements_test.txt index 81cb711eec..9d55d23272 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,14 +1,14 @@ -pylint==4.0.2 +pylint==4.0.4 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.14.4 # also change in .pre-commit-config.yaml when updating -pyupgrade==3.21.0 # also change in .pre-commit-config.yaml when updating +ruff==0.14.7 # also change in .pre-commit-config.yaml when updating +pyupgrade==3.21.2 # also change in .pre-commit-config.yaml when updating pre-commit # Unit tests -pytest==8.4.2 +pytest==9.0.1 pytest-cov==7.0.0 pytest-mock==3.15.1 -pytest-asyncio==1.2.0 +pytest-asyncio==1.3.0 pytest-xdist==3.8.0 asyncmock==0.4.2 hypothesis==6.92.1 diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 38d1f8c2b7..27a36f889f 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -86,6 +86,7 @@ ISOLATED_COMPONENTS = { "modbus_controller": "Defines multiple modbus buses for testing client/server functionality - conflicts with package modbus bus", "neopixelbus": "RMT type conflict with ESP32 Arduino/ESP-IDF headers (enum vs struct rmt_channel_t)", "packages": "cannot merge packages", + "tinyusb": "Conflicts with usb_host component - cannot be used together", } diff --git a/script/api_protobuf/api_protobuf.py b/script/api_protobuf/api_protobuf.py index 3b756095a1..3412fac5db 100755 --- a/script/api_protobuf/api_protobuf.py +++ b/script/api_protobuf/api_protobuf.py @@ -462,7 +462,7 @@ class Int64Type(TypeInfo): wire_type = WireType.VARINT # Uses wire type 0 def dump(self, name: str) -> str: - o = f'snprintf(buffer, sizeof(buffer), "%lld", {name});\n' + o = f'snprintf(buffer, sizeof(buffer), "%" PRId64, {name});\n' o += "out.append(buffer);" return o @@ -482,7 +482,7 @@ class UInt64Type(TypeInfo): wire_type = WireType.VARINT # Uses wire type 0 def dump(self, name: str) -> str: - o = f'snprintf(buffer, sizeof(buffer), "%llu", {name});\n' + o = f'snprintf(buffer, sizeof(buffer), "%" PRIu64, {name});\n' o += "out.append(buffer);" return o @@ -522,7 +522,7 @@ class Fixed64Type(TypeInfo): wire_type = WireType.FIXED64 # Uses wire type 1 def dump(self, name: str) -> str: - o = f'snprintf(buffer, sizeof(buffer), "%llu", {name});\n' + o = f'snprintf(buffer, sizeof(buffer), "%" PRIu64, {name});\n' o += "out.append(buffer);" return o @@ -1106,7 +1106,7 @@ class SFixed64Type(TypeInfo): wire_type = WireType.FIXED64 # Uses wire type 1 def dump(self, name: str) -> str: - o = f'snprintf(buffer, sizeof(buffer), "%lld", {name});\n' + o = f'snprintf(buffer, sizeof(buffer), "%" PRId64, {name});\n' o += "out.append(buffer);" return o @@ -1150,7 +1150,7 @@ class SInt64Type(TypeInfo): wire_type = WireType.VARINT # Uses wire type 0 def dump(self, name: str) -> str: - o = f'snprintf(buffer, sizeof(buffer), "%lld", {name});\n' + o = f'snprintf(buffer, sizeof(buffer), "%" PRId64, {name});\n' o += "out.append(buffer);" return o @@ -2546,7 +2546,7 @@ static void dump_field(std::string &out, const char *field_name, float value, in static void dump_field(std::string &out, const char *field_name, uint64_t value, int indent = 2) { char buffer[64]; append_field_prefix(out, field_name, indent); - snprintf(buffer, 64, "%llu", value); + snprintf(buffer, 64, "%" PRIu64, value); append_with_newline(out, buffer); } @@ -2769,8 +2769,8 @@ static const char *const TAG = "api.service"; cases = list(RECEIVE_CASES.items()) cases.sort() hpp += " protected:\n" - hpp += " void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;\n" - out = f"void {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {{\n" + hpp += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n" + out = f"void {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n" out += " switch (msg_type) {\n" for i, (case, ifdef, message_name) in cases: if ifdef is not None: @@ -2878,9 +2878,9 @@ static const char *const TAG = "api.service"; result += "#endif\n" return result - hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) override;\n" + hpp_protected += " void read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) override;\n" - cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, uint8_t *msg_data) {{\n" + cpp += f"\nvoid {class_name}::read_message(uint32_t msg_size, uint32_t msg_type, const uint8_t *msg_data) {{\n" cpp += " // Check authentication/connection requirements for messages\n" cpp += " switch (msg_type) {\n" diff --git a/script/helpers.py b/script/helpers.py index 1039ef39ac..06a50a3092 100644 --- a/script/helpers.py +++ b/script/helpers.py @@ -630,7 +630,12 @@ def get_all_dependencies(component_names: set[str]) -> set[str]: Returns: Set of all components including dependencies and auto-loaded components """ - from esphome.const import KEY_CORE + from esphome.const import ( + KEY_CORE, + KEY_TARGET_FRAMEWORK, + KEY_TARGET_PLATFORM, + PLATFORM_HOST, + ) from esphome.core import CORE from esphome.loader import get_component @@ -642,7 +647,10 @@ def get_all_dependencies(component_names: set[str]) -> set[str]: # Set up fake config path for component loading root = Path(__file__).parent.parent CORE.config_path = root - CORE.data[KEY_CORE] = {} + CORE.data[KEY_CORE] = { + KEY_TARGET_PLATFORM: PLATFORM_HOST, + KEY_TARGET_FRAMEWORK: "host-native", + } # Keep finding dependencies until no new ones are found while True: diff --git a/tests/component_tests/binary_sensor/test_binary_sensor.py b/tests/component_tests/binary_sensor/test_binary_sensor.py index 32d74027ba..86e0705023 100644 --- a/tests/component_tests/binary_sensor/test_binary_sensor.py +++ b/tests/component_tests/binary_sensor/test_binary_sensor.py @@ -29,7 +29,7 @@ def test_binary_sensor_sets_mandatory_fields(generate_main): ) # Then - assert 'bs_1->set_name("test bs1");' in main_cpp + assert 'bs_1->set_name_and_object_id("test bs1", "test_bs1");' in main_cpp assert "bs_1->set_pin(" in main_cpp diff --git a/tests/component_tests/button/test_button.py b/tests/component_tests/button/test_button.py index 512ef42b44..b21665288c 100644 --- a/tests/component_tests/button/test_button.py +++ b/tests/component_tests/button/test_button.py @@ -26,7 +26,7 @@ def test_button_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/button/test_button.yaml") # Then - assert 'wol_1->set_name("wol_test_1");' in main_cpp + assert 'wol_1->set_name_and_object_id("wol_test_1", "wol_test_1");' in main_cpp assert "wol_2->set_macaddr(18, 52, 86, 120, 144, 171);" in main_cpp diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index e68f6fbfba..56a52df2ab 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -220,7 +220,7 @@ def test_esp32s3_specific_errors( set_core_config( PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32S3}, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) with pytest.raises(cv.Invalid, match=error_match): @@ -250,7 +250,7 @@ def test_custom_model_with_all_options( """Test custom model configuration with all available options.""" set_core_config( PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32S3}, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) run_schema_validation( @@ -293,7 +293,7 @@ def test_all_predefined_models( """Test all predefined display models validate successfully with appropriate defaults.""" set_core_config( PlatformFramework.ESP32_IDF, - platform_data={KEY_BOARD: "esp32dev", KEY_VARIANT: VARIANT_ESP32S3}, + platform_data={KEY_BOARD: "esp32-s3-devkitc-1", KEY_VARIANT: VARIANT_ESP32S3}, ) # Enable PSRAM which is required for some models diff --git a/tests/component_tests/ota/test_web_server_ota.py b/tests/component_tests/ota/test_web_server_ota.py index 0d8ff6f134..794eaac9be 100644 --- a/tests/component_tests/ota/test_web_server_ota.py +++ b/tests/component_tests/ota/test_web_server_ota.py @@ -1,6 +1,18 @@ """Tests for the web_server OTA platform.""" +from __future__ import annotations + from collections.abc import Callable +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.web_server.ota import _web_server_ota_final_validate +from esphome.const import CONF_ID, CONF_OTA, CONF_PLATFORM, CONF_WEB_SERVER +from esphome.core import ID +import esphome.final_validate as fv def test_web_server_ota_generated(generate_main: Callable[[str], str]) -> None: @@ -100,3 +112,144 @@ def test_web_server_ota_esp8266(generate_main: Callable[[str], str]) -> None: # Check web server OTA component is present assert "WebServerOTAComponent" in main_cpp assert "web_server::WebServerOTAComponent" in main_cpp + + +@pytest.mark.parametrize( + ("ota_configs", "expected_count", "warning_expected"), + [ + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=False), + } + ], + 1, + False, + id="single_instance_no_merge", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=False), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=False), + }, + ], + 1, + True, + id="two_instances_merged", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=False), + }, + { + CONF_PLATFORM: "esphome", + CONF_ID: ID("ota_esphome", is_manual=False), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=False), + }, + ], + 2, + True, + id="mixed_platforms_web_server_merged", + ), + ], +) +def test_web_server_ota_instance_merging( + ota_configs: list[dict[str, Any]], + expected_count: int, + warning_expected: bool, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test web_server OTA instance merging behavior.""" + full_conf = {CONF_OTA: ota_configs.copy()} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _web_server_ota_final_validate({}) + + updated_conf = fv.full_config.get() + + # Verify total number of OTA platforms + assert len(updated_conf[CONF_OTA]) == expected_count + + # Verify warning + if warning_expected: + assert any( + "Found and merged" in record.message + and "web_server OTA" in record.message + for record in caplog.records + ), "Expected merge warning not found in log" + else: + assert len(caplog.records) == 0, "Unexpected warnings logged" + finally: + fv.full_config.reset(token) + + +def test_web_server_ota_consistent_manual_ids( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that consistent manual IDs can be merged successfully.""" + ota_configs = [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=True), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web", is_manual=True), + }, + ] + + full_conf = {CONF_OTA: ota_configs} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _web_server_ota_final_validate({}) + + updated_conf = fv.full_config.get() + assert len(updated_conf[CONF_OTA]) == 1 + assert updated_conf[CONF_OTA][0][CONF_ID].id == "ota_web" + assert any( + "Found and merged" in record.message and "web_server OTA" in record.message + for record in caplog.records + ) + finally: + fv.full_config.reset(token) + + +def test_web_server_ota_inconsistent_manual_ids() -> None: + """Test that inconsistent manual IDs raise an error.""" + ota_configs = [ + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_1", is_manual=True), + }, + { + CONF_PLATFORM: CONF_WEB_SERVER, + CONF_ID: ID("ota_web_2", is_manual=True), + }, + ] + + full_conf = {CONF_OTA: ota_configs} + + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match="Found multiple web_server OTA configurations but id is inconsistent", + ): + _web_server_ota_final_validate({}) + finally: + fv.full_config.reset(token) diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index 1c4c91aa52..ac4e211fe6 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -95,7 +95,7 @@ def test_package_invalid_dict(basic_esphome, basic_wifi): @pytest.mark.parametrize( - "package", + "packages", [ {"package1": "github://esphome/non-existant-repo/file1.yml@main"}, {"package2": "github://esphome/non-existant-repo/file1.yml"}, @@ -107,12 +107,12 @@ def test_package_invalid_dict(basic_esphome, basic_wifi): ], ], ) -def test_package_shorthand(package): - CONFIG_SCHEMA(package) +def test_package_shorthand(packages): + CONFIG_SCHEMA(packages) @pytest.mark.parametrize( - "package", + "packages", [ # not github {"package1": "someplace://esphome/non-existant-repo/file1.yml@main"}, @@ -133,9 +133,9 @@ def test_package_shorthand(package): [3], ], ) -def test_package_invalid(package): +def test_package_invalid(packages): with pytest.raises(cv.Invalid): - CONFIG_SCHEMA(package) + CONFIG_SCHEMA(packages) def test_package_include(basic_wifi, basic_esphome): @@ -155,6 +155,33 @@ def test_package_include(basic_wifi, basic_esphome): assert actual == expected +def test_single_package( + basic_esphome, + basic_wifi, + caplog: pytest.LogCaptureFixture, +): + """ + Tests the simple case where a single package is added to the top-level config as is. + In this test, the CONF_WIFI config is expected to be simply added to the top-level config. + This tests the case where the user just put packages: !include package.yaml, not + part of a list or mapping of packages. + This behavior is deprecated, the test also checks if a warning is issued. + """ + config = {CONF_ESPHOME: basic_esphome, CONF_PACKAGES: {CONF_WIFI: basic_wifi}} + + expected = {CONF_ESPHOME: basic_esphome, CONF_WIFI: basic_wifi} + + with caplog.at_level("WARNING"): + actual = packages_pass(config) + + assert actual == expected + + assert ( + "Including a single package under `packages:` is deprecated. Use a list instead." + in caplog.text + ) + + def test_package_append(basic_wifi, basic_esphome): """ Tests the case where a key is present in both a package and top-level config. diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index f8ad013689..86bc29cc84 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -23,22 +23,23 @@ from tests.component_tests.types import SetCoreConfigCallable UNSUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32C2, VARIANT_ESP32C3, - VARIANT_ESP32C5, VARIANT_ESP32C6, VARIANT_ESP32H2, ] SUPPORTED_PSRAM_VARIANTS = [ VARIANT_ESP32, + VARIANT_ESP32C5, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, - VARIANT_ESP32P4, ] SUPPORTED_PSRAM_MODES = { VARIANT_ESP32: ["quad"], + VARIANT_ESP32C5: ["quad"], + VARIANT_ESP32P4: ["hex"], VARIANT_ESP32S2: ["quad"], VARIANT_ESP32S3: ["quad", "octal"], - VARIANT_ESP32P4: ["hex"], } diff --git a/tests/component_tests/sntp/__init__.py b/tests/component_tests/sntp/__init__.py new file mode 100644 index 0000000000..7d323a4980 --- /dev/null +++ b/tests/component_tests/sntp/__init__.py @@ -0,0 +1 @@ +"""Tests for SNTP component.""" diff --git a/tests/component_tests/sntp/config/sntp_test.yaml b/tests/component_tests/sntp/config/sntp_test.yaml new file mode 100644 index 0000000000..3942c9606b --- /dev/null +++ b/tests/component_tests/sntp/config/sntp_test.yaml @@ -0,0 +1,22 @@ +esphome: + name: sntp-test + +esp32: + board: esp32dev + framework: + type: esp-idf + +wifi: + ssid: "testssid" + password: "testpassword" + +# Test multiple SNTP instances that should be merged +time: + - platform: sntp + servers: + - 192.168.1.1 + - pool.ntp.org + - platform: sntp + servers: + - pool.ntp.org + - 192.168.1.2 diff --git a/tests/component_tests/sntp/test_init.py b/tests/component_tests/sntp/test_init.py new file mode 100644 index 0000000000..9197ff55d0 --- /dev/null +++ b/tests/component_tests/sntp/test_init.py @@ -0,0 +1,238 @@ +"""Tests for SNTP time configuration validation.""" + +from __future__ import annotations + +import logging +from typing import Any + +import pytest + +from esphome import config_validation as cv +from esphome.components.sntp.time import CONF_SNTP, _sntp_final_validate +from esphome.const import CONF_ID, CONF_PLATFORM, CONF_SERVERS, CONF_TIME +from esphome.core import ID +import esphome.final_validate as fv + + +@pytest.mark.parametrize( + ("time_configs", "expected_count", "expected_servers", "warning_messages"), + [ + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + } + ], + 1, + ["192.168.1.1", "pool.ntp.org"], + [], + id="single_instance_no_merge", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="two_instances_merged", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="deduplication_preserves_order", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1", "pool.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2", "pool2.ntp.org"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_3", is_manual=False), + CONF_SERVERS: ["pool3.ntp.org"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + [ + "SNTP supports maximum 3 servers. Dropped excess server(s): ['pool2.ntp.org', 'pool3.ntp.org']", + "Found and merged 3 SNTP time configurations into one instance", + ], + id="three_instances_drops_excess_servers", + ), + pytest.param( + [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: [ + "192.168.1.1", + "pool.ntp.org", + "pool.ntp.org", + "192.168.1.1", + ], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["pool.ntp.org", "192.168.1.2"], + }, + ], + 1, + ["192.168.1.1", "pool.ntp.org", "192.168.1.2"], + ["Found and merged 2 SNTP time configurations into one instance"], + id="deduplication_multiple_duplicates", + ), + ], +) +def test_sntp_instance_merging( + time_configs: list[dict[str, Any]], + expected_count: int, + expected_servers: list[str], + warning_messages: list[str], + caplog: pytest.LogCaptureFixture, +) -> None: + """Test SNTP instance merging behavior.""" + # Create a mock full config with time configs + full_conf = {CONF_TIME: time_configs.copy()} + + # Set the context var + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _sntp_final_validate({}) + + # Get the updated config + updated_conf = fv.full_config.get() + + # Check if merging occurred + if len(time_configs) > 1: + # Verify only one SNTP instance remains + sntp_instances = [ + tc + for tc in updated_conf[CONF_TIME] + if tc.get(CONF_PLATFORM) == CONF_SNTP + ] + assert len(sntp_instances) == expected_count + + # Verify server list + assert sntp_instances[0][CONF_SERVERS] == expected_servers + + # Verify warnings + for expected_msg in warning_messages: + assert any( + expected_msg in record.message for record in caplog.records + ), f"Expected warning message '{expected_msg}' not found in log" + else: + # Single instance should not trigger merging or warnings + assert len(caplog.records) == 0 + # Config should be unchanged + assert updated_conf[CONF_TIME] == time_configs + finally: + fv.full_config.reset(token) + + +def test_sntp_inconsistent_manual_ids() -> None: + """Test that inconsistent manual IDs raise an error.""" + # Create configs with manual IDs that are inconsistent + time_configs = [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=True), + CONF_SERVERS: ["192.168.1.1"], + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=True), + CONF_SERVERS: ["192.168.1.2"], + }, + ] + + full_conf = {CONF_TIME: time_configs} + + token = fv.full_config.set(full_conf) + try: + with pytest.raises( + cv.Invalid, + match="Found multiple SNTP configurations but id is inconsistent", + ): + _sntp_final_validate({}) + finally: + fv.full_config.reset(token) + + +def test_sntp_with_other_time_platforms(caplog: pytest.LogCaptureFixture) -> None: + """Test that SNTP merging doesn't affect other time platforms.""" + time_configs = [ + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_1", is_manual=False), + CONF_SERVERS: ["192.168.1.1"], + }, + { + CONF_PLATFORM: "homeassistant", + CONF_ID: ID("homeassistant_time", is_manual=False), + }, + { + CONF_PLATFORM: CONF_SNTP, + CONF_ID: ID("sntp_time_2", is_manual=False), + CONF_SERVERS: ["192.168.1.2"], + }, + ] + + full_conf = {CONF_TIME: time_configs.copy()} + + token = fv.full_config.set(full_conf) + try: + with caplog.at_level(logging.WARNING): + _sntp_final_validate({}) + + updated_conf = fv.full_config.get() + + # Should have 2 time platforms: 1 merged SNTP + 1 homeassistant + assert len(updated_conf[CONF_TIME]) == 2 + + # Find the platforms + platforms = {tc[CONF_PLATFORM] for tc in updated_conf[CONF_TIME]} + assert platforms == {CONF_SNTP, "homeassistant"} + + # Verify SNTP was merged + sntp_instances = [ + tc for tc in updated_conf[CONF_TIME] if tc[CONF_PLATFORM] == CONF_SNTP + ] + assert len(sntp_instances) == 1 + assert sntp_instances[0][CONF_SERVERS] == ["192.168.1.1", "192.168.1.2"] + finally: + fv.full_config.reset(token) diff --git a/tests/component_tests/text/test_text.py b/tests/component_tests/text/test_text.py index 99ddd78ee7..bfc3131f6d 100644 --- a/tests/component_tests/text/test_text.py +++ b/tests/component_tests/text/test_text.py @@ -25,7 +25,7 @@ def test_text_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text/test_text.yaml") # Then - assert 'it_1->set_name("test 1 text");' in main_cpp + assert 'it_1->set_name_and_object_id("test 1 text", "test_1_text");' in main_cpp def test_text_config_value_internal_set(generate_main): diff --git a/tests/component_tests/text_sensor/test_text_sensor.py b/tests/component_tests/text_sensor/test_text_sensor.py index 1c4ef6633d..934ee67cef 100644 --- a/tests/component_tests/text_sensor/test_text_sensor.py +++ b/tests/component_tests/text_sensor/test_text_sensor.py @@ -25,9 +25,18 @@ def test_text_sensor_sets_mandatory_fields(generate_main): main_cpp = generate_main("tests/component_tests/text_sensor/test_text_sensor.yaml") # Then - assert 'ts_1->set_name("Template Text Sensor 1");' in main_cpp - assert 'ts_2->set_name("Template Text Sensor 2");' in main_cpp - assert 'ts_3->set_name("Template Text Sensor 3");' in main_cpp + assert ( + 'ts_1->set_name_and_object_id("Template Text Sensor 1", "template_text_sensor_1");' + in main_cpp + ) + assert ( + 'ts_2->set_name_and_object_id("Template Text Sensor 2", "template_text_sensor_2");' + in main_cpp + ) + assert ( + 'ts_3->set_name_and_object_id("Template Text Sensor 3", "template_text_sensor_3");' + in main_cpp + ) def test_text_sensor_config_value_internal_set(generate_main): diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index fc53b8ac7e..0416cebf9b 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -1,6 +1,10 @@ esphome: on_boot: then: + - wait_until: + condition: + api.connected: + state_subscription_only: true - homeassistant.event: event: esphome.button_pressed data: diff --git a/tests/components/bm8563/common.yaml b/tests/components/bm8563/common.yaml new file mode 100644 index 0000000000..ec3fdd1518 --- /dev/null +++ b/tests/components/bm8563/common.yaml @@ -0,0 +1,10 @@ +esphome: + on_boot: + - bm8563.read_time + - bm8563.write_time + - bm8563.start_timer: + duration: 300s + +time: + - platform: bm8563 + i2c_id: i2c_bus diff --git a/tests/components/bm8563/test.esp32-ard.yaml b/tests/components/bm8563/test.esp32-ard.yaml new file mode 100644 index 0000000000..7c503b0ccb --- /dev/null +++ b/tests/components/bm8563/test.esp32-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/bm8563/test.esp32-idf.yaml b/tests/components/bm8563/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/bm8563/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/bm8563/test.esp8266-ard.yaml b/tests/components/bm8563/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/bm8563/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/bm8563/test.rp2040-ard.yaml b/tests/components/bm8563/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/bm8563/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml b/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml deleted file mode 100644 index 0fd8684a2c..0000000000 --- a/tests/components/bme68x_bsec2_i2c/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/chsc6x/test.rp2040-ard.yaml b/tests/components/chsc6x/test.rp2040-ard.yaml index 89cc1b7477..2e3613a4a3 100644 --- a/tests/components/chsc6x/test.rp2040-ard.yaml +++ b/tests/components/chsc6x/test.rp2040-ard.yaml @@ -16,5 +16,6 @@ display: touchscreen: - platform: chsc6x + i2c_id: i2c_bus display: ili9xxx_display interrupt_pin: 22 diff --git a/tests/components/climate_ir_lg/common.yaml b/tests/components/climate_ir_lg/common.yaml index da0d656b21..37011b16ee 100644 --- a/tests/components/climate_ir_lg/common.yaml +++ b/tests/components/climate_ir_lg/common.yaml @@ -1,4 +1,16 @@ +sensor: + - platform: template + id: temp_sensor + lambda: return 22.0; + update_interval: 60s + - platform: template + id: humidity_sensor + lambda: return 50.0; + update_interval: 60s + climate: - platform: climate_ir_lg name: LG Climate transmitter_id: xmitr + sensor: temp_sensor + humidity_sensor: humidity_sensor diff --git a/tests/components/debug/test.esp32-s3-idf.yaml b/tests/components/debug/test.nrf52-xiao-ble.yaml similarity index 100% rename from tests/components/debug/test.esp32-s3-idf.yaml rename to tests/components/debug/test.nrf52-xiao-ble.yaml diff --git a/tests/components/ds1307/test.nrf52-adafruit.yaml b/tests/components/ds1307/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..2a0de6241c --- /dev/null +++ b/tests/components/ds1307/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + +<<: !include common.yaml diff --git a/tests/components/epaper_spi/test.esp32-s3-idf.yaml b/tests/components/epaper_spi/test.esp32-s3-idf.yaml index cff1f51897..d330b4127d 100644 --- a/tests/components/epaper_spi/test.esp32-s3-idf.yaml +++ b/tests/components/epaper_spi/test.esp32-s3-idf.yaml @@ -19,3 +19,8 @@ display: - platform: epaper_spi model: seeed-reterminal-e1002 + - platform: epaper_spi + model: seeed-ee04-mono-4.26 + # Override pins to avoid conflict with other display configs + busy_pin: 43 + dc_pin: 42 diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..1c243ef459 --- /dev/null +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -0,0 +1,31 @@ +esp32: + variant: esp32p4 + flash_size: 32MB + cpu_frequency: 400MHz + framework: + type: esp-idf + components: + - espressif/mdns^1.8.2 + - name: espressif/esp_hosted + ref: 2.6.6 + advanced: + enable_idf_experimental_features: yes + +ota: + platform: esphome + +wifi: + ssid: MySSID + password: password1 + +esp32_hosted: + variant: ESP32C6 + slot: 1 + active_high: true + reset_pin: GPIO15 + cmd_pin: GPIO13 + clk_pin: GPIO12 + d0_pin: GPIO11 + d1_pin: GPIO10 + d2_pin: GPIO9 + d3_pin: GPIO8 diff --git a/tests/components/esphome/common.yaml b/tests/components/esphome/common.yaml index b2d7bccaa5..db75b08b38 100644 --- a/tests/components/esphome/common.yaml +++ b/tests/components/esphome/common.yaml @@ -2,6 +2,9 @@ esphome: debug_scheduler: true platformio_options: board_build.flash_mode: dio + environment_variables: + TEST_ENV_VAR: "test_value" + BUILD_NUMBER: "12345" area: id: testing_area name: Testing Area diff --git a/tests/components/hc8/common.yaml b/tests/components/hc8/common.yaml new file mode 100644 index 0000000000..ac3b454315 --- /dev/null +++ b/tests/components/hc8/common.yaml @@ -0,0 +1,13 @@ +esphome: + on_boot: + then: + - hc8.calibrate: + id: hc8_sensor + baseline: 420 + +sensor: + - platform: hc8 + id: hc8_sensor + co2: + name: HC8 CO2 Value + update_interval: 15s diff --git a/tests/components/hc8/test.esp32-idf.yaml b/tests/components/hc8/test.esp32-idf.yaml new file mode 100644 index 0000000000..2d29656c94 --- /dev/null +++ b/tests/components/hc8/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/hc8/test.esp8266-ard.yaml b/tests/components/hc8/test.esp8266-ard.yaml new file mode 100644 index 0000000000..5a05efa259 --- /dev/null +++ b/tests/components/hc8/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hc8/test.rp2040-ard.yaml b/tests/components/hc8/test.rp2040-ard.yaml new file mode 100644 index 0000000000..f1df2daf83 --- /dev/null +++ b/tests/components/hc8/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hlk_fm22x/common.yaml b/tests/components/hlk_fm22x/common.yaml new file mode 100644 index 0000000000..6fcd9af594 --- /dev/null +++ b/tests/components/hlk_fm22x/common.yaml @@ -0,0 +1,41 @@ +esphome: + on_boot: + then: + - hlk_fm22x.enroll: + name: "Test" + direction: 1 + - hlk_fm22x.delete_all: + +hlk_fm22x: + on_face_scan_matched: + - logger.log: test_hlk_22x_face_scan_matched + on_face_scan_unmatched: + - logger.log: test_hlk_22x_face_scan_unmatched + on_face_scan_invalid: + - logger.log: test_hlk_22x_face_scan_invalid + on_face_info: + - logger.log: test_hlk_22x_face_info + on_enrollment_done: + - logger.log: test_hlk_22x_enrollment_done + on_enrollment_failed: + - logger.log: test_hlk_22x_enrollment_failed + +sensor: + - platform: hlk_fm22x + face_count: + name: "Face Count" + last_face_id: + name: "Last Face ID" + status: + name: "Face Status" + +binary_sensor: + - platform: hlk_fm22x + name: "Face Enrolling" + +text_sensor: + - platform: hlk_fm22x + version: + name: "HLK Version" + last_face_name: + name: "Last Face Name" diff --git a/tests/components/hlk_fm22x/test.esp32-idf.yaml b/tests/components/hlk_fm22x/test.esp32-idf.yaml new file mode 100644 index 0000000000..2d29656c94 --- /dev/null +++ b/tests/components/hlk_fm22x/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/hlk_fm22x/test.esp8266-ard.yaml b/tests/components/hlk_fm22x/test.esp8266-ard.yaml new file mode 100644 index 0000000000..5a05efa259 --- /dev/null +++ b/tests/components/hlk_fm22x/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hlk_fm22x/test.rp2040-ard.yaml b/tests/components/hlk_fm22x/test.rp2040-ard.yaml new file mode 100644 index 0000000000..f1df2daf83 --- /dev/null +++ b/tests/components/hlk_fm22x/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/lvgl/common.yaml b/tests/components/lvgl/common.yaml index c70dd7568d..652ae7e7a1 100644 --- a/tests/components/lvgl/common.yaml +++ b/tests/components/lvgl/common.yaml @@ -115,8 +115,8 @@ wifi: password: PASSWORD123 time: - platform: sntp - id: time_id + - platform: sntp + id: sntp_time text: - id: lvgl_text diff --git a/tests/components/lvgl/lvgl-package.yaml b/tests/components/lvgl/lvgl-package.yaml index b122d10f04..65d629bcdf 100644 --- a/tests/components/lvgl/lvgl-package.yaml +++ b/tests/components/lvgl/lvgl-package.yaml @@ -16,6 +16,18 @@ binary_sensor: platform: template - id: left_sensor platform: template + - platform: lvgl + id: button_checker + name: LVGL button + widget: button_button + on_state: + then: + - lvgl.checkbox.update: + id: checkbox_id + state: + checked: !lambda |- + auto y = x; // block inlining of one line return + return y; lvgl: log_level: debug @@ -76,7 +88,7 @@ lvgl: line_width: 8 line_rounded: true - id: date_style - text_font: roboto10 + text_font: !lambda return id(roboto10); align: center text_color: !lambda return color_id2; bg_opa: cover @@ -267,7 +279,7 @@ lvgl: snprintf(buf, sizeof(buf), "Setup: %d", 42); return std::string(buf); align: top_mid - text_font: space16 + text_font: !lambda return id(space16); - label: id: chip_info_label # Test complex setup lambda (real-world pattern) @@ -414,6 +426,14 @@ lvgl: logger.log: Long pressed repeated - buttons: - id: button_e + - button: + id: button_with_text + text: Button + on_click: + lvgl.button.update: + id: button_with_text + text: Clicked + - button: layout: 2x1 id: button_button @@ -478,19 +498,19 @@ lvgl: id: hello_label text: time_format: "%c" - time: time_id + time: sntp_time - lvgl.label.update: id: hello_label text: time_format: "%c" - time: !lambda return id(time_id).now(); + time: !lambda return id(sntp_time).now(); - lvgl.label.update: id: hello_label text: time_format: "%c" time: !lambda |- ESP_LOGD("label", "multi-line lambda"); - return id(time_id).now(); + return id(sntp_time).now(); on_value: logger.log: format: "state now %d" @@ -537,6 +557,9 @@ lvgl: - tileview: id: tileview_id scrollbar_mode: active + scroll_dir: all + scroll_elastic: true + scroll_momentum: true on_value: then: - if: @@ -546,7 +569,10 @@ lvgl: - logger.log: "tile 1 is now showing" tiles: - id: tile_1 + scroll_snap_y: center + scroll_snap_x: start layout: vertical + pad_all: 6px row: 0 column: 0 dir: ALL @@ -700,6 +726,12 @@ lvgl: width: 100% height: 10% align: top_mid + on_value: + - lvgl.spinbox.update: + id: spinbox_id + value: !lambda |- + static float yyy = 83.0; + return yyy + .8; - button: styles: spin_button id: spin_up @@ -775,6 +807,18 @@ lvgl: arc_color: 0xFFFF00 focused: arc_color: 0x808080 + on_click: + then: + - lvgl.arc.update: + id: lv_arc_1 + value: !lambda return (int)((float)rand() / RAND_MAX * 100); + min_value: !lambda return (int)((float)rand() / RAND_MAX * 100); + max_value: !lambda return (int)((float)rand() / RAND_MAX * 100); + start_angle: !lambda return (int)((float)rand() / RAND_MAX * 100); + end_angle: !lambda return (int)((float)rand() / RAND_MAX * 100); + rotation: !lambda return (int)((float)rand() / RAND_MAX * 100); + change_rate: !lambda return (uint)((float)rand() / RAND_MAX * 100); + mode: NORMAL - bar: id: bar_id align: top_mid @@ -875,6 +919,7 @@ lvgl: grid_columns: [40, fr(1), fr(1)] pad_row: 6px pad_column: 0 + multiple_widgets_per_cell: true widgets: - image: grid_cell_row_pos: 0 @@ -899,6 +944,10 @@ lvgl: grid_cell_row_pos: 1 grid_cell_column_pos: 0 text: "Grid cell 1/0" + - label: + grid_cell_row_pos: 1 + grid_cell_column_pos: 0 + text: "Duplicate for 1/0" - label: styles: bdr_style grid_cell_row_pos: 1 @@ -1021,6 +1070,7 @@ lvgl: opa: 0% - id: page3 layout: Horizontal + pad_all: 6px widgets: - keyboard: id: lv_keyboard diff --git a/tests/components/lvgl/test.esp32-idf.yaml b/tests/components/lvgl/test.esp32-idf.yaml index 2450d28eb8..e6025e17fc 100644 --- a/tests/components/lvgl/test.esp32-idf.yaml +++ b/tests/components/lvgl/test.esp32-idf.yaml @@ -60,6 +60,7 @@ display: update_interval: never lvgl: + update_when_display_idle: true displays: - tft_display - second_display diff --git a/tests/components/lvgl/test.host.yaml b/tests/components/lvgl/test.host.yaml index 39d9a0ebf3..00a8cd8c01 100644 --- a/tests/components/lvgl/test.host.yaml +++ b/tests/components/lvgl/test.host.yaml @@ -18,6 +18,7 @@ touchscreen: lvgl: - id: lvgl_0 + default_font: space16 displays: sdl0 - id: lvgl_1 displays: sdl1 @@ -39,3 +40,8 @@ lvgl: text: Click ME on_click: logger.log: Clicked + +font: + - file: "gfonts://Roboto" + id: space16 + bpp: 4 diff --git a/tests/components/matrix_keypad/test.esp32-s3-idf.yaml b/tests/components/matrix_keypad/test.esp32-s3-idf.yaml deleted file mode 100644 index a491f2ed59..0000000000 --- a/tests/components/matrix_keypad/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,15 +0,0 @@ -packages: - common: !include common.yaml - -matrix_keypad: - id: keypad - rows: - - pin: 10 - - pin: 11 - columns: - - pin: 12 - - pin: 13 - keys: "1234" - has_pulldowns: true - on_key: - - lambda: ESP_LOGI("KEY", "key %d pressed", x); diff --git a/tests/components/mcp3204/common.yaml b/tests/components/mcp3204/common.yaml index eca6ec44f4..9750f0af8e 100644 --- a/tests/components/mcp3204/common.yaml +++ b/tests/components/mcp3204/common.yaml @@ -4,7 +4,21 @@ mcp3204: sensor: - platform: mcp3204 - id: mcp3204_sensor + id: mcp3204_default_single_0 mcp3204_id: mcp3204_hub number: 0 update_interval: 5s + + - platform: mcp3204 + id: mcp3204_single_0 + mcp3204_id: mcp3204_hub + number: 0 + diff_mode: false + update_interval: 5s + + - platform: mcp3204 + id: mcp3204_diff_0_1 + mcp3204_id: mcp3204_hub + number: 0 + diff_mode: true + update_interval: 5s diff --git a/tests/components/mcp3221/test.esp32-s3-idf.yaml b/tests/components/mcp3221/test.esp32-s3-idf.yaml deleted file mode 100644 index 0fd8684a2c..0000000000 --- a/tests/components/mcp3221/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/mlx90393/test.esp32-s3-idf.yaml b/tests/components/mlx90393/test.esp32-s3-idf.yaml deleted file mode 100644 index 0fd8684a2c..0000000000 --- a/tests/components/mlx90393/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/mqtt/common.yaml b/tests/components/mqtt/common.yaml index 3f1b83bb01..284ac30337 100644 --- a/tests/components/mqtt/common.yaml +++ b/tests/components/mqtt/common.yaml @@ -4,6 +4,7 @@ wifi: time: - platform: sntp + id: sntp_time mqtt: broker: "192.168.178.84" diff --git a/tests/components/binary_sensor/test.esp32-s3-idf.yaml b/tests/components/mqtt/test.rtl87xx-ard.yaml similarity index 100% rename from tests/components/binary_sensor/test.esp32-s3-idf.yaml rename to tests/components/mqtt/test.rtl87xx-ard.yaml diff --git a/tests/components/network/test.esp32-idf.yaml b/tests/components/network/test.esp32-idf.yaml index dade44d145..7c01bafa0d 100644 --- a/tests/components/network/test.esp32-idf.yaml +++ b/tests/components/network/test.esp32-idf.yaml @@ -1 +1,4 @@ <<: !include common.yaml + +network: + enable_high_performance: true diff --git a/tests/components/npi19/test.esp32-s3-idf.yaml b/tests/components/npi19/test.esp32-s3-idf.yaml deleted file mode 100644 index 0fd8684a2c..0000000000 --- a/tests/components/npi19/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/nrf52/test.nrf52-adafruit.yaml b/tests/components/nrf52/test.nrf52-adafruit.yaml index cf704ecceb..5fa0d6e88f 100644 --- a/tests/components/nrf52/test.nrf52-adafruit.yaml +++ b/tests/components/nrf52/test.nrf52-adafruit.yaml @@ -15,3 +15,7 @@ nrf52: inverted: true mode: output: true + dcdc: False + reg0: + voltage: 2.1V + uicr_erase: true diff --git a/tests/components/nrf52/test.nrf52-mcumgr.yaml b/tests/components/nrf52/test.nrf52-mcumgr.yaml index e69de29bb2..89ec637db6 100644 --- a/tests/components/nrf52/test.nrf52-mcumgr.yaml +++ b/tests/components/nrf52/test.nrf52-mcumgr.yaml @@ -0,0 +1,4 @@ +nrf52: + reg0: + voltage: 3.3V + uicr_erase: true diff --git a/tests/components/nrf52/test.nrf52-xiao-ble.yaml b/tests/components/nrf52/test.nrf52-xiao-ble.yaml index 3fe80209b6..d53c692001 100644 --- a/tests/components/nrf52/test.nrf52-xiao-ble.yaml +++ b/tests/components/nrf52/test.nrf52-xiao-ble.yaml @@ -5,3 +5,5 @@ nrf52: inverted: true mode: output: true + reg0: + voltage: 1.8V diff --git a/tests/components/ntc/test.esp32-s3-idf.yaml b/tests/components/ntc/test.esp32-s3-idf.yaml deleted file mode 100644 index 37fb325f4a..0000000000 --- a/tests/components/ntc/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -substitutions: - pin: GPIO4 - -<<: !include common.yaml diff --git a/tests/components/pcf85063/test.nrf52-adafruit.yaml b/tests/components/pcf85063/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..2a0de6241c --- /dev/null +++ b/tests/components/pcf85063/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + +<<: !include common.yaml diff --git a/tests/components/pcf8563/test.nrf52-adafruit.yaml b/tests/components/pcf8563/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..2a0de6241c --- /dev/null +++ b/tests/components/pcf8563/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + +<<: !include common.yaml diff --git a/tests/components/prometheus/common.yaml b/tests/components/prometheus/common.yaml index cf46e882a7..7ff416dccb 100644 --- a/tests/components/prometheus/common.yaml +++ b/tests/components/prometheus/common.yaml @@ -39,6 +39,15 @@ sensor: return 0.0; update_interval: 60s +text: + - platform: template + name: "Template text" + optimistic: true + min_length: 0 + max_length: 100 + mode: text + initial_value: "Hello World" + text_sensor: - platform: version name: "ESPHome Version" @@ -52,6 +61,25 @@ text_sensor: return {"Goodbye (cruel) World"}; update_interval: 60s +event: + - platform: template + name: "Template Event" + id: template_event1 + event_types: + - "custom_event_1" + - "custom_event_2" + +button: + - platform: template + name: "Template Event Button" + on_press: + - logger.log: "Template Event Button pressed" + - lambda: |- + ESP_LOGD("template_event_button", "Template Event Button pressed"); + - event.trigger: + id: template_event1 + event_type: custom_event_1 + binary_sensor: - platform: template id: template_binary_sensor1 @@ -84,6 +112,25 @@ cover: } return COVER_CLOSED; +light: + - platform: binary + name: "Binary Light" + output: test_output + - platform: monochromatic + name: "Brightness Light" + output: test_output + - platform: rgb + name: "RGB Light" + red: test_output + green: test_output + blue: test_output + - platform: rgbw + name: "RGBW Light" + red: test_output + green: test_output + blue: test_output + white: test_output + lock: - platform: template id: template_lock1 @@ -94,6 +141,14 @@ lock: return LOCK_STATE_UNLOCKED; optimistic: true +output: + - platform: template + id: test_output + type: float + write_action: + - lambda: |- + // no-op for CI/build tests + (void)state; select: - platform: template id: template_select1 diff --git a/tests/components/psram/test.esp32-c5-idf.yaml b/tests/components/psram/test.esp32-c5-idf.yaml new file mode 100644 index 0000000000..fbd0132e2d --- /dev/null +++ b/tests/components/psram/test.esp32-c5-idf.yaml @@ -0,0 +1,8 @@ +esp32: + cpu_frequency: 240MHz + framework: + type: esp-idf + +psram: + speed: 120MHz + ignore_not_found: false diff --git a/tests/components/remote_receiver/test.rp2040-ard.yaml b/tests/components/remote_receiver/test.rp2040-ard.yaml new file mode 100644 index 0000000000..c9784ae003 --- /dev/null +++ b/tests/components/remote_receiver/test.rp2040-ard.yaml @@ -0,0 +1,12 @@ +remote_receiver: + id: rcvr + pin: GPIO5 + dump: all + <<: !include common-actions.yaml + +binary_sensor: + - platform: remote_receiver + name: Panasonic Remote Input + panasonic: + address: 0x4004 + command: 0x100BCBD diff --git a/tests/components/remote_transmitter/test.rp2040-ard.yaml b/tests/components/remote_transmitter/test.rp2040-ard.yaml new file mode 100644 index 0000000000..19759360f4 --- /dev/null +++ b/tests/components/remote_transmitter/test.rp2040-ard.yaml @@ -0,0 +1,7 @@ +remote_transmitter: + id: xmitr + pin: GPIO5 + carrier_duty_percent: 50% + +packages: + buttons: !include common-buttons.yaml diff --git a/tests/components/resistance/test.esp32-s3-idf.yaml b/tests/components/resistance/test.esp32-s3-idf.yaml deleted file mode 100644 index 1910f325ae..0000000000 --- a/tests/components/resistance/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,4 +0,0 @@ -substitutions: - pin: GPIO1 - -<<: !include common.yaml diff --git a/tests/components/rx8130/common.yaml b/tests/components/rx8130/common.yaml new file mode 100644 index 0000000000..e6b849e25b --- /dev/null +++ b/tests/components/rx8130/common.yaml @@ -0,0 +1,8 @@ +esphome: + on_boot: + - rx8130.read_time + - rx8130.write_time + +time: + - platform: rx8130 + i2c_id: i2c_bus diff --git a/tests/components/rx8130/test.esp32-idf.yaml b/tests/components/rx8130/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/rx8130/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8130/test.esp8266-ard.yaml b/tests/components/rx8130/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/rx8130/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8130/test.nrf52-adafruit.yaml b/tests/components/rx8130/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..2a0de6241c --- /dev/null +++ b/tests/components/rx8130/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + +<<: !include common.yaml diff --git a/tests/components/rx8130/test.rp2040-ard.yaml b/tests/components/rx8130/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/rx8130/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/sensor/common.yaml b/tests/components/sensor/common.yaml index 2180f66da8..1961c98685 100644 --- a/tests/components/sensor/common.yaml +++ b/tests/components/sensor/common.yaml @@ -236,3 +236,10 @@ sensor: - multiply: 2.0 - offset: 10.0 - lambda: return x * 3.0; + + # Testing measurement_angle state class + - platform: template + name: "Angle Sensor" + lambda: return 42.0; + update_interval: 1s + state_class: "measurement_angle" diff --git a/tests/components/speaker/common.yaml b/tests/components/speaker/common.yaml index fa54fa7e39..9aaf639162 100644 --- a/tests/components/speaker/common.yaml +++ b/tests/components/speaker/common.yaml @@ -11,26 +11,42 @@ esphome: on_boot: then: - speaker.mute_on: + id: speaker_id - speaker.mute_off: + id: speaker_id - if: - condition: speaker.is_stopped + condition: + speaker.is_stopped: + id: speaker_id then: - - speaker.play: [0, 1, 2, 3] - - speaker.volume_set: 0.9 + - speaker.play: + id: speaker_id + data: [0, 1, 2, 3] + - speaker.volume_set: + id: speaker_id + volume: 0.9 - if: - condition: speaker.is_playing + condition: + speaker.is_playing: + id: speaker_id then: - speaker.finish: + id: speaker_id - speaker.stop: + id: speaker_id button: - platform: template name: "Speaker Button" on_press: then: - - speaker.play: [0x10, 0x20, 0x30, 0x40] - - speaker.play: !lambda |- - return {0x01, 0x02, (uint8_t)id(my_number).state}; + - speaker.play: + id: speaker_id + data: [0x10, 0x20, 0x30, 0x40] + - speaker.play: + id: speaker_id + data: !lambda |- + return {0x01, 0x02, (uint8_t)id(my_number).state}; i2s_audio: i2s_lrclk_pin: ${i2s_bclk_pin} diff --git a/tests/components/ssd1306_i2c/test.nrf52-xiao-ble.yaml b/tests/components/ssd1306_i2c/test.nrf52-xiao-ble.yaml new file mode 100644 index 0000000000..28254e4af5 --- /dev/null +++ b/tests/components/ssd1306_i2c/test.nrf52-xiao-ble.yaml @@ -0,0 +1,7 @@ +substitutions: + reset_pin: P0.10 + +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + +<<: !include common.yaml diff --git a/tests/components/stts22h/common.yaml b/tests/components/stts22h/common.yaml new file mode 100644 index 0000000000..2e332f9276 --- /dev/null +++ b/tests/components/stts22h/common.yaml @@ -0,0 +1,5 @@ +sensor: + - platform: stts22h + i2c_id: i2c_bus + name: Temperature + update_interval: 15s diff --git a/tests/components/stts22h/test.esp32-idf.yaml b/tests/components/stts22h/test.esp32-idf.yaml new file mode 100644 index 0000000000..b47e39c389 --- /dev/null +++ b/tests/components/stts22h/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/stts22h/test.esp8266-ard.yaml b/tests/components/stts22h/test.esp8266-ard.yaml new file mode 100644 index 0000000000..4a98b9388a --- /dev/null +++ b/tests/components/stts22h/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/stts22h/test.nrf52-adafruit.yaml b/tests/components/stts22h/test.nrf52-adafruit.yaml new file mode 100644 index 0000000000..2a0de6241c --- /dev/null +++ b/tests/components/stts22h/test.nrf52-adafruit.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/nrf52.yaml + +<<: !include common.yaml diff --git a/tests/components/stts22h/test.rp2040-ard.yaml b/tests/components/stts22h/test.rp2040-ard.yaml new file mode 100644 index 0000000000..319a7c71a6 --- /dev/null +++ b/tests/components/stts22h/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/switch/test.esp32-s3-idf.yaml b/tests/components/switch/test.esp32-s3-idf.yaml deleted file mode 100644 index 25cb37a0b4..0000000000 --- a/tests/components/switch/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - common: !include common.yaml diff --git a/tests/components/tem3200/test.esp32-s3-idf.yaml b/tests/components/tem3200/test.esp32-s3-idf.yaml deleted file mode 100644 index e9d826aa7c..0000000000 --- a/tests/components/tem3200/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,8 +0,0 @@ -substitutions: - scl_pin: GPIO40 - sda_pin: GPIO41 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/template/test.esp32-s3-idf.yaml b/tests/components/template/test.esp32-s3-idf.yaml deleted file mode 100644 index 25cb37a0b4..0000000000 --- a/tests/components/template/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,2 +0,0 @@ -packages: - common: !include common.yaml diff --git a/tests/components/thermopro_ble/common.yaml b/tests/components/thermopro_ble/common.yaml new file mode 100644 index 0000000000..297725e1c3 --- /dev/null +++ b/tests/components/thermopro_ble/common.yaml @@ -0,0 +1,13 @@ +esp32_ble_tracker: + +sensor: + - platform: thermopro_ble + mac_address: FE:74:B8:6A:97:B7 + temperature: + name: "ThermoPro Temperature" + humidity: + name: "ThermoPro Humidity" + battery_level: + name: "ThermoPro Battery Level" + signal_strength: + name: "ThermoPro Signal Strength" diff --git a/tests/components/thermopro_ble/test.esp32-idf.yaml b/tests/components/thermopro_ble/test.esp32-idf.yaml new file mode 100644 index 0000000000..7a6541ae76 --- /dev/null +++ b/tests/components/thermopro_ble/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + ble: !include ../../test_build_components/common/ble/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/toshiba/common_ras2819t.yaml b/tests/components/toshiba/common_ras2819t.yaml index 32081fca98..157456ba81 100644 --- a/tests/components/toshiba/common_ras2819t.yaml +++ b/tests/components/toshiba/common_ras2819t.yaml @@ -1,11 +1,3 @@ -remote_transmitter: - pin: ${tx_pin} - carrier_duty_percent: 50% - -remote_receiver: - id: rcvr - pin: ${rx_pin} - climate: - platform: toshiba name: "RAS-2819T Climate" diff --git a/tests/components/toshiba/test_ras2819t.esp32-ard.yaml b/tests/components/toshiba/test_ras2819t.esp32-ard.yaml index 00805baa01..d82ba54897 100644 --- a/tests/components/toshiba/test_ras2819t.esp32-ard.yaml +++ b/tests/components/toshiba/test_ras2819t.esp32-ard.yaml @@ -1,5 +1,5 @@ -substitutions: - tx_pin: GPIO5 - rx_pin: GPIO4 +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-ard.yaml <<: !include common_ras2819t.yaml diff --git a/tests/components/toshiba/test_ras2819t.esp32-c3-ard.yaml b/tests/components/toshiba/test_ras2819t.esp32-c3-ard.yaml index 00805baa01..6858dd587f 100644 --- a/tests/components/toshiba/test_ras2819t.esp32-c3-ard.yaml +++ b/tests/components/toshiba/test_ras2819t.esp32-c3-ard.yaml @@ -1,5 +1,5 @@ -substitutions: - tx_pin: GPIO5 - rx_pin: GPIO4 +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-c3-ard.yaml <<: !include common_ras2819t.yaml diff --git a/tests/components/toshiba/test_ras2819t.esp32-idf.yaml b/tests/components/toshiba/test_ras2819t.esp32-idf.yaml index 00805baa01..3facc5bbb3 100644 --- a/tests/components/toshiba/test_ras2819t.esp32-idf.yaml +++ b/tests/components/toshiba/test_ras2819t.esp32-idf.yaml @@ -1,5 +1,5 @@ -substitutions: - tx_pin: GPIO5 - rx_pin: GPIO4 +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp32-idf.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp32-idf.yaml <<: !include common_ras2819t.yaml diff --git a/tests/components/toshiba/test_ras2819t.esp8266-ard.yaml b/tests/components/toshiba/test_ras2819t.esp8266-ard.yaml index 00805baa01..3976dcc739 100644 --- a/tests/components/toshiba/test_ras2819t.esp8266-ard.yaml +++ b/tests/components/toshiba/test_ras2819t.esp8266-ard.yaml @@ -1,5 +1,5 @@ -substitutions: - tx_pin: GPIO5 - rx_pin: GPIO4 +packages: + remote_transmitter: !include ../../test_build_components/common/remote_transmitter/esp8266-ard.yaml + remote_receiver: !include ../../test_build_components/common/remote_receiver/esp8266-ard.yaml <<: !include common_ras2819t.yaml diff --git a/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-s3-idf.yaml b/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-s3-idf.yaml deleted file mode 100644 index 88a806eb92..0000000000 --- a/tests/components/uart/test-uart_max_with_usb_serial_jtag.esp32-s3-idf.yaml +++ /dev/null @@ -1,48 +0,0 @@ -<<: !include ../logger/common-usb_serial_jtag.yaml - -esphome: - on_boot: - then: - - uart.write: - id: uart_1 - data: 'Hello World' - - uart.write: - id: uart_1 - data: [0x00, 0x20, 0x42] - -uart: - - id: uart_1 - tx_pin: 4 - rx_pin: 5 - flow_control_pin: 6 - baud_rate: 9600 - data_bits: 8 - rx_buffer_size: 512 - rx_full_threshold: 10 - rx_timeout: 1 - parity: EVEN - stop_bits: 2 - - - id: uart_2 - tx_pin: 7 - rx_pin: 8 - flow_control_pin: 9 - baud_rate: 9600 - data_bits: 8 - rx_buffer_size: 512 - rx_full_threshold: 10 - rx_timeout: 1 - parity: EVEN - stop_bits: 2 - - - id: uart_3 - tx_pin: 10 - rx_pin: 11 - flow_control_pin: 12 - baud_rate: 9600 - data_bits: 8 - rx_buffer_size: 512 - rx_full_threshold: 10 - rx_timeout: 1 - parity: EVEN - stop_bits: 2 diff --git a/tests/components/uptime/common.yaml b/tests/components/uptime/common.yaml index 86b764e7ff..279258c670 100644 --- a/tests/components/uptime/common.yaml +++ b/tests/components/uptime/common.yaml @@ -3,6 +3,7 @@ wifi: time: - platform: sntp + id: sntp_time sensor: - platform: uptime diff --git a/tests/components/wifi/common.yaml b/tests/components/wifi/common.yaml index af27f85092..7ce74ab00d 100644 --- a/tests/components/wifi/common.yaml +++ b/tests/components/wifi/common.yaml @@ -10,10 +10,19 @@ esphome: - logger.log: "Connected to WiFi!" on_error: - logger.log: "Failed to connect to WiFi!" + - if: + condition: wifi.ap_active + then: + - logger.log: "WiFi AP is active!" wifi: networks: - ssid: MySSID password: password1 + priority: 10 - ssid: MySSID2 password: password2 + priority: 5 + - ssid: MySSID3 + password: password3 + priority: 0 diff --git a/tests/components/wifi/test.esp32-idf.yaml b/tests/components/wifi/test.esp32-idf.yaml index 91e235b9ce..3e01d7f990 100644 --- a/tests/components/wifi/test.esp32-idf.yaml +++ b/tests/components/wifi/test.esp32-idf.yaml @@ -1,7 +1,34 @@ psram: +# Tests the high performance request and release; requires the USE_WIFI_RUNTIME_POWER_SAVE define +esphome: + platformio_options: + build_flags: + - "-DUSE_WIFI_RUNTIME_POWER_SAVE" + on_boot: + - then: + - lambda: |- + esphome::wifi::global_wifi_component->request_high_performance(); + esphome::wifi::global_wifi_component->release_high_performance(); + wifi: use_psram: true + min_auth_mode: WPA + manual_ip: + static_ip: 192.168.1.100 + gateway: 192.168.1.1 + subnet: 255.255.255.0 + dns1: 1.1.1.1 + dns2: 8.8.8.8 + ap: + ssid: Fallback AP + password: fallback_password + manual_ip: + static_ip: 192.168.4.1 + gateway: 192.168.4.1 + subnet: 255.255.255.0 + +captive_portal: packages: - !include common.yaml diff --git a/tests/components/wifi/test.esp8266-ard.yaml b/tests/components/wifi/test.esp8266-ard.yaml index dade44d145..9cb0e3cf48 100644 --- a/tests/components/wifi/test.esp8266-ard.yaml +++ b/tests/components/wifi/test.esp8266-ard.yaml @@ -1 +1,5 @@ -<<: !include common.yaml +wifi: + min_auth_mode: WPA2 + +packages: + - !include common.yaml diff --git a/tests/components/wifi_info/common-mac.yaml b/tests/components/wifi_info/common-mac.yaml new file mode 100644 index 0000000000..3571cd08c4 --- /dev/null +++ b/tests/components/wifi_info/common-mac.yaml @@ -0,0 +1,8 @@ +wifi: + ssid: MySSID + password: password1 + +text_sensor: + - platform: wifi_info + mac_address: + name: MAC Address diff --git a/tests/components/wifi_info/common.yaml b/tests/components/wifi_info/common.yaml index cf5ea563ba..f87d381d0c 100644 --- a/tests/components/wifi_info/common.yaml +++ b/tests/components/wifi_info/common.yaml @@ -13,6 +13,6 @@ text_sensor: bssid: name: BSSID mac_address: - name: Mac Address + name: MAC Address dns_address: name: DNS ADdress diff --git a/tests/components/wifi_info/test-mac.esp32-idf.yaml b/tests/components/wifi_info/test-mac.esp32-idf.yaml new file mode 100644 index 0000000000..9d561ca2ce --- /dev/null +++ b/tests/components/wifi_info/test-mac.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp32-idf.yaml + +<<: !include common-mac.yaml diff --git a/tests/components/wifi_info/test-mac.esp8266-ard.yaml b/tests/components/wifi_info/test-mac.esp8266-ard.yaml new file mode 100644 index 0000000000..05f6344fb6 --- /dev/null +++ b/tests/components/wifi_info/test-mac.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/esp8266-ard.yaml + +<<: !include common-mac.yaml diff --git a/tests/components/wifi_info/test-mac.rp2040-ard.yaml b/tests/components/wifi_info/test-mac.rp2040-ard.yaml new file mode 100644 index 0000000000..d2d54def9d --- /dev/null +++ b/tests/components/wifi_info/test-mac.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + i2c: !include ../../test_build_components/common/i2c/rp2040-ard.yaml + +<<: !include common-mac.yaml diff --git a/tests/components/wireguard/common.yaml b/tests/components/wireguard/common.yaml index cd7ab1075e..342ffa32f6 100644 --- a/tests/components/wireguard/common.yaml +++ b/tests/components/wireguard/common.yaml @@ -4,8 +4,10 @@ wifi: time: - platform: sntp + id: sntp_time wireguard: + time_id: sntp_time address: 172.16.34.100 netmask: 255.255.255.0 # NEVER use the following keys for your VPN -- they are now public! diff --git a/tests/components/wk2132_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2132_i2c/test.esp32-s3-idf.yaml deleted file mode 100644 index d7b149a6fd..0000000000 --- a/tests/components/wk2132_i2c/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,9 +0,0 @@ -substitutions: - scl_pin: GPIO40 - sda_pin: GPIO41 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2132_spi/test.esp32-s3-idf.yaml b/tests/components/wk2132_spi/test.esp32-s3-idf.yaml deleted file mode 100644 index 9c7d36996e..0000000000 --- a/tests/components/wk2132_spi/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,11 +0,0 @@ -substitutions: - clk_pin: GPIO40 - miso_pin: GPIO41 - mosi_pin: GPIO6 - cs_pin: GPIO19 - -packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml - uart_bridge_2: !include ../../test_build_components/common/uart_bridge_2/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2168_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2168_i2c/test.esp32-s3-idf.yaml deleted file mode 100644 index 115812be97..0000000000 --- a/tests/components/wk2168_i2c/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,9 +0,0 @@ -substitutions: - scl_pin: GPIO40 - sda_pin: GPIO41 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2168_spi/test.esp32-s3-idf.yaml b/tests/components/wk2168_spi/test.esp32-s3-idf.yaml deleted file mode 100644 index 374fe64d16..0000000000 --- a/tests/components/wk2168_spi/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,11 +0,0 @@ -substitutions: - clk_pin: GPIO40 - miso_pin: GPIO41 - mosi_pin: GPIO6 - cs_pin: GPIO19 - -packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2204_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2204_i2c/test.esp32-s3-idf.yaml deleted file mode 100644 index 115812be97..0000000000 --- a/tests/components/wk2204_i2c/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,9 +0,0 @@ -substitutions: - scl_pin: GPIO40 - sda_pin: GPIO41 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2204_spi/test.esp32-s3-idf.yaml b/tests/components/wk2204_spi/test.esp32-s3-idf.yaml deleted file mode 100644 index 374fe64d16..0000000000 --- a/tests/components/wk2204_spi/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,11 +0,0 @@ -substitutions: - clk_pin: GPIO40 - miso_pin: GPIO41 - mosi_pin: GPIO6 - cs_pin: GPIO19 - -packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2212_i2c/test.esp32-s3-idf.yaml b/tests/components/wk2212_i2c/test.esp32-s3-idf.yaml deleted file mode 100644 index 115812be97..0000000000 --- a/tests/components/wk2212_i2c/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,9 +0,0 @@ -substitutions: - scl_pin: GPIO40 - sda_pin: GPIO41 - -packages: - i2c: !include ../../test_build_components/common/i2c/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/components/wk2212_spi/test.esp32-s3-idf.yaml b/tests/components/wk2212_spi/test.esp32-s3-idf.yaml deleted file mode 100644 index 374fe64d16..0000000000 --- a/tests/components/wk2212_spi/test.esp32-s3-idf.yaml +++ /dev/null @@ -1,11 +0,0 @@ -substitutions: - clk_pin: GPIO40 - miso_pin: GPIO41 - mosi_pin: GPIO6 - cs_pin: GPIO19 - -packages: - spi: !include ../../test_build_components/common/spi/esp32-s3-idf.yaml - uart_bridge_4: !include ../../test_build_components/common/uart_bridge_4/esp32-s3-idf.yaml - -<<: !include common.yaml diff --git a/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml b/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml index bf4ef9eafd..3996d0f169 100644 --- a/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml +++ b/tests/integration/fixtures/climate_custom_fan_modes_and_presets.yaml @@ -14,6 +14,7 @@ climate: id: test_thermostat name: Test Thermostat Custom Modes sensor: thermostat_sensor + default_preset: "Eco Plus" preset: - name: Away default_target_temperature_low: 16°C diff --git a/tests/integration/fixtures/light_automations.yaml b/tests/integration/fixtures/light_automations.yaml new file mode 100644 index 0000000000..b5b88d95e7 --- /dev/null +++ b/tests/integration/fixtures/light_automations.yaml @@ -0,0 +1,26 @@ +esphome: + name: light-automations-test + +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +output: + - platform: template + id: test_output + type: binary + write_action: + - lambda: "" + +light: + - platform: binary + id: test_light + name: "Test Light" + output: test_output + on_turn_on: + - logger.log: "TRIGGER: on_turn_on fired" + on_turn_off: + - logger.log: "TRIGGER: on_turn_off fired" + on_state: + - logger.log: "TRIGGER: on_state fired" diff --git a/tests/integration/fixtures/lock_automations.yaml b/tests/integration/fixtures/lock_automations.yaml new file mode 100644 index 0000000000..fe11e656fa --- /dev/null +++ b/tests/integration/fixtures/lock_automations.yaml @@ -0,0 +1,17 @@ +esphome: + name: lock-automations-test + +host: +api: # Port will be automatically injected +logger: + level: DEBUG + +lock: + - platform: template + id: test_lock + name: "Test Lock" + optimistic: true + on_lock: + - logger.log: "TRIGGER: on_lock fired" + on_unlock: + - logger.log: "TRIGGER: on_unlock fired" diff --git a/tests/integration/fixtures/script_delay_with_params.yaml b/tests/integration/fixtures/script_delay_with_params.yaml new file mode 100644 index 0000000000..2a0f16d9fe --- /dev/null +++ b/tests/integration/fixtures/script_delay_with_params.yaml @@ -0,0 +1,131 @@ +esphome: + name: test-script-delay-params + +host: + +api: + actions: + # Test case from issue #12044: parent script with repeat calling child with delay + - action: test_repeat_with_delay + then: + - logger.log: "=== TEST: Repeat loop calling script with delay and parameters ===" + - script.execute: father_script + + # Test case from issue #12043: script.wait with delayed child script + - action: test_script_wait + then: + - logger.log: "=== TEST: script.wait with delayed child script ===" + - script.execute: show_start_page + - script.wait: show_start_page + - logger.log: "After wait: script completed successfully" + + # Test: Delay with different parameter types + - action: test_delay_param_types + then: + - logger.log: "=== TEST: Delay with various parameter types ===" + - script.execute: + id: delay_with_int + val: 42 + - delay: 50ms + - script.execute: + id: delay_with_string + msg: "test message" + - delay: 50ms + - script.execute: + id: delay_with_float + num: 3.14 + +logger: + level: DEBUG + +script: + # Reproduces issue #12044: child script with conditional delay + - id: son_script + mode: single + parameters: + iteration: int + then: + - logger.log: + format: "Son script started with iteration %d" + args: ['iteration'] + - if: + condition: + lambda: 'return iteration >= 5;' + then: + - logger.log: + format: "Son script delaying for iteration %d" + args: ['iteration'] + - delay: 100ms + - logger.log: + format: "Son script finished with iteration %d" + args: ['iteration'] + + # Reproduces issue #12044: parent script with repeat loop + - id: father_script + mode: single + then: + - repeat: + count: 10 + then: + - logger.log: + format: "Father iteration %d: calling son" + args: ['iteration'] + - script.execute: + id: son_script + iteration: !lambda 'return iteration;' + - script.wait: son_script + - logger.log: + format: "Father iteration %d: son finished, wait returned" + args: ['iteration'] + + # Reproduces issue #12043: script.wait hangs + - id: show_start_page + mode: single + then: + - logger.log: "Start page: beginning" + - delay: 100ms + - logger.log: "Start page: after delay" + - delay: 100ms + - logger.log: "Start page: completed" + + # Test delay with int parameter + - id: delay_with_int + mode: single + parameters: + val: int + then: + - logger.log: + format: "Int test: before delay, val=%d" + args: ['val'] + - delay: 50ms + - logger.log: + format: "Int test: after delay, val=%d" + args: ['val'] + + # Test delay with string parameter + - id: delay_with_string + mode: single + parameters: + msg: string + then: + - logger.log: + format: "String test: before delay, msg=%s" + args: ['msg.c_str()'] + - delay: 50ms + - logger.log: + format: "String test: after delay, msg=%s" + args: ['msg.c_str()'] + + # Test delay with float parameter + - id: delay_with_float + mode: single + parameters: + num: float + then: + - logger.log: + format: "Float test: before delay, num=%.2f" + args: ['num'] + - delay: 50ms + - logger.log: + format: "Float test: after delay, num=%.2f" + args: ['num'] diff --git a/tests/integration/fixtures/script_wait_on_boot.yaml b/tests/integration/fixtures/script_wait_on_boot.yaml new file mode 100644 index 0000000000..8736b02294 --- /dev/null +++ b/tests/integration/fixtures/script_wait_on_boot.yaml @@ -0,0 +1,54 @@ +esphome: + name: test-script-wait-on-boot + on_boot: + # Use default priority (600.0) which is same as ScriptWaitAction's setup priority + # This tests the race condition where on_boot runs before ScriptWaitAction::setup() + then: + - logger.log: "=== on_boot: Starting boot sequence ===" + - script.execute: show_start_page + - script.wait: show_start_page + - logger.log: "=== on_boot: First script completed, starting second ===" + - script.execute: flip_thru_pages + - script.wait: flip_thru_pages + - logger.log: "=== on_boot: All boot scripts completed successfully ===" + +host: + +api: + actions: + # Manual trigger for additional testing + - action: test_script_wait + then: + - logger.log: "=== Manual test: Starting ===" + - script.execute: show_start_page + - script.wait: show_start_page + - logger.log: "=== Manual test: First script completed ===" + - script.execute: flip_thru_pages + - script.wait: flip_thru_pages + - logger.log: "=== Manual test: All completed ===" + +logger: + level: DEBUG + +script: + # First script - simulates display initialization + - id: show_start_page + mode: single + then: + - logger.log: "show_start_page: Starting" + - delay: 100ms + - logger.log: "show_start_page: After delay 1" + - delay: 100ms + - logger.log: "show_start_page: Completed" + + # Second script - simulates page flip sequence + - id: flip_thru_pages + mode: single + then: + - logger.log: "flip_thru_pages: Starting" + - delay: 50ms + - logger.log: "flip_thru_pages: Page 1" + - delay: 50ms + - logger.log: "flip_thru_pages: Page 2" + - delay: 50ms + - logger.log: "flip_thru_pages: Completed" diff --git a/tests/integration/fixtures/sensor_timeout_filter.yaml b/tests/integration/fixtures/sensor_timeout_filter.yaml new file mode 100644 index 0000000000..dbd4db3242 --- /dev/null +++ b/tests/integration/fixtures/sensor_timeout_filter.yaml @@ -0,0 +1,150 @@ +esphome: + name: test-timeout-filters + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Template sensors that we'll use to publish values +sensor: + - platform: template + name: "Source Timeout Last" + id: source_timeout_last + accuracy_decimals: 1 + + - platform: template + name: "Source Timeout Reset" + id: source_timeout_reset + accuracy_decimals: 1 + + - platform: template + name: "Source Timeout Static" + id: source_timeout_static + accuracy_decimals: 1 + + - platform: template + name: "Source Timeout Lambda" + id: source_timeout_lambda + accuracy_decimals: 1 + + # Test 1: TimeoutFilter - "last" mode (outputs last received value) + - platform: copy + source_id: source_timeout_last + name: "Timeout Last Sensor" + id: timeout_last_sensor + filters: + - timeout: + timeout: 100ms + value: last # Explicitly specify "last" mode to use TimeoutFilter class + + # Test 2: TimeoutFilter - reset behavior (same filter, different source) + - platform: copy + source_id: source_timeout_reset + name: "Timeout Reset Sensor" + id: timeout_reset_sensor + filters: + - timeout: + timeout: 100ms + value: last # Explicitly specify "last" mode + + # Test 3: TimeoutFilterConfigured - static value mode + - platform: copy + source_id: source_timeout_static + name: "Timeout Static Sensor" + id: timeout_static_sensor + filters: + - timeout: + timeout: 100ms + value: 99.9 + + # Test 4: TimeoutFilterConfigured - lambda mode + - platform: copy + source_id: source_timeout_lambda + name: "Timeout Lambda Sensor" + id: timeout_lambda_sensor + filters: + - timeout: + timeout: 100ms + value: !lambda "return -1.0;" + +# Scripts to publish values with controlled timing +script: + # Test 1: Single value followed by timeout + - id: test_timeout_last_script + then: + # Publish initial value + - sensor.template.publish: + id: source_timeout_last + state: 42.0 + # Wait for timeout to fire (100ms + margin) + - delay: 150ms + + # Test 2: Multiple values before timeout (should reset timer) + - id: test_timeout_reset_script + then: + # Publish first value + - sensor.template.publish: + id: source_timeout_reset + state: 10.0 + # Wait 50ms (halfway to timeout) + - delay: 50ms + # Publish second value (resets timeout) + - sensor.template.publish: + id: source_timeout_reset + state: 20.0 + # Wait 50ms (halfway to timeout again) + - delay: 50ms + # Publish third value (resets timeout) + - sensor.template.publish: + id: source_timeout_reset + state: 30.0 + # Wait for timeout to fire (100ms + margin) + - delay: 150ms + + # Test 3: Static value timeout + - id: test_timeout_static_script + then: + # Publish initial value + - sensor.template.publish: + id: source_timeout_static + state: 55.5 + # Wait for timeout to fire + - delay: 150ms + + # Test 4: Lambda value timeout + - id: test_timeout_lambda_script + then: + # Publish initial value + - sensor.template.publish: + id: source_timeout_lambda + state: 77.7 + # Wait for timeout to fire + - delay: 150ms + +# Buttons to trigger each test scenario +button: + - platform: template + name: "Test Timeout Last Button" + id: test_timeout_last_button + on_press: + - script.execute: test_timeout_last_script + + - platform: template + name: "Test Timeout Reset Button" + id: test_timeout_reset_button + on_press: + - script.execute: test_timeout_reset_script + + - platform: template + name: "Test Timeout Static Button" + id: test_timeout_static_button + on_press: + - script.execute: test_timeout_static_script + + - platform: template + name: "Test Timeout Lambda Button" + id: test_timeout_lambda_button + on_press: + - script.execute: test_timeout_lambda_script diff --git a/tests/integration/fixtures/template_alarm_control_panel_many_sensors.yaml b/tests/integration/fixtures/template_alarm_control_panel_many_sensors.yaml new file mode 100644 index 0000000000..836d3f11d5 --- /dev/null +++ b/tests/integration/fixtures/template_alarm_control_panel_many_sensors.yaml @@ -0,0 +1,136 @@ +esphome: + name: template-alarm-many-sensors + friendly_name: "Template Alarm Control Panel with Many Sensors" + +logger: + +host: + +api: + +binary_sensor: + - platform: template + id: sensor1 + name: "Door 1" + - platform: template + id: sensor2 + name: "Door 2" + - platform: template + id: sensor3 + name: "Window 1" + - platform: template + id: sensor4 + name: "Window 2" + - platform: template + id: sensor5 + name: "Motion 1" + - platform: template + id: sensor6 + name: "Motion 2" + - platform: template + id: sensor7 + name: "Glass Break 1" + - platform: template + id: sensor8 + name: "Glass Break 2" + - platform: template + id: sensor9 + name: "Smoke Detector" + - platform: template + id: sensor10 + name: "CO Detector" + +alarm_control_panel: + - platform: template + id: test_alarm + name: "Test Alarm" + codes: + - "1234" + requires_code_to_arm: true + arming_away_time: 5s + arming_home_time: 3s + arming_night_time: 3s + pending_time: 10s + trigger_time: 300s + restore_mode: ALWAYS_DISARMED + binary_sensors: + - input: sensor1 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: true + chime: true + trigger_mode: DELAYED + - input: sensor2 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: true + chime: true + trigger_mode: DELAYED + - input: sensor3 + bypass_armed_home: true + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: DELAYED + - input: sensor4 + bypass_armed_home: true + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: DELAYED + - input: sensor5 + bypass_armed_home: false + bypass_armed_night: true + bypass_auto: false + chime: false + trigger_mode: INSTANT + - input: sensor6 + bypass_armed_home: false + bypass_armed_night: true + bypass_auto: false + chime: false + trigger_mode: INSTANT + - input: sensor7 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: INSTANT + - input: sensor8 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: INSTANT + - input: sensor9 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: INSTANT_ALWAYS + - input: sensor10 + bypass_armed_home: false + bypass_armed_night: false + bypass_auto: false + chime: false + trigger_mode: INSTANT_ALWAYS + on_disarmed: + - logger.log: "Alarm disarmed" + on_arming: + - logger.log: "Alarm arming" + on_armed_away: + - logger.log: "Alarm armed away" + on_armed_home: + - logger.log: "Alarm armed home" + on_armed_night: + - logger.log: "Alarm armed night" + on_pending: + - logger.log: "Alarm pending" + on_triggered: + - logger.log: "Alarm triggered" + on_cleared: + - logger.log: "Alarm cleared" + on_chime: + - logger.log: "Chime activated" + on_ready: + - logger.log: "Sensors ready state changed" diff --git a/tests/integration/fixtures/text_sensor_raw_state.yaml b/tests/integration/fixtures/text_sensor_raw_state.yaml new file mode 100644 index 0000000000..03aece0a04 --- /dev/null +++ b/tests/integration/fixtures/text_sensor_raw_state.yaml @@ -0,0 +1,54 @@ +esphome: + name: test-text-sensor-raw-state + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Text sensor WITHOUT filters - get_raw_state() should return same as state +text_sensor: + - platform: template + name: "No Filter Sensor" + id: no_filter_sensor + + # Text sensor WITH filter - get_raw_state() should return original value + - platform: template + name: "With Filter Sensor" + id: with_filter_sensor + filters: + - to_upper + +# Button to publish values and log raw_state vs state +button: + - platform: template + name: "Test No Filter Button" + id: test_no_filter_button + on_press: + - text_sensor.template.publish: + id: no_filter_sensor + state: "hello world" + - delay: 50ms + # Log both state and get_raw_state() to verify they match + - logger.log: + format: "NO_FILTER: state='%s' raw_state='%s'" + args: + - id(no_filter_sensor).state.c_str() + - id(no_filter_sensor).get_raw_state().c_str() + + - platform: template + name: "Test With Filter Button" + id: test_with_filter_button + on_press: + - text_sensor.template.publish: + id: with_filter_sensor + state: "hello world" + - delay: 50ms + # Log both state and get_raw_state() to verify filter works + # state should be "HELLO WORLD" (filtered), raw_state should be "hello world" (original) + - logger.log: + format: "WITH_FILTER: state='%s' raw_state='%s'" + args: + - id(with_filter_sensor).state.c_str() + - id(with_filter_sensor).get_raw_state().c_str() diff --git a/tests/integration/fixtures/wait_until_fifo_ordering.yaml b/tests/integration/fixtures/wait_until_fifo_ordering.yaml new file mode 100644 index 0000000000..5dd60c8755 --- /dev/null +++ b/tests/integration/fixtures/wait_until_fifo_ordering.yaml @@ -0,0 +1,82 @@ +esphome: + name: test-wait-until-ordering + +host: + +api: + actions: + - action: test_wait_until_fifo + then: + - logger.log: "=== TEST: wait_until should execute in FIFO order ===" + - globals.set: + id: gate_open + value: 'false' + - delay: 100ms + # Start multiple parallel executions of coordinator script + # Each will call the shared waiter script, queueing in same wait_until + - script.execute: coordinator_0 + - script.execute: coordinator_1 + - script.execute: coordinator_2 + - script.execute: coordinator_3 + - script.execute: coordinator_4 + # Give scripts time to reach wait_until and queue + - delay: 200ms + - logger.log: "Opening gate - all wait_until should complete now" + - globals.set: + id: gate_open + value: 'true' + - delay: 500ms + - logger.log: "Test complete" + +globals: + - id: gate_open + type: bool + initial_value: 'false' + +script: + # Shared waiter with single wait_until action (all coordinators call this) + - id: waiter + mode: parallel + parameters: + iter: int + then: + - lambda: 'ESP_LOGD("main", "Queueing iteration %d", iter);' + - wait_until: + condition: + lambda: 'return id(gate_open);' + timeout: 5s + - lambda: 'ESP_LOGD("main", "Completed iteration %d", iter);' + + # Coordinator scripts - each calls shared waiter with different iteration number + - id: coordinator_0 + then: + - script.execute: + id: waiter + iter: 0 + + - id: coordinator_1 + then: + - script.execute: + id: waiter + iter: 1 + + - id: coordinator_2 + then: + - script.execute: + id: waiter + iter: 2 + + - id: coordinator_3 + then: + - script.execute: + id: waiter + iter: 3 + + - id: coordinator_4 + then: + - script.execute: + id: waiter + iter: 4 + +logger: + level: DEBUG diff --git a/tests/integration/fixtures/wait_until_on_boot.yaml b/tests/integration/fixtures/wait_until_on_boot.yaml new file mode 100644 index 0000000000..358bef971b --- /dev/null +++ b/tests/integration/fixtures/wait_until_on_boot.yaml @@ -0,0 +1,47 @@ +# Test for wait_until in on_boot automation +# Reproduces bug where wait_until in on_boot would hang forever +# because WaitUntilAction::setup() would disable_loop() after +# play_complex() had already enabled it. + +esphome: + name: wait-until-on-boot + on_boot: + then: + - logger.log: "on_boot: Starting wait_until test" + - globals.set: + id: on_boot_started + value: 'true' + - wait_until: + condition: + lambda: return id(test_flag); + timeout: 5s + - logger.log: "on_boot: wait_until completed successfully" + +host: + +logger: + level: DEBUG + +globals: + - id: on_boot_started + type: bool + initial_value: 'false' + - id: test_flag + type: bool + initial_value: 'false' + +api: + actions: + - action: set_test_flag + then: + - globals.set: + id: test_flag + value: 'true' + - action: check_on_boot_started + then: + - lambda: |- + if (id(on_boot_started)) { + ESP_LOGI("test", "on_boot has started"); + } else { + ESP_LOGI("test", "on_boot has NOT started"); + } diff --git a/tests/integration/test_climate_custom_modes.py b/tests/integration/test_climate_custom_modes.py index ce34959d88..67a7b0581a 100644 --- a/tests/integration/test_climate_custom_modes.py +++ b/tests/integration/test_climate_custom_modes.py @@ -2,9 +2,13 @@ from __future__ import annotations -from aioesphomeapi import ClimateInfo, ClimatePreset +import asyncio + +import aioesphomeapi +from aioesphomeapi import ClimateInfo, ClimatePreset, EntityState import pytest +from .state_utils import InitialStateHelper from .types import APIClientConnectedFactory, RunCompiledFunction @@ -14,15 +18,50 @@ async def test_climate_custom_fan_modes_and_presets( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test that custom presets are properly exposed via API.""" + """Test that custom presets are properly exposed and can be changed.""" + loop = asyncio.get_running_loop() async with run_compiled(yaml_config), api_client_connected() as client: - # Get entities and services + states: dict[int, EntityState] = {} + super_saver_future: asyncio.Future[EntityState] = loop.create_future() + vacation_future: asyncio.Future[EntityState] = loop.create_future() + + def on_state(state: EntityState) -> None: + states[state.key] = state + if isinstance(state, aioesphomeapi.ClimateState): + # Wait for Super Saver preset + if ( + state.custom_preset == "Super Saver" + and state.target_temperature_low == 20.0 + and state.target_temperature_high == 24.0 + and not super_saver_future.done() + ): + super_saver_future.set_result(state) + # Wait for Vacation Mode preset + elif ( + state.custom_preset == "Vacation Mode" + and state.target_temperature_low == 15.0 + and state.target_temperature_high == 18.0 + and not vacation_future.done() + ): + vacation_future.set_result(state) + + # Get entities and set up state synchronization entities, services = await client.list_entities_services() + initial_state_helper = InitialStateHelper(entities) climate_infos = [e for e in entities if isinstance(e, ClimateInfo)] assert len(climate_infos) == 1, "Expected exactly 1 climate entity" test_climate = climate_infos[0] + # Subscribe with the wrapper that filters initial states + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for all initial states to be broadcast + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + # Verify enum presets are exposed (from preset: config map) assert ClimatePreset.AWAY in test_climate.supported_presets, ( "Expected AWAY in enum presets" @@ -40,3 +79,43 @@ async def test_climate_custom_fan_modes_and_presets( assert "Vacation Mode" in custom_presets, ( "Expected 'Vacation Mode' in custom presets" ) + + # Get initial state and verify default preset + initial_state = initial_state_helper.initial_states.get(test_climate.key) + assert initial_state is not None, "Climate initial state not found" + assert isinstance(initial_state, aioesphomeapi.ClimateState) + assert initial_state.custom_preset == "Eco Plus", ( + f"Expected default preset 'Eco Plus', got '{initial_state.custom_preset}'" + ) + assert initial_state.target_temperature_low == 18.0, ( + f"Expected low temp 18.0, got {initial_state.target_temperature_low}" + ) + assert initial_state.target_temperature_high == 22.0, ( + f"Expected high temp 22.0, got {initial_state.target_temperature_high}" + ) + + # Test changing to "Super Saver" custom preset + client.climate_command(test_climate.key, custom_preset="Super Saver") + + try: + super_saver_state = await asyncio.wait_for(super_saver_future, timeout=5.0) + except TimeoutError: + pytest.fail("Super Saver preset change not received within 5 seconds") + + assert isinstance(super_saver_state, aioesphomeapi.ClimateState) + assert super_saver_state.custom_preset == "Super Saver" + assert super_saver_state.target_temperature_low == 20.0 + assert super_saver_state.target_temperature_high == 24.0 + + # Test changing to "Vacation Mode" custom preset + client.climate_command(test_climate.key, custom_preset="Vacation Mode") + + try: + vacation_state = await asyncio.wait_for(vacation_future, timeout=5.0) + except TimeoutError: + pytest.fail("Vacation Mode preset change not received within 5 seconds") + + assert isinstance(vacation_state, aioesphomeapi.ClimateState) + assert vacation_state.custom_preset == "Vacation Mode" + assert vacation_state.target_temperature_low == 15.0 + assert vacation_state.target_temperature_high == 18.0 diff --git a/tests/integration/test_light_automations.py b/tests/integration/test_light_automations.py new file mode 100644 index 0000000000..9ff334548a --- /dev/null +++ b/tests/integration/test_light_automations.py @@ -0,0 +1,101 @@ +"""Integration test for light automation triggers. + +Tests that on_turn_on, on_turn_off, and on_state triggers work correctly +with the listener interface pattern. +""" + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_light_automations( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test light on_turn_on, on_turn_off, and on_state triggers.""" + loop = asyncio.get_running_loop() + + # Futures for log line detection + on_turn_on_future: asyncio.Future[bool] = loop.create_future() + on_turn_off_future: asyncio.Future[bool] = loop.create_future() + on_state_count = 0 + counting_enabled = False + on_state_futures: list[asyncio.Future[bool]] = [] + + def create_on_state_future() -> asyncio.Future[bool]: + """Create a new future for on_state trigger.""" + future: asyncio.Future[bool] = loop.create_future() + on_state_futures.append(future) + return future + + def check_output(line: str) -> None: + """Check log output for trigger messages.""" + nonlocal on_state_count + if "TRIGGER: on_turn_on fired" in line: + if not on_turn_on_future.done(): + on_turn_on_future.set_result(True) + elif "TRIGGER: on_turn_off fired" in line: + if not on_turn_off_future.done(): + on_turn_off_future.set_result(True) + elif "TRIGGER: on_state fired" in line: + # Only count on_state after we start testing + if counting_enabled: + on_state_count += 1 + # Complete any pending on_state futures + for future in on_state_futures: + if not future.done(): + future.set_result(True) + break + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Get entities + entities = await client.list_entities_services() + light = next(e for e in entities[0] if e.object_id == "test_light") + + # Start counting on_state events now + counting_enabled = True + + # Test 1: Turn light on - should trigger on_turn_on and on_state + on_state_future_1 = create_on_state_future() + client.light_command(key=light.key, state=True) + + # Wait for on_turn_on trigger + try: + await asyncio.wait_for(on_turn_on_future, timeout=5.0) + except TimeoutError: + pytest.fail("on_turn_on trigger did not fire") + + # Wait for on_state trigger + try: + await asyncio.wait_for(on_state_future_1, timeout=5.0) + except TimeoutError: + pytest.fail("on_state trigger did not fire after turn on") + + # Test 2: Turn light off - should trigger on_turn_off and on_state + on_state_future_2 = create_on_state_future() + client.light_command(key=light.key, state=False) + + # Wait for on_turn_off trigger + try: + await asyncio.wait_for(on_turn_off_future, timeout=5.0) + except TimeoutError: + pytest.fail("on_turn_off trigger did not fire") + + # Wait for on_state trigger + try: + await asyncio.wait_for(on_state_future_2, timeout=5.0) + except TimeoutError: + pytest.fail("on_state trigger did not fire after turn off") + + # Verify on_state fired exactly twice (once for on, once for off) + assert on_state_count == 2, ( + f"on_state should have triggered exactly twice, got {on_state_count}" + ) diff --git a/tests/integration/test_lock_automations.py b/tests/integration/test_lock_automations.py new file mode 100644 index 0000000000..e200a2eacd --- /dev/null +++ b/tests/integration/test_lock_automations.py @@ -0,0 +1,58 @@ +"""Integration test for lock automation triggers. + +Tests that on_lock and on_unlock triggers work correctly. +""" + +import asyncio + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_lock_automations( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test lock on_lock and on_unlock triggers.""" + loop = asyncio.get_running_loop() + + # Futures for log line detection + on_lock_future: asyncio.Future[bool] = loop.create_future() + on_unlock_future: asyncio.Future[bool] = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for trigger messages.""" + if "TRIGGER: on_lock fired" in line and not on_lock_future.done(): + on_lock_future.set_result(True) + elif "TRIGGER: on_unlock fired" in line and not on_unlock_future.done(): + on_unlock_future.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Import here to avoid import errors when aioesphomeapi is not installed + from aioesphomeapi import LockCommand + + # Get entities + entities = await client.list_entities_services() + lock = next(e for e in entities[0] if e.object_id == "test_lock") + + # Test 1: Lock - should trigger on_lock + client.lock_command(key=lock.key, command=LockCommand.LOCK) + + try: + await asyncio.wait_for(on_lock_future, timeout=5.0) + except TimeoutError: + pytest.fail("on_lock trigger did not fire") + + # Test 2: Unlock - should trigger on_unlock + client.lock_command(key=lock.key, command=LockCommand.UNLOCK) + + try: + await asyncio.wait_for(on_unlock_future, timeout=5.0) + except TimeoutError: + pytest.fail("on_unlock trigger did not fire") diff --git a/tests/integration/test_script_delay_params.py b/tests/integration/test_script_delay_params.py new file mode 100644 index 0000000000..1b5d70863b --- /dev/null +++ b/tests/integration/test_script_delay_params.py @@ -0,0 +1,121 @@ +"""Integration test for script.wait FIFO ordering (issues #12043, #12044). + +This test verifies that ScriptWaitAction processes queued items in FIFO order. + +PR #7972 introduced bugs in ScriptWaitAction: +- Used emplace_front() causing LIFO ordering instead of FIFO +- Called loop() synchronously causing reentrancy issues +- Used while loop processing entire queue causing infinite loops + +These bugs manifested as: +- Scripts becoming "zombies" (stuck in running state) +- script.wait hanging forever +- Incorrect execution order +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_delay_with_params( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that script.wait processes queued items in FIFO order. + + This reproduces issues #12043 and #12044 where scripts would hang or become + zombies due to LIFO ordering bugs in ScriptWaitAction from PR #7972. + """ + test_complete = asyncio.Event() + + # Patterns to match in logs + father_calling_pattern = re.compile(r"Father iteration (\d+): calling son") + son_started_pattern = re.compile(r"Son script started with iteration (\d+)") + son_delaying_pattern = re.compile(r"Son script delaying for iteration (\d+)") + son_finished_pattern = re.compile(r"Son script finished with iteration (\d+)") + father_wait_returned_pattern = re.compile( + r"Father iteration (\d+): son finished, wait returned" + ) + + # Track which iterations completed + father_calling = set() + son_started = set() + son_delaying = set() + son_finished = set() + wait_returned = set() + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if test_complete.is_set(): + return + + if mo := father_calling_pattern.search(line): + father_calling.add(int(mo.group(1))) + elif mo := son_started_pattern.search(line): + son_started.add(int(mo.group(1))) + elif mo := son_delaying_pattern.search(line): + son_delaying.add(int(mo.group(1))) + elif mo := son_finished_pattern.search(line): + son_finished.add(int(mo.group(1))) + elif mo := father_wait_returned_pattern.search(line): + iteration = int(mo.group(1)) + wait_returned.add(iteration) + # Test completes when iteration 9 finishes + if iteration == 9: + test_complete.set() + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-script-delay-params" + + # Get services + _, services = await client.list_entities_services() + test_service = next( + (s for s in services if s.name == "test_repeat_with_delay"), None + ) + assert test_service is not None, "test_repeat_with_delay service not found" + + # Execute the test + client.execute_service(test_service, {}) + + # Wait for test to complete (10 iterations * ~100ms each + margin) + try: + await asyncio.wait_for(test_complete.wait(), timeout=5.0) + except TimeoutError: + pytest.fail( + f"Test timed out. Completed iterations: {sorted(wait_returned)}. " + f"This likely indicates the script became a zombie (issue #12044)." + ) + + # Verify all 10 iterations completed successfully + expected_iterations = set(range(10)) + assert father_calling == expected_iterations, "Not all iterations started" + assert son_started == expected_iterations, ( + "Son script not started for all iterations" + ) + assert son_finished == expected_iterations, ( + "Son script not finished for all iterations" + ) + assert wait_returned == expected_iterations, ( + "script.wait did not return for all iterations" + ) + + # Verify delays were triggered for iterations >= 5 + expected_delays = set(range(5, 10)) + assert son_delaying == expected_delays, ( + "Delays not triggered for iterations >= 5" + ) diff --git a/tests/integration/test_script_wait_on_boot.py b/tests/integration/test_script_wait_on_boot.py new file mode 100644 index 0000000000..478090f782 --- /dev/null +++ b/tests/integration/test_script_wait_on_boot.py @@ -0,0 +1,130 @@ +"""Integration test for script.wait during on_boot (issue #12043). + +This test verifies that script.wait works correctly when triggered from on_boot. +The issue was that ScriptWaitAction::setup() unconditionally disabled the loop, +even if play_complex() had already been called (from an on_boot trigger at the +same priority level) and enabled it. + +The race condition occurs because: +1. on_boot's default priority is 600.0 (setup_priority::DATA) +2. ScriptWaitAction's default setup priority is also DATA (600.0) +3. When they have the same priority, if on_boot runs first and triggers a script, + ScriptWaitAction::play_complex() enables the loop +4. Then ScriptWaitAction::setup() runs and unconditionally disables the loop +5. The wait never completes because the loop is disabled + +The fix adds a conditional check (like WaitUntilAction has) to only disable the +loop in setup() if num_running_ is 0. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_script_wait_on_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that script.wait works correctly when triggered from on_boot. + + This reproduces issue #12043 where script.wait would hang forever when + triggered from on_boot due to a race condition in ScriptWaitAction::setup(). + """ + test_complete = asyncio.Event() + + # Track progress through the boot sequence + boot_started = False + first_script_started = False + first_script_completed = False + first_wait_returned = False + second_script_started = False + second_script_completed = False + all_completed = False + + # Patterns for boot sequence logs + boot_start_pattern = re.compile(r"on_boot: Starting boot sequence") + show_start_pattern = re.compile(r"show_start_page: Starting") + show_complete_pattern = re.compile(r"show_start_page: Completed") + first_wait_pattern = re.compile(r"on_boot: First script completed") + flip_start_pattern = re.compile(r"flip_thru_pages: Starting") + flip_complete_pattern = re.compile(r"flip_thru_pages: Completed") + all_complete_pattern = re.compile(r"on_boot: All boot scripts completed") + + def check_output(line: str) -> None: + """Check log output for boot sequence progress.""" + nonlocal boot_started, first_script_started, first_script_completed + nonlocal first_wait_returned, second_script_started, second_script_completed + nonlocal all_completed + + if boot_start_pattern.search(line): + boot_started = True + elif show_start_pattern.search(line): + first_script_started = True + elif show_complete_pattern.search(line): + first_script_completed = True + elif first_wait_pattern.search(line): + first_wait_returned = True + elif flip_start_pattern.search(line): + second_script_started = True + elif flip_complete_pattern.search(line): + second_script_completed = True + elif all_complete_pattern.search(line): + all_completed = True + test_complete.set() + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-script-wait-on-boot" + + # Wait for on_boot sequence to complete + # The boot sequence should complete automatically + # Timeout is generous to allow for delays in the scripts + try: + await asyncio.wait_for(test_complete.wait(), timeout=5.0) + except TimeoutError: + # Build a detailed error message showing where the boot sequence got stuck + progress = [] + if boot_started: + progress.append("boot started") + if first_script_started: + progress.append("show_start_page started") + if first_script_completed: + progress.append("show_start_page completed") + if first_wait_returned: + progress.append("first script.wait returned") + if second_script_started: + progress.append("flip_thru_pages started") + if second_script_completed: + progress.append("flip_thru_pages completed") + + if not first_wait_returned and first_script_completed: + pytest.fail( + f"Test timed out - script.wait hung after show_start_page completed! " + f"This is the issue #12043 bug. Progress: {', '.join(progress)}" + ) + else: + pytest.fail( + f"Test timed out. Progress: {', '.join(progress) if progress else 'none'}" + ) + + # Verify the complete boot sequence executed in order + assert boot_started, "on_boot did not start" + assert first_script_started, "show_start_page did not start" + assert first_script_completed, "show_start_page did not complete" + assert first_wait_returned, "First script.wait did not return" + assert second_script_started, "flip_thru_pages did not start" + assert second_script_completed, "flip_thru_pages did not complete" + assert all_completed, "Boot sequence did not complete" diff --git a/tests/integration/test_sensor_timeout_filter.py b/tests/integration/test_sensor_timeout_filter.py new file mode 100644 index 0000000000..9b4704bb7b --- /dev/null +++ b/tests/integration/test_sensor_timeout_filter.py @@ -0,0 +1,185 @@ +"""Test sensor timeout filter functionality.""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import EntityState, SensorState +import pytest + +from .state_utils import InitialStateHelper, build_key_to_entity_mapping +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_sensor_timeout_filter( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test TimeoutFilter and TimeoutFilterConfigured with all modes.""" + loop = asyncio.get_running_loop() + + # Track state changes for all sensors + timeout_last_states: list[float] = [] + timeout_reset_states: list[float] = [] + timeout_static_states: list[float] = [] + timeout_lambda_states: list[float] = [] + + # Futures for each test scenario + test1_complete = loop.create_future() # TimeoutFilter - last mode + test2_complete = loop.create_future() # TimeoutFilter - reset behavior + test3_complete = loop.create_future() # TimeoutFilterConfigured - static value + test4_complete = loop.create_future() # TimeoutFilterConfigured - lambda + + def on_state(state: EntityState) -> None: + """Track sensor state updates.""" + if not isinstance(state, SensorState): + return + + if state.missing_state: + return + + sensor_name = key_to_sensor.get(state.key) + + # Test 1: TimeoutFilter - last mode + if sensor_name == "timeout_last_sensor": + timeout_last_states.append(state.state) + # Expect 2 values: initial 42.0 + timeout fires with 42.0 + if len(timeout_last_states) >= 2 and not test1_complete.done(): + test1_complete.set_result(True) + + # Test 2: TimeoutFilter - reset behavior + elif sensor_name == "timeout_reset_sensor": + timeout_reset_states.append(state.state) + # Expect 4 values: 10.0, 20.0, 30.0, then timeout fires with 30.0 + if len(timeout_reset_states) >= 4 and not test2_complete.done(): + test2_complete.set_result(True) + + # Test 3: TimeoutFilterConfigured - static value + elif sensor_name == "timeout_static_sensor": + timeout_static_states.append(state.state) + # Expect 2 values: initial 55.5 + timeout fires with 99.9 + if len(timeout_static_states) >= 2 and not test3_complete.done(): + test3_complete.set_result(True) + + # Test 4: TimeoutFilterConfigured - lambda + elif sensor_name == "timeout_lambda_sensor": + timeout_lambda_states.append(state.state) + # Expect 2 values: initial 77.7 + timeout fires with -1.0 + if len(timeout_lambda_states) >= 2 and not test4_complete.done(): + test4_complete.set_result(True) + + async with ( + run_compiled(yaml_config), + api_client_connected() as client, + ): + entities, services = await client.list_entities_services() + + key_to_sensor = build_key_to_entity_mapping( + entities, + [ + "timeout_last_sensor", + "timeout_reset_sensor", + "timeout_static_sensor", + "timeout_lambda_sensor", + ], + ) + + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Helper to find buttons by object_id substring + def find_button(object_id_substring: str) -> int: + """Find a button by object_id substring and return its key.""" + button = next( + (e for e in entities if object_id_substring in e.object_id.lower()), + None, + ) + assert button is not None, f"Button '{object_id_substring}' not found" + return button.key + + # Find all test buttons + test1_button_key = find_button("test_timeout_last_button") + test2_button_key = find_button("test_timeout_reset_button") + test3_button_key = find_button("test_timeout_static_button") + test4_button_key = find_button("test_timeout_lambda_button") + + # === Test 1: TimeoutFilter - last mode === + client.button_command(test1_button_key) + try: + await asyncio.wait_for(test1_complete, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 1 timeout. Received states: {timeout_last_states}") + + assert len(timeout_last_states) == 2, ( + f"Test 1: Should have 2 states, got {len(timeout_last_states)}: {timeout_last_states}" + ) + assert timeout_last_states[0] == pytest.approx(42.0), ( + f"Test 1: First state should be 42.0, got {timeout_last_states[0]}" + ) + assert timeout_last_states[1] == pytest.approx(42.0), ( + f"Test 1: Timeout should output last value (42.0), got {timeout_last_states[1]}" + ) + + # === Test 2: TimeoutFilter - reset behavior === + client.button_command(test2_button_key) + try: + await asyncio.wait_for(test2_complete, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 2 timeout. Received states: {timeout_reset_states}") + + assert len(timeout_reset_states) == 4, ( + f"Test 2: Should have 4 states, got {len(timeout_reset_states)}: {timeout_reset_states}" + ) + assert timeout_reset_states[0] == pytest.approx(10.0), ( + f"Test 2: First state should be 10.0, got {timeout_reset_states[0]}" + ) + assert timeout_reset_states[1] == pytest.approx(20.0), ( + f"Test 2: Second state should be 20.0, got {timeout_reset_states[1]}" + ) + assert timeout_reset_states[2] == pytest.approx(30.0), ( + f"Test 2: Third state should be 30.0, got {timeout_reset_states[2]}" + ) + assert timeout_reset_states[3] == pytest.approx(30.0), ( + f"Test 2: Timeout should output last value (30.0), got {timeout_reset_states[3]}" + ) + + # === Test 3: TimeoutFilterConfigured - static value === + client.button_command(test3_button_key) + try: + await asyncio.wait_for(test3_complete, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 3 timeout. Received states: {timeout_static_states}") + + assert len(timeout_static_states) == 2, ( + f"Test 3: Should have 2 states, got {len(timeout_static_states)}: {timeout_static_states}" + ) + assert timeout_static_states[0] == pytest.approx(55.5), ( + f"Test 3: First state should be 55.5, got {timeout_static_states[0]}" + ) + assert timeout_static_states[1] == pytest.approx(99.9), ( + f"Test 3: Timeout should output configured value (99.9), got {timeout_static_states[1]}" + ) + + # === Test 4: TimeoutFilterConfigured - lambda === + client.button_command(test4_button_key) + try: + await asyncio.wait_for(test4_complete, timeout=2.0) + except TimeoutError: + pytest.fail(f"Test 4 timeout. Received states: {timeout_lambda_states}") + + assert len(timeout_lambda_states) == 2, ( + f"Test 4: Should have 2 states, got {len(timeout_lambda_states)}: {timeout_lambda_states}" + ) + assert timeout_lambda_states[0] == pytest.approx(77.7), ( + f"Test 4: First state should be 77.7, got {timeout_lambda_states[0]}" + ) + assert timeout_lambda_states[1] == pytest.approx(-1.0), ( + f"Test 4: Timeout should evaluate lambda (-1.0), got {timeout_lambda_states[1]}" + ) diff --git a/tests/integration/test_template_alarm_control_panel_many_sensors.py b/tests/integration/test_template_alarm_control_panel_many_sensors.py new file mode 100644 index 0000000000..856815c731 --- /dev/null +++ b/tests/integration/test_template_alarm_control_panel_many_sensors.py @@ -0,0 +1,118 @@ +"""Integration test for template alarm control panel with many sensors.""" + +from __future__ import annotations + +import aioesphomeapi +from aioesphomeapi.model import APIIntEnum +import pytest + +from .state_utils import InitialStateHelper +from .types import APIClientConnectedFactory, RunCompiledFunction + + +class EspHomeACPFeatures(APIIntEnum): + """ESPHome AlarmControlPanel feature numbers.""" + + ARM_HOME = 1 + ARM_AWAY = 2 + ARM_NIGHT = 4 + TRIGGER = 8 + ARM_CUSTOM_BYPASS = 16 + ARM_VACATION = 32 + + +@pytest.mark.asyncio +async def test_template_alarm_control_panel_many_sensors( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test template alarm control panel with 10 binary sensors using FixedVector.""" + async with run_compiled(yaml_config), api_client_connected() as client: + # Get entity info first + entities, _ = await client.list_entities_services() + + # Find the alarm control panel and binary sensors + alarm_info: aioesphomeapi.AlarmControlPanelInfo | None = None + binary_sensors: list[aioesphomeapi.BinarySensorInfo] = [] + + for entity in entities: + if isinstance(entity, aioesphomeapi.AlarmControlPanelInfo): + alarm_info = entity + elif isinstance(entity, aioesphomeapi.BinarySensorInfo): + binary_sensors.append(entity) + + assert alarm_info is not None, "Alarm control panel entity info not found" + assert alarm_info.name == "Test Alarm" + assert alarm_info.requires_code is True + assert alarm_info.requires_code_to_arm is True + + # Verify we have 10 binary sensors + assert len(binary_sensors) == 10, ( + f"Expected 10 binary sensors, got {len(binary_sensors)}" + ) + + # Verify sensor names + expected_sensor_names = { + "Door 1", + "Door 2", + "Window 1", + "Window 2", + "Motion 1", + "Motion 2", + "Glass Break 1", + "Glass Break 2", + "Smoke Detector", + "CO Detector", + } + actual_sensor_names = {sensor.name for sensor in binary_sensors} + assert actual_sensor_names == expected_sensor_names, ( + f"Sensor names mismatch. Expected: {expected_sensor_names}, " + f"Got: {actual_sensor_names}" + ) + + # Use InitialStateHelper to wait for all initial states + state_helper = InitialStateHelper(entities) + + def on_state(state: aioesphomeapi.EntityState) -> None: + # We'll receive subsequent states here after initial states + pass + + client.subscribe_states(state_helper.on_state_wrapper(on_state)) + + # Wait for all initial states + await state_helper.wait_for_initial_states(timeout=5.0) + + # Verify the alarm state is disarmed initially + alarm_state = state_helper.initial_states.get(alarm_info.key) + assert alarm_state is not None, "Alarm control panel initial state not received" + assert isinstance(alarm_state, aioesphomeapi.AlarmControlPanelEntityState) + assert alarm_state.state == aioesphomeapi.AlarmControlPanelState.DISARMED, ( + f"Expected initial state DISARMED, got {alarm_state.state}" + ) + + # Verify all 10 binary sensors have initial states + binary_sensor_states = [ + state_helper.initial_states.get(sensor.key) for sensor in binary_sensors + ] + assert all(state is not None for state in binary_sensor_states), ( + "Not all binary sensors have initial states" + ) + + # Verify all binary sensor states are BinarySensorState type + for i, state in enumerate(binary_sensor_states): + assert isinstance(state, aioesphomeapi.BinarySensorState), ( + f"Binary sensor {i} state is not BinarySensorState: {type(state)}" + ) + + # Verify supported features + expected_features = ( + EspHomeACPFeatures.ARM_HOME + | EspHomeACPFeatures.ARM_AWAY + | EspHomeACPFeatures.ARM_NIGHT + | EspHomeACPFeatures.TRIGGER + ) + assert alarm_info.supported_features == expected_features, ( + f"Expected supported_features={expected_features} (ARM_HOME|ARM_AWAY|ARM_NIGHT|TRIGGER), " + f"got {alarm_info.supported_features}" + ) diff --git a/tests/integration/test_text_sensor_raw_state.py b/tests/integration/test_text_sensor_raw_state.py new file mode 100644 index 0000000000..a53ec8c963 --- /dev/null +++ b/tests/integration/test_text_sensor_raw_state.py @@ -0,0 +1,114 @@ +"""Integration test for TextSensor get_raw_state() functionality. + +This tests the optimization in PR #12205 where raw_state is only stored +when filters are configured. When no filters exist, get_raw_state() should +return state directly. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_text_sensor_raw_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that get_raw_state() works correctly with and without filters. + + Without filters: get_raw_state() should return the same value as state + With filters: get_raw_state() should return the original (unfiltered) value + """ + loop = asyncio.get_running_loop() + + # Futures to track log messages + no_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() + with_filter_future: asyncio.Future[tuple[str, str]] = loop.create_future() + + # Patterns to match log output + # NO_FILTER: state='hello world' raw_state='hello world' + no_filter_pattern = re.compile(r"NO_FILTER: state='([^']*)' raw_state='([^']*)'") + # WITH_FILTER: state='HELLO WORLD' raw_state='hello world' + with_filter_pattern = re.compile( + r"WITH_FILTER: state='([^']*)' raw_state='([^']*)'" + ) + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not no_filter_future.done(): + match = no_filter_pattern.search(line) + if match: + no_filter_future.set_result((match.group(1), match.group(2))) + + if not with_filter_future.done(): + match = with_filter_pattern.search(line) + if match: + with_filter_future.set_result((match.group(1), match.group(2))) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-text-sensor-raw-state" + + # Get entities to find our buttons + entities, _ = await client.list_entities_services() + + # Find the test buttons + no_filter_button = next( + (e for e in entities if "test_no_filter_button" in e.object_id.lower()), + None, + ) + assert no_filter_button is not None, "Test No Filter Button not found" + + with_filter_button = next( + (e for e in entities if "test_with_filter_button" in e.object_id.lower()), + None, + ) + assert with_filter_button is not None, "Test With Filter Button not found" + + # Test 1: Text sensor without filters + # get_raw_state() should return the same as state + client.button_command(no_filter_button.key) + + try: + state, raw_state = await asyncio.wait_for(no_filter_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for NO_FILTER log message") + + assert state == "hello world", f"Expected state='hello world', got '{state}'" + assert raw_state == "hello world", ( + f"Expected raw_state='hello world', got '{raw_state}'" + ) + assert state == raw_state, ( + f"Without filters, state and raw_state should be equal. " + f"state='{state}', raw_state='{raw_state}'" + ) + + # Test 2: Text sensor with to_upper filter + # state should be filtered (uppercase), raw_state should be original + client.button_command(with_filter_button.key) + + try: + state, raw_state = await asyncio.wait_for(with_filter_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for WITH_FILTER log message") + + assert state == "HELLO WORLD", f"Expected state='HELLO WORLD', got '{state}'" + assert raw_state == "hello world", ( + f"Expected raw_state='hello world', got '{raw_state}'" + ) + assert state != raw_state, ( + f"With filters, state and raw_state should differ. " + f"state='{state}', raw_state='{raw_state}'" + ) diff --git a/tests/integration/test_wait_until_on_boot.py b/tests/integration/test_wait_until_on_boot.py new file mode 100644 index 0000000000..b42c530c54 --- /dev/null +++ b/tests/integration/test_wait_until_on_boot.py @@ -0,0 +1,91 @@ +"""Integration test for wait_until in on_boot automation. + +This test validates that wait_until works correctly when triggered from on_boot, +which runs at the same setup priority as WaitUntilAction itself. This was broken +before the fix because WaitUntilAction::setup() would unconditionally disable_loop(), +even if play_complex() had already been called and enabled the loop. + +The bug: on_boot fires during StartupTrigger::setup(), which calls WaitUntilAction::play_complex() +before WaitUntilAction::setup() has run. Then when WaitUntilAction::setup() runs, it calls +disable_loop(), undoing the enable_loop() from play_complex(), causing wait_until to hang forever. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wait_until_on_boot( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that wait_until works in on_boot automation with a condition that becomes true later.""" + loop = asyncio.get_running_loop() + + on_boot_started = False + on_boot_completed = False + + on_boot_started_pattern = re.compile(r"on_boot: Starting wait_until test") + on_boot_complete_pattern = re.compile(r"on_boot: wait_until completed successfully") + + on_boot_started_future = loop.create_future() + on_boot_complete_future = loop.create_future() + + def check_output(line: str) -> None: + """Check log output for test progress.""" + nonlocal on_boot_started, on_boot_completed + + if on_boot_started_pattern.search(line): + on_boot_started = True + if not on_boot_started_future.done(): + on_boot_started_future.set_result(True) + + if on_boot_complete_pattern.search(line): + on_boot_completed = True + if not on_boot_complete_future.done(): + on_boot_complete_future.set_result(True) + + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Wait for on_boot to start + await asyncio.wait_for(on_boot_started_future, timeout=10.0) + assert on_boot_started, "on_boot did not start" + + # At this point, on_boot is blocked in wait_until waiting for test_flag to become true + # If the bug exists, wait_until's loop is disabled and it will never complete + # even after we set the flag + + # Give a moment for setup to complete + await asyncio.sleep(0.5) + + # Now set the flag that wait_until is waiting for + _, services = await client.list_entities_services() + set_flag_service = next( + (s for s in services if s.name == "set_test_flag"), None + ) + assert set_flag_service is not None, "set_test_flag service not found" + + client.execute_service(set_flag_service, {}) + + # If the fix works, wait_until's loop() will check the condition and proceed + # If the bug exists, wait_until is stuck with disabled loop and will timeout + try: + await asyncio.wait_for(on_boot_complete_future, timeout=2.0) + assert on_boot_completed, ( + "on_boot wait_until did not complete after flag was set" + ) + except TimeoutError: + pytest.fail( + "wait_until in on_boot did not complete within 2s after condition became true. " + "This indicates the bug where WaitUntilAction::setup() disables the loop " + "after play_complex() has already enabled it." + ) diff --git a/tests/integration/test_wait_until_ordering.py b/tests/integration/test_wait_until_ordering.py new file mode 100644 index 0000000000..7c39913e5a --- /dev/null +++ b/tests/integration/test_wait_until_ordering.py @@ -0,0 +1,90 @@ +"""Integration test for wait_until FIFO ordering. + +This test verifies that when multiple wait_until actions are queued, +they execute in FIFO (First In First Out) order, not LIFO. + +PR #7972 introduced a bug where emplace_front() was used, causing +LIFO ordering which is incorrect. +""" + +from __future__ import annotations + +import asyncio +import re + +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_wait_until_fifo_ordering( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that wait_until executes queued items in FIFO order. + + With the bug (using emplace_front), the order would be 4,3,2,1,0 (LIFO). + With the fix (using emplace_back), the order should be 0,1,2,3,4 (FIFO). + """ + test_complete = asyncio.Event() + + # Track completion order + completed_order = [] + + # Patterns to match + queuing_pattern = re.compile(r"Queueing iteration (\d+)") + completed_pattern = re.compile(r"Completed iteration (\d+)") + + def check_output(line: str) -> None: + """Check log output for completion order.""" + if test_complete.is_set(): + return + + if mo := queuing_pattern.search(line): + iteration = int(mo.group(1)) + + elif mo := completed_pattern.search(line): + iteration = int(mo.group(1)) + completed_order.append(iteration) + + # Test completes when all 5 have completed + if len(completed_order) == 5: + test_complete.set() + + # Run with log monitoring + async with ( + run_compiled(yaml_config, line_callback=check_output), + api_client_connected() as client, + ): + # Verify device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "test-wait-until-ordering" + + # Get services + _, services = await client.list_entities_services() + test_service = next( + (s for s in services if s.name == "test_wait_until_fifo"), None + ) + assert test_service is not None, "test_wait_until_fifo service not found" + + # Execute the test + client.execute_service(test_service, {}) + + # Wait for test to complete + try: + await asyncio.wait_for(test_complete.wait(), timeout=5.0) + except TimeoutError: + pytest.fail( + f"Test timed out. Completed order: {completed_order}. " + f"Expected 5 completions but got {len(completed_order)}." + ) + + # Verify FIFO order + expected_order = [0, 1, 2, 3, 4] + assert completed_order == expected_order, ( + f"Unexpected order: {completed_order}. " + f"Expected FIFO order: {expected_order}" + ) diff --git a/tests/test_build_components/build_components_base.esp32-c5-idf.yaml b/tests/test_build_components/build_components_base.esp32-c5-idf.yaml new file mode 100644 index 0000000000..6468297e9a --- /dev/null +++ b/tests/test_build_components/build_components_base.esp32-c5-idf.yaml @@ -0,0 +1,17 @@ +esphome: + name: componenttestesp32c5idf + friendly_name: $component_name + +esp32: + board: esp32-c5-devkitc-1 + framework: + type: esp-idf + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/build_components_base.rtl87xx-ard.yaml b/tests/test_build_components/build_components_base.rtl87xx-ard.yaml new file mode 100644 index 0000000000..1720ef700d --- /dev/null +++ b/tests/test_build_components/build_components_base.rtl87xx-ard.yaml @@ -0,0 +1,15 @@ +esphome: + name: componenttestesprtl87xx + friendly_name: $component_name + +rtl87xx: + board: generic-rtl8710bn-2mb-788k + +logger: + level: VERY_VERBOSE + +packages: + component_under_test: !include + file: $component_test_file + vars: + component_test_file: $component_test_file diff --git a/tests/test_build_components/common/remote_receiver/esp32-ard.yaml b/tests/test_build_components/common/remote_receiver/esp32-ard.yaml new file mode 100644 index 0000000000..af5c2f2409 --- /dev/null +++ b/tests/test_build_components/common/remote_receiver/esp32-ard.yaml @@ -0,0 +1,12 @@ +# Common remote_receiver configuration for ESP32 Arduino tests +# Provides a shared remote receiver that all components can use +# Components will auto-use this receiver if they don't specify receiver_id + +substitutions: + remote_receiver_pin: GPIO32 + +remote_receiver: + - id: rcvr + pin: ${remote_receiver_pin} + dump: all + tolerance: 25% diff --git a/tests/test_build_components/common/remote_receiver/esp32-c3-ard.yaml b/tests/test_build_components/common/remote_receiver/esp32-c3-ard.yaml new file mode 100644 index 0000000000..26b288b427 --- /dev/null +++ b/tests/test_build_components/common/remote_receiver/esp32-c3-ard.yaml @@ -0,0 +1,12 @@ +# Common remote_receiver configuration for ESP32-C3 Arduino tests +# Provides a shared remote receiver that all components can use +# Components will auto-use this receiver if they don't specify receiver_id + +substitutions: + remote_receiver_pin: GPIO10 + +remote_receiver: + - id: rcvr + pin: ${remote_receiver_pin} + dump: all + tolerance: 25% diff --git a/tests/unit_tests/core/test_entity_helpers.py b/tests/unit_tests/core/test_entity_helpers.py index 9ba5367413..01de0f27f9 100644 --- a/tests/unit_tests/core/test_entity_helpers.py +++ b/tests/unit_tests/core/test_entity_helpers.py @@ -27,8 +27,13 @@ from esphome.helpers import sanitize, snake_case from .common import load_config_from_fixture -# Pre-compiled regex pattern for extracting object IDs from expressions +# Pre-compiled regex patterns for extracting object IDs from expressions +# Matches both old format: .set_object_id("obj_id") +# and new format: .set_name_and_object_id("name", "obj_id") OBJECT_ID_PATTERN = re.compile(r'\.set_object_id\(["\'](.*?)["\']\)') +COMBINED_PATTERN = re.compile( + r'\.set_name_and_object_id\(["\'].*?["\']\s*,\s*["\'](.*?)["\']\)' +) FIXTURES_DIR = Path(__file__).parent.parent / "fixtures" / "core" / "entity_helpers" @@ -273,8 +278,10 @@ def setup_test_environment() -> Generator[list[str], None, None]: def extract_object_id_from_expressions(expressions: list[str]) -> str | None: """Extract the object ID that was set from the generated expressions.""" for expr in expressions: - # Look for set_object_id calls with regex to handle various formats - # Matches: var.set_object_id("temperature_2") or var.set_object_id('temperature_2') + # First try new combined format: .set_name_and_object_id("name", "obj_id") + if match := COMBINED_PATTERN.search(expr): + return match.group(1) + # Fall back to old format: .set_object_id("obj_id") if match := OBJECT_ID_PATTERN.search(expr): return match.group(1) return None diff --git a/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml b/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml index a479370f4b..773a124f25 100644 --- a/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/05-extend-remove.approved.yaml @@ -7,3 +7,33 @@ some_component: value: 2 - id: component2 value: 5 +lvgl: + pages: + - id: page1 + widgets: + - obj: + id: object1 + x: 3 + y: 2 + width: 4 + - obj: + id: object3 + x: 6 + y: 12 + widgets: + - obj: + id: object4 + x: 14 + y: 9 + width: 15 + height: 13 + - obj: + id: object5 + x: 10 + y: 11 + - obj: + id: + - Invalid ID + - obj: + id: + invalid: id diff --git a/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml b/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml index 2e0e60798d..e6d46d6dc4 100644 --- a/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/05-extend-remove.input.yaml @@ -13,6 +13,34 @@ packages: value: 5 - id: component3 value: 6 + - lvgl: + pages: + - id: page1 + widgets: + - obj: + id: object1 + x: 1 + y: 2 + - obj: + id: object2 + x: 5 + - obj: + id: object3 + x: 6 + y: 7 + widgets: + - obj: + id: object4 + x: 8 + y: 9 + - obj: + id: object5 + x: 10 + y: 11 + - obj: + id: ["Invalid ID"] + - obj: + id: {"invalid": "id"} some_component: - id: !extend ${A} @@ -20,3 +48,23 @@ some_component: - id: component2 value: 3 - id: !remove ${C} + +lvgl: + pages: + - id: !extend page1 + widgets: + - obj: + id: !extend object1 + x: 3 + width: 4 + - obj: + id: !remove object2 + - obj: + id: !extend object3 + y: 12 + height: 13 + widgets: + - obj: + id: !extend object4 + x: 14 + width: 15 diff --git a/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml b/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml new file mode 100644 index 0000000000..0fffbfb7cb --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-remote_packages.approved.yaml @@ -0,0 +1,30 @@ +substitutions: + x: 10 + y: 20 + z: 30 +values_from_repo1_main: + - package_name: package1 + x: 3 + y: 4 + z: 5 + volume: 60 + - package_name: package2 + x: 6 + y: 7 + z: 8 + volume: 336 + - package_name: default + x: 10 + y: 20 + z: 5 + volume: 1000 + - package_name: package4_from_repo2 + x: 9 + y: 10 + z: 11 + volume: 990 + - package_name: default + x: 10 + y: 20 + z: 5 + volume: 1000 diff --git a/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml b/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml new file mode 100644 index 0000000000..772860bf19 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-remote_packages.input.yaml @@ -0,0 +1,43 @@ +substitutions: + x: 10 + y: 20 + z: 30 +packages: + package1: + url: https://github.com/esphome/repo1 + files: + - path: file1.yaml + vars: + package_name: package1 + x: 3 + y: 4 + ref: main + package2: !include # a package that just includes the given remote package + file: remote_package_proxy.yaml + vars: + url: https://github.com/esphome/repo1 + ref: main + files: + - path: file1.yaml + vars: + package_name: package2 + x: 6 + y: 7 + z: 8 + package3: github://esphome/repo1/file1.yaml@main # a package that uses the shorthand syntax + package4: # include repo2, which itself includes repo1 + url: https://github.com/esphome/repo2 + files: + - path: file2.yaml + vars: + package_name: package4 + a: 9 + b: 10 + c: 11 + ref: main + package5: !include + file: remote_package_shorthand.yaml + vars: + repo: repo1 + file: file1.yaml + ref: main diff --git a/tests/unit_tests/fixtures/substitutions/remote_package_proxy.yaml b/tests/unit_tests/fixtures/substitutions/remote_package_proxy.yaml new file mode 100644 index 0000000000..05da30acb4 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remote_package_proxy.yaml @@ -0,0 +1,6 @@ +# acts as a proxy to be able to include a remote package +# in which the url/ref/files come from a substitution +packages: + - url: ${url} + ref: ${ref} + files: ${files} diff --git a/tests/unit_tests/fixtures/substitutions/remote_package_shorthand.yaml b/tests/unit_tests/fixtures/substitutions/remote_package_shorthand.yaml new file mode 100644 index 0000000000..f49e85e038 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remote_package_shorthand.yaml @@ -0,0 +1,4 @@ +# acts as a proxy to be able to include a remote package +# in which the shorthand comes from a substitution +packages: + - github://esphome/${repo}/${file}@${ref} diff --git a/tests/unit_tests/fixtures/substitutions/remotes/README.md b/tests/unit_tests/fixtures/substitutions/remotes/README.md new file mode 100644 index 0000000000..09d9f38699 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remotes/README.md @@ -0,0 +1,3 @@ +This folder contains fake repos for remote packages testing +These are used by `test_substitutions.py`. +To add repos, create a folder and add its path to the `REMOTES` constant in `test_substitutions.py`. diff --git a/tests/unit_tests/fixtures/substitutions/remotes/repo1/main/file1.yaml b/tests/unit_tests/fixtures/substitutions/remotes/repo1/main/file1.yaml new file mode 100644 index 0000000000..3830b1650f --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remotes/repo1/main/file1.yaml @@ -0,0 +1,9 @@ +defaults: + z: 5 + package_name: default +values_from_repo1_main: + - package_name: ${package_name} + x: ${x} + y: ${y} + z: ${z} + volume: ${x*y*z} diff --git a/tests/unit_tests/fixtures/substitutions/remotes/repo2/main/file2.yaml b/tests/unit_tests/fixtures/substitutions/remotes/repo2/main/file2.yaml new file mode 100644 index 0000000000..7f62ab8926 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/remotes/repo2/main/file2.yaml @@ -0,0 +1,10 @@ +packages: + - url: https://github.com/esphome/repo1 + ref: main + files: + - path: file1.yaml + vars: + package_name: ${package_name}_from_repo2 + x: ${a} + y: ${b} + z: ${c} diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py index 92b60efd93..e52cb24831 100644 --- a/tests/unit_tests/test_core.py +++ b/tests/unit_tests/test_core.py @@ -670,3 +670,51 @@ class TestEsphomeCore: os.environ.pop("ESPHOME_IS_HA_ADDON", None) os.environ.pop("ESPHOME_DATA_DIR", None) assert target.data_dir == Path(expected_default) + + def test_web_port__none(self, target): + """Test web_port returns None when web_server is not configured.""" + target.config = {} + assert target.web_port is None + + def test_web_port__explicit_web_server_default_port(self, target): + """Test web_port returns 80 when web_server is explicitly configured without port.""" + target.config = {const.CONF_WEB_SERVER: {}} + assert target.web_port == 80 + + def test_web_port__explicit_web_server_custom_port(self, target): + """Test web_port returns custom port when web_server is configured with port.""" + target.config = {const.CONF_WEB_SERVER: {const.CONF_PORT: 8080}} + assert target.web_port == 8080 + + def test_web_port__ota_web_server_platform_only(self, target): + """ + Test web_port returns None when ota.web_server platform is explicitly configured. + + This is a critical test for Dashboard Issue #766: + https://github.com/esphome/dashboard/issues/766 + + When ota: platform: web_server is explicitly configured (or auto-loaded by captive_portal): + - "web_server" appears in loaded_integrations (platform name added to integrations) + - "ota/web_server" appears in loaded_platforms + - But CONF_WEB_SERVER is NOT in config (only the platform is loaded, not the component) + - web_port MUST return None (no web UI available) + - Dashboard should NOT show VISIT button + + This test ensures web_port only checks CONF_WEB_SERVER in config, not loaded_integrations. + """ + # Simulate config with ota.web_server platform but no web_server component + # This happens when: + # 1. User explicitly configures: ota: - platform: web_server + # 2. OR captive_portal auto-loads ota.web_server + target.config = { + const.CONF_OTA: [ + { + "platform": "web_server", + # OTA web_server platform config would be here + } + ], + # Note: CONF_WEB_SERVER is NOT in config - only the OTA platform + } + # Even though "web_server" is in loaded_integrations due to the platform, + # web_port must return None because the full web_server component is not configured + assert target.web_port is None diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 9e5f399381..670d6c16fc 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -269,6 +269,16 @@ def mock_memory_analyzer_cli() -> Generator[Mock]: yield mock_class +@pytest.fixture +def mock_ram_strings_analyzer() -> Generator[Mock]: + """Mock RamStringsAnalyzer for testing.""" + with patch("esphome.analyze_memory.ram_strings.RamStringsAnalyzer") as mock_class: + mock_analyzer = MagicMock() + mock_analyzer.generate_report.return_value = "Mock RAM Strings Report" + mock_class.return_value = mock_analyzer + yield mock_class + + def test_choose_upload_log_host_with_string_default() -> None: """Test with a single string default device.""" setup_core() @@ -1166,6 +1176,56 @@ def test_upload_program_ota_with_mqtt_resolution( ) +def test_upload_program_ota_with_mqtt_empty_broker( + mock_mqtt_get_ip: Mock, + mock_is_ip_address: Mock, + mock_run_ota: Mock, + tmp_path: Path, + caplog: CaptureFixture, +) -> None: + """Test upload_program with OTA when MQTT broker is empty (issue #11653).""" + setup_core(address="192.168.1.50", platform=PLATFORM_ESP32, tmp_path=tmp_path) + + mock_is_ip_address.return_value = True + mock_mqtt_get_ip.side_effect = EsphomeError( + "Cannot discover IP via MQTT as the broker is not configured" + ) + mock_run_ota.return_value = (0, "192.168.1.50") + + config = { + CONF_OTA: [ + { + CONF_PLATFORM: CONF_ESPHOME, + CONF_PORT: 3232, + } + ], + CONF_MQTT: { + CONF_BROKER: "", + }, + CONF_MDNS: { + CONF_DISABLED: True, + }, + } + args = MockArgs(username="user", password="pass", client_id="client") + devices = ["MQTTIP", "192.168.1.50"] + + exit_code, host = upload_program(config, args, devices) + + assert exit_code == 0 + assert host == "192.168.1.50" + # Verify MQTT was attempted but failed gracefully + mock_mqtt_get_ip.assert_called_once_with(config, "user", "pass", "client") + # Verify we fell back to the IP address + expected_firmware = ( + tmp_path / ".esphome" / "build" / "test" / ".pioenvs" / "test" / "firmware.bin" + ) + mock_run_ota.assert_called_once_with( + ["192.168.1.50"], 3232, None, expected_firmware + ) + # Verify warning was logged + assert "MQTT IP discovery failed" in caplog.text + + @patch("esphome.__main__.importlib.import_module") def test_upload_program_platform_specific_handler( mock_import: Mock, @@ -2374,6 +2434,7 @@ def test_command_analyze_memory_success( mock_get_idedata: Mock, mock_get_esphome_components: Mock, mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, ) -> None: """Test command_analyze_memory with successful compilation and analysis.""" setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") @@ -2421,9 +2482,20 @@ def test_command_analyze_memory_success( mock_analyzer.analyze.assert_called_once() mock_analyzer.generate_report.assert_called_once() - # Verify report was printed + # Verify RAM strings analyzer was created and run + mock_ram_strings_analyzer.assert_called_once_with( + str(firmware_elf), + objdump_path="/path/to/objdump", + platform="esp32", + ) + mock_ram_analyzer = mock_ram_strings_analyzer.return_value + mock_ram_analyzer.analyze.assert_called_once() + mock_ram_analyzer.generate_report.assert_called_once() + + # Verify reports were printed captured = capfd.readouterr() assert "Mock Memory Report" in captured.out + assert "Mock RAM Strings Report" in captured.out def test_command_analyze_memory_with_external_components( @@ -2433,6 +2505,7 @@ def test_command_analyze_memory_with_external_components( mock_get_idedata: Mock, mock_get_esphome_components: Mock, mock_memory_analyzer_cli: Mock, + mock_ram_strings_analyzer: Mock, ) -> None: """Test command_analyze_memory detects external components.""" setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device") diff --git a/tests/unit_tests/test_mqtt.py b/tests/unit_tests/test_mqtt.py new file mode 100644 index 0000000000..4c2c34dff1 --- /dev/null +++ b/tests/unit_tests/test_mqtt.py @@ -0,0 +1,91 @@ +"""Unit tests for esphome.mqtt module.""" + +from __future__ import annotations + +import pytest + +from esphome.const import CONF_BROKER, CONF_ESPHOME, CONF_MQTT, CONF_NAME +from esphome.core import EsphomeError +from esphome.mqtt import get_esphome_device_ip + + +def test_get_esphome_device_ip_empty_broker() -> None: + """Test that get_esphome_device_ip raises EsphomeError when broker is empty.""" + config = { + CONF_MQTT: { + CONF_BROKER: "", + }, + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + with pytest.raises( + EsphomeError, + match="Cannot discover IP via MQTT as the broker is not configured", + ): + get_esphome_device_ip(config) + + +def test_get_esphome_device_ip_none_broker() -> None: + """Test that get_esphome_device_ip raises EsphomeError when broker is None.""" + config = { + CONF_MQTT: { + CONF_BROKER: None, + }, + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + with pytest.raises( + EsphomeError, + match="Cannot discover IP via MQTT as the broker is not configured", + ): + get_esphome_device_ip(config) + + +def test_get_esphome_device_ip_missing_mqtt() -> None: + """Test that get_esphome_device_ip raises EsphomeError when mqtt config is missing.""" + config = { + CONF_ESPHOME: { + CONF_NAME: "test-device", + }, + } + + with pytest.raises( + EsphomeError, + match="Cannot discover IP via MQTT as the config does not include the mqtt:", + ): + get_esphome_device_ip(config) + + +def test_get_esphome_device_ip_missing_esphome() -> None: + """Test that get_esphome_device_ip raises EsphomeError when esphome config is missing.""" + config = { + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + } + + with pytest.raises( + EsphomeError, + match="Cannot discover IP via MQTT as the config does not include the device name:", + ): + get_esphome_device_ip(config) + + +def test_get_esphome_device_ip_missing_name() -> None: + """Test that get_esphome_device_ip raises EsphomeError when device name is missing.""" + config = { + CONF_MQTT: { + CONF_BROKER: "mqtt.local", + }, + CONF_ESPHOME: {}, + } + + with pytest.raises( + EsphomeError, + match="Cannot discover IP via MQTT as the config does not include the device name:", + ): + get_esphome_device_ip(config) diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py index 13ef3516e4..4d7b635e59 100644 --- a/tests/unit_tests/test_platformio_api.py +++ b/tests/unit_tests/test_platformio_api.py @@ -1,6 +1,7 @@ """Tests for platformio_api.py path functions.""" import json +import logging import os from pathlib import Path import shutil @@ -670,3 +671,100 @@ def test_process_stacktrace_bad_alloc( assert "Memory allocation of 512 bytes failed at 40201234" in caplog.text mock_decode_pc.assert_called_once_with(config, "40201234") assert state is False + + +def test_platformio_log_filter_allows_non_platformio_messages() -> None: + """Test that non-platformio logger messages are allowed through.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name="esphome.core", + level=logging.INFO, + pathname="", + lineno=0, + msg="Some esphome message", + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is True + + +@pytest.mark.parametrize( + "msg", + [ + "Verbose mode can be enabled via `-v, --verbose` option", + "Found 5 compatible libraries", + "Found 123 compatible libraries", + "Building in release mode", + "Building in debug mode", + "Merged 2 ELF section", + "esptool.py v4.7.0", + "esptool v4.8.1", + "PLATFORM: espressif32 @ 6.4.0", + "Using cache: /path/to/cache", + "Package configuration completed successfully", + "Scanning dependencies...", + "Installing dependencies", + "Library Manager: Already installed, built-in library", + "Memory Usage -> https://bit.ly/pio-memory-usage", + ], +) +def test_platformio_log_filter_blocks_noisy_messages(msg: str) -> None: + """Test that noisy platformio messages are filtered out.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name="platformio.builder", + level=logging.INFO, + pathname="", + lineno=0, + msg=msg, + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is False + + +@pytest.mark.parametrize( + "msg", + [ + "Compiling .pio/build/test/src/main.cpp.o", + "Linking .pio/build/test/firmware.elf", + "Error: something went wrong", + "warning: unused variable", + ], +) +def test_platformio_log_filter_allows_other_platformio_messages(msg: str) -> None: + """Test that non-noisy platformio messages are allowed through.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name="platformio.builder", + level=logging.INFO, + pathname="", + lineno=0, + msg=msg, + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is True + + +@pytest.mark.parametrize( + "logger_name", + [ + "PLATFORMIO.builder", + "PlatformIO.core", + "platformio.run", + ], +) +def test_platformio_log_filter_case_insensitive_logger_name(logger_name: str) -> None: + """Test that platformio logger name matching is case insensitive.""" + log_filter = platformio_api.PlatformioLogFilter() + record = logging.LogRecord( + name=logger_name, + level=logging.INFO, + pathname="", + lineno=0, + msg="Found 5 compatible libraries", + args=(), + exc_info=None, + ) + assert log_filter.filter(record) is False diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index 7d50b44506..c5e6618ea6 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -2,6 +2,7 @@ import glob import logging from pathlib import Path from typing import Any +from unittest.mock import patch from esphome import config as config_module, yaml_util from esphome.components import substitutions @@ -84,11 +85,41 @@ def verify_database(value: Any, path: str = "") -> str | None: return None -def test_substitutions_fixtures(fixture_path): +# Mapping of (url, ref) to local test repository path under fixtures/substitutions +REMOTES = { + ("https://github.com/esphome/repo1", "main"): "remotes/repo1/main", + ("https://github.com/esphome/repo2", "main"): "remotes/repo2/main", +} + + +@patch("esphome.git.clone_or_update") +def test_substitutions_fixtures(mock_clone_or_update, fixture_path): base_dir = fixture_path / "substitutions" sources = sorted(glob.glob(str(base_dir / "*.input.yaml"))) assert sources, f"No input YAML files found in {base_dir}" + def fake_clone_or_update( + *, + url: str, + ref: str | None = None, + refresh=None, + domain: str, + username: str | None = None, + password: str | None = None, + submodules: list[str] | None = None, + _recover_broken: bool = True, + ) -> tuple[Path, None]: + path = REMOTES.get((url, ref)) + if path is None: + path = REMOTES.get((url.rstrip(".git"), ref)) + if path is None: + raise RuntimeError( + f"Cannot find test repository for {url} @ {ref}. Check the REMOTES mapping in test_substitutions.py" + ) + return base_dir / path, None + + mock_clone_or_update.side_effect = fake_clone_or_update + failures = [] for source_path in sources: source_path = Path(source_path) diff --git a/tests/unit_tests/test_writer.py b/tests/unit_tests/test_writer.py index a4490fbbc0..9fa60c06ec 100644 --- a/tests/unit_tests/test_writer.py +++ b/tests/unit_tests/test_writer.py @@ -1,7 +1,9 @@ """Test writer module functionality.""" from collections.abc import Callable +import os from pathlib import Path +import stat from typing import Any from unittest.mock import MagicMock, patch @@ -15,6 +17,7 @@ from esphome.writer import ( CPP_INCLUDE_BEGIN, CPP_INCLUDE_END, GITIGNORE_CONTENT, + clean_all, clean_build, clean_cmake_cache, storage_should_clean, @@ -737,6 +740,37 @@ def test_write_cpp_with_duplicate_markers( write_cpp("// New code") +@patch("esphome.writer.CORE") +def test_clean_all_with_yaml_file( + mock_core: MagicMock, + tmp_path: Path, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test clean_all with a .yaml file uses parent directory.""" + # Create config directory with yaml file + config_dir = tmp_path / "config" + config_dir.mkdir() + yaml_file = config_dir / "test.yaml" + yaml_file.write_text("esphome:\n name: test\n") + + build_dir = config_dir / ".esphome" + build_dir.mkdir() + (build_dir / "dummy.txt").write_text("x") + + from esphome.writer import clean_all + + with caplog.at_level("INFO"): + clean_all([str(yaml_file)]) + + # Verify .esphome directory still exists but contents cleaned + assert build_dir.exists() + assert not (build_dir / "dummy.txt").exists() + + # Verify logging mentions the build dir + assert "Cleaning" in caplog.text + assert str(build_dir) in caplog.text + + @patch("esphome.writer.CORE") def test_clean_all( mock_core: MagicMock, @@ -1031,3 +1065,103 @@ def test_clean_all_preserves_json_files( # Verify logging mentions cleaning assert "Cleaning" in caplog.text assert str(build_dir) in caplog.text + + +@patch("esphome.writer.CORE") +def test_clean_build_handles_readonly_files( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_build handles read-only files (e.g., git pack files on Windows).""" + # Create directory structure with read-only files + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + git_dir = pioenvs_dir / ".git" / "objects" / "pack" + git_dir.mkdir(parents=True) + + # Create a read-only file (simulating git pack files on Windows) + readonly_file = git_dir / "pack-abc123.pack" + readonly_file.write_text("pack data") + os.chmod(readonly_file, stat.S_IRUSR) # Read-only + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = pioenvs_dir + mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" + mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock" + + # Verify file is read-only + assert not os.access(readonly_file, os.W_OK) + + # Call the function - should not crash + clean_build() + + # Verify directory was removed despite read-only files + assert not pioenvs_dir.exists() + + +@patch("esphome.writer.CORE") +def test_clean_all_handles_readonly_files( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_all handles read-only files.""" + # Create config directory + config_dir = tmp_path / "config" + config_dir.mkdir() + + build_dir = config_dir / ".esphome" + build_dir.mkdir() + + # Create a subdirectory with read-only files + subdir = build_dir / "subdir" + subdir.mkdir() + readonly_file = subdir / "readonly.txt" + readonly_file.write_text("content") + os.chmod(readonly_file, stat.S_IRUSR) # Read-only + + # Verify file is read-only + assert not os.access(readonly_file, os.W_OK) + + # Call the function - should not crash + clean_all([str(config_dir)]) + + # Verify directory was removed despite read-only files + assert not subdir.exists() + assert build_dir.exists() # .esphome dir itself is preserved + + +@patch("esphome.writer.CORE") +def test_clean_build_reraises_for_other_errors( + mock_core: MagicMock, + tmp_path: Path, +) -> None: + """Test clean_build re-raises errors that are not read-only permission issues.""" + # Create directory structure with a read-only subdirectory + # This prevents file deletion and triggers the error handler + pioenvs_dir = tmp_path / ".pioenvs" + pioenvs_dir.mkdir() + subdir = pioenvs_dir / "subdir" + subdir.mkdir() + test_file = subdir / "test.txt" + test_file.write_text("content") + + # Make subdir read-only so files inside can't be deleted + os.chmod(subdir, stat.S_IRUSR | stat.S_IXUSR) + + # Setup mocks + mock_core.relative_pioenvs_path.return_value = pioenvs_dir + mock_core.relative_piolibdeps_path.return_value = tmp_path / ".piolibdeps" + mock_core.relative_build_path.return_value = tmp_path / "dependencies.lock" + + try: + # Mock os.access in writer module to return True (writable) + # This simulates a case where the error is NOT due to read-only permissions + # so the error handler should re-raise instead of trying to fix permissions + with ( + patch("esphome.writer.os.access", return_value=True), + pytest.raises(PermissionError), + ): + clean_build() + finally: + # Cleanup - restore write permission so tmp_path cleanup works + os.chmod(subdir, stat.S_IRWXU)