diff --git a/.ai/instructions.md b/.ai/instructions.md index 681829bae6..994d517f75 100644 --- a/.ai/instructions.md +++ b/.ai/instructions.md @@ -276,12 +276,12 @@ This document provides essential context for AI models interacting with this pro ## 7. Specific Instructions for AI Collaboration * **Contribution Workflow (Pull Request Process):** - 1. **Fork & Branch:** Create a new branch in your fork. + 1. **Fork & Branch:** Create a new branch based on the `dev` branch (always use `git checkout -b dev` to ensure you're branching from `dev`, not the currently checked out branch). 2. **Make Changes:** Adhere to all coding conventions and patterns. 3. **Test:** Create component tests for all supported platforms and run the full test suite locally. 4. **Lint:** Run `pre-commit` to ensure code is compliant. 5. **Commit:** Commit your changes. There is no strict format for commit messages. - 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made with the PULL_REQUEST_TEMPLATE.md template filled out correctly. + 6. **Pull Request:** Submit a PR against the `dev` branch. The Pull Request title should have a prefix of the component being worked on (e.g., `[display] Fix bug`, `[abc123] Add new component`). Update documentation, examples, and add `CODEOWNERS` entries as needed. Pull requests should always be made using the `.github/PULL_REQUEST_TEMPLATE.md` template - fill out all sections completely without removing any parts of the template. * **Documentation Contributions:** * Documentation is hosted in the separate `esphome/esphome-docs` repository. @@ -402,35 +402,45 @@ This document provides essential context for AI models interacting with this pro _use_feature = True ``` - **Good Pattern (CORE.data with Helpers):** + **Bad Pattern (Flat Keys):** ```python + # Don't do this - keys should be namespaced under component domain + MY_FEATURE_KEY = "my_component_feature" + CORE.data[MY_FEATURE_KEY] = True + ``` + + **Good Pattern (dataclass):** + ```python + from dataclasses import dataclass, field from esphome.core import CORE - # Keys for CORE.data storage - COMPONENT_STATE_KEY = "my_component_state" - USE_FEATURE_KEY = "my_component_use_feature" + DOMAIN = "my_component" - def _get_component_state() -> list: - """Get component state from CORE.data.""" - return CORE.data.setdefault(COMPONENT_STATE_KEY, []) + @dataclass + class MyComponentData: + feature_enabled: bool = False + item_count: int = 0 + items: list[str] = field(default_factory=list) - def _get_use_feature() -> bool | None: - """Get feature flag from CORE.data.""" - return CORE.data.get(USE_FEATURE_KEY) + def _get_data() -> MyComponentData: + if DOMAIN not in CORE.data: + CORE.data[DOMAIN] = MyComponentData() + return CORE.data[DOMAIN] - def _set_use_feature(value: bool) -> None: - """Set feature flag in CORE.data.""" - CORE.data[USE_FEATURE_KEY] = value + def request_feature() -> None: + _get_data().feature_enabled = True - def enable_feature(): - _set_use_feature(True) + def add_item(item: str) -> None: + _get_data().items.append(item) ``` + If you need a real-world example, search for components that use `@dataclass` with `CORE.data` in the codebase. Note: Some components may use `TypedDict` for dictionary-based storage; both patterns are acceptable depending on your needs. + **Why this matters:** - Module-level globals persist between compilation runs if the dashboard doesn't fork/exec - `CORE.data` automatically clears between runs - - Typed helper functions provide better IDE support and maintainability - - Encapsulation makes state management explicit and testable + - Namespacing under `DOMAIN` prevents key collisions between components + - `@dataclass` provides type safety and cleaner attribute access * **Security:** Be mindful of security when making changes to the API, web server, or any other network-related code. Do not hardcode secrets or keys. diff --git a/.clang-tidy.hash b/.clang-tidy.hash index 3ade00f0cd..a3322ba731 100644 --- a/.clang-tidy.hash +++ b/.clang-tidy.hash @@ -1 +1 @@ -3d46b63015d761c85ca9cb77ab79a389509e5776701fb22aed16e7b79d432c0c +766420905c06eeb6c5f360f68fd965e5ddd9c4a5db6b823263d3ad3accb64a07 diff --git a/.github/actions/restore-python/action.yml b/.github/actions/restore-python/action.yml index c4ac3d1a9e..75586fd854 100644 --- a/.github/actions/restore-python/action.yml +++ b/.github/actions/restore-python/action.yml @@ -22,7 +22,7 @@ runs: python-version: ${{ inputs.python-version }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: venv # yamllint disable-line rule:line-length diff --git a/.github/workflows/auto-label-pr.yml b/.github/workflows/auto-label-pr.yml index 39164fc2ea..8e96297cc0 100644 --- a/.github/workflows/auto-label-pr.yml +++ b/.github/workflows/auto-label-pr.yml @@ -26,7 +26,7 @@ jobs: - name: Generate a token id: generate-token - uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2 + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 with: app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} diff --git a/.github/workflows/ci-api-proto.yml b/.github/workflows/ci-api-proto.yml index a0c6568345..4c4bbf9981 100644 --- a/.github/workflows/ci-api-proto.yml +++ b/.github/workflows/ci-api-proto.yml @@ -62,7 +62,7 @@ jobs: run: git diff - if: failure() name: Archive artifacts - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: generated-proto-files path: | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ef6b4341c..434aa388f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: venv # yamllint disable-line rule:line-length @@ -152,12 +152,12 @@ jobs: . venv/bin/activate pytest -vv --cov-report=xml --tb=native -n auto tests --ignore=tests/integration/ - name: Upload coverage to Codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 with: token: ${{ secrets.CODECOV_TOKEN }} - name: Save Python virtual environment cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: venv key: ${{ runner.os }}-${{ steps.restore-python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -193,7 +193,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Restore components graph cache - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -223,7 +223,7 @@ jobs: echo "component-test-batches=$(echo "$output" | jq -c '.component_test_batches')" >> $GITHUB_OUTPUT - name: Save components graph cache if: github.ref == 'refs/heads/dev' - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: .temp/components_graph.json key: components-graph-${{ hashFiles('esphome/components/**/*.py') }} @@ -245,7 +245,7 @@ jobs: python-version: "3.13" - name: Restore Python virtual environment id: cache-venv - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-venv-${{ needs.common.outputs.cache-key }} @@ -334,14 +334,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-${{ matrix.pio_cache_key }}-${{ hashFiles('platformio.ini') }} @@ -413,14 +413,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -502,14 +502,14 @@ jobs: - name: Cache platformio if: github.ref == 'refs/heads/dev' - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} - name: Cache platformio if: github.ref != 'refs/heads/dev' - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-tidyesp32-${{ hashFiles('platformio.ini') }} @@ -668,7 +668,7 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - uses: esphome/action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache + - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache env: SKIP: pylint,clang-tidy-hash - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 @@ -735,7 +735,7 @@ jobs: - name: Restore cached memory analysis id: cache-memory-analysis if: steps.check-script.outputs.skip != 'true' - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -759,7 +759,7 @@ jobs: - name: Cache platformio if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -800,7 +800,7 @@ jobs: - name: Save memory analysis to cache if: steps.check-script.outputs.skip != 'true' && steps.cache-memory-analysis.outputs.cache-hit != 'true' && steps.build.outcome == 'success' - uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/save@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: memory-analysis-target.json key: ${{ steps.cache-key.outputs.cache-key }} @@ -821,7 +821,7 @@ jobs: fi - name: Upload memory analysis JSON - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: memory-analysis-target path: memory-analysis-target.json @@ -847,7 +847,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Cache platformio - uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + uses: actions/cache/restore@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1 with: path: ~/.platformio key: platformio-memory-${{ fromJSON(needs.determine-jobs.outputs.memory_impact).platform }}-${{ hashFiles('platformio.ini') }} @@ -885,7 +885,7 @@ jobs: --platform "$platform" - name: Upload memory analysis JSON - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: memory-analysis-pr path: memory-analysis-pr.json @@ -915,13 +915,13 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - name: Download target analysis JSON - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: memory-analysis-target path: ./memory-analysis continue-on-error: true - name: Download PR analysis JSON - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: name: memory-analysis-pr path: ./memory-analysis @@ -959,13 +959,13 @@ jobs: - memory-impact-comment if: always() steps: - - name: Success - if: ${{ !(contains(needs.*.result, 'failure')) }} - run: exit 0 - - name: Failure - if: ${{ contains(needs.*.result, 'failure') }} + - name: Check job results env: - JSON_DOC: ${{ toJSON(needs) }} + NEEDS_JSON: ${{ toJSON(needs) }} run: | - echo $JSON_DOC | jq - exit 1 + # memory-impact-target-branch is allowed to fail without blocking CI. + # This job builds the target branch (dev/beta/release) which may fail because: + # 1. The target branch has a build issue independent of this PR + # 2. This PR fixes a build issue on the target branch + # In either case, we only care that the PR branch builds successfully. + echo "$NEEDS_JSON" | jq -e 'del(.["memory-impact-target-branch"]) | all(.result != "failure")' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index d9b6bcdcca..f917ecd8f8 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -58,7 +58,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 + uses: github/codeql-action/init@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 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@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6 + uses: github/codeql-action/analyze@1b168cd39490f61582a9beae412bb7057a6b2c4e # v4.31.8 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d52595bbb3..10194aa599 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -138,7 +138,7 @@ jobs: # version: ${{ needs.init.outputs.tag }} - name: Upload digests - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: digests-${{ matrix.platform.arch }} path: /tmp/digests @@ -171,7 +171,7 @@ jobs: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Download digests - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7.0.0 with: pattern: digests-* path: /tmp/digests @@ -219,10 +219,19 @@ jobs: - init - deploy-manifest steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: home-assistant-addon + - name: Trigger Workflow uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: | let description = "ESPHome"; if (context.eventName == "release") { @@ -245,10 +254,19 @@ jobs: needs: [init] environment: ${{ needs.init.outputs.deploy_env }} steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: esphome-schema + - name: Trigger Workflow uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ secrets.DEPLOY_ESPHOME_SCHEMA_REPO_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: | github.rest.actions.createWorkflowDispatch({ owner: "esphome", @@ -259,3 +277,34 @@ jobs: version: "${{ needs.init.outputs.tag }}", } }) + + version-notifier: + if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' + runs-on: ubuntu-latest + needs: + - init + - deploy-manifest + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: version-notifier + + - name: Trigger Workflow + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + github.rest.actions.createWorkflowDispatch({ + owner: "esphome", + repo: "version-notifier", + workflow_id: "notify.yml", + ref: "main", + inputs: { + version: "${{ needs.init.outputs.tag }}", + } + }) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5843b3a5e0..7e03e2a5f9 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Stale - uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 + uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 with: debug-only: ${{ github.ref != 'refs/heads/dev' }} # Dry-run when not run on dev branch remove-stale-when-updated: true diff --git a/.github/workflows/sync-device-classes.yml b/.github/workflows/sync-device-classes.yml index ea81a1e013..8c830d99c7 100644 --- a/.github/workflows/sync-device-classes.yml +++ b/.github/workflows/sync-device-classes.yml @@ -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@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: commit-message: "Synchronise Device Classes from Home Assistant" committer: esphomebot diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 412a678d02..2f5076a6e6 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.7 + rev: v0.14.9 hooks: # Run the linter. - id: ruff diff --git a/CODEOWNERS b/CODEOWNERS index 7861871323..fc27253d23 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -97,6 +97,7 @@ esphome/components/camera_encoder/* @DT-art1 esphome/components/canbus/* @danielschramm @mvturnho esphome/components/cap1188/* @mreditor97 esphome/components/captive_portal/* @esphome/core +esphome/components/cc1101/* @gabest11 @lygris esphome/components/ccs811/* @habbie esphome/components/cd74hc4067/* @asoehlke esphome/components/ch422g/* @clydebarrow @jesterret @@ -190,6 +191,7 @@ esphome/components/gps/* @coogle @ximex esphome/components/graph/* @synco esphome/components/graphical_display_menu/* @MrMDavidson esphome/components/gree/* @orestismers +esphome/components/gree/switch/* @nagyrobi esphome/components/grove_gas_mc_v2/* @YorkshireIoT esphome/components/grove_tb6612fng/* @max246 esphome/components/growatt_solar/* @leeuwte @@ -210,8 +212,10 @@ esphome/components/he60r/* @clydebarrow esphome/components/heatpumpir/* @rob-deutsch esphome/components/hitachi_ac424/* @sourabhjaiswal esphome/components/hlk_fm22x/* @OnFreund +esphome/components/hlw8032/* @rici4kubicek esphome/components/hm3301/* @freekode esphome/components/hmac_md5/* @dwmw2 +esphome/components/hmac_sha256/* @dwmw2 esphome/components/homeassistant/* @esphome/core @OttoWinter esphome/components/homeassistant/number/* @landonr esphome/components/homeassistant/switch/* @Links2004 @@ -225,6 +229,7 @@ esphome/components/hte501/* @Stock-M esphome/components/http_request/ota/* @oarcher esphome/components/http_request/update/* @jesserockz esphome/components/htu31d/* @betterengineering +esphome/components/hub75/* @stuartparmenter esphome/components/hydreon_rgxx/* @functionpointer esphome/components/hyt271/* @Philippe12 esphome/components/i2c/* @esphome/core @@ -304,7 +309,7 @@ esphome/components/md5/* @esphome/core esphome/components/mdns/* @esphome/core esphome/components/media_player/* @jesserockz esphome/components/micro_wake_word/* @jesserockz @kahrendt -esphome/components/micronova/* @jorre05 +esphome/components/micronova/* @edenhaus @jorre05 esphome/components/microphone/* @jesserockz @kahrendt esphome/components/mics_4514/* @jesserockz esphome/components/midea/* @dudanov @@ -520,6 +525,7 @@ esphome/components/ufire_ise/* @pvizeli esphome/components/ultrasonic/* @OttoWinter esphome/components/update/* @jesserockz esphome/components/uponor_smatrix/* @kroimon +esphome/components/usb_cdc_acm/* @kbx81 esphome/components/usb_host/* @clydebarrow esphome/components/usb_uart/* @clydebarrow esphome/components/valve/* @esphome/core diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 303b548310..66ad3ed599 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ We welcome contributions to the ESPHome suite of code and documentation! -Please read our [contributing guide](https://esphome.io/guides/contributing.html) if you wish to contribute to the +Please read our [contributing guide](https://developers.esphome.io/contributing/code/) if you wish to contribute to the project and be sure to join us on [Discord](https://discord.gg/KhAMKrd). **See also:** diff --git a/Doxyfile b/Doxyfile index a19120b9da..503979b61e 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.12.0-dev +PROJECT_NUMBER = 2026.1.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/README.md b/README.md index 0439b1bc06..b8ce8d091d 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ - - ESPHome Logo + + ESPHome Logo diff --git a/esphome/components/absolute_humidity/absolute_humidity.cpp b/esphome/components/absolute_humidity/absolute_humidity.cpp index d16a024d86..74d675b80b 100644 --- a/esphome/components/absolute_humidity/absolute_humidity.cpp +++ b/esphome/components/absolute_humidity/absolute_humidity.cpp @@ -163,7 +163,7 @@ float AbsoluteHumidityComponent::es_wobus(float t) { } // From https://www.environmentalbiophysics.org/chalk-talk-how-to-calculate-absolute-humidity/ -// H/T to https://esphome.io/cookbook/bme280_environment.html +// H/T to https://esphome.io/cookbook/bme280_environment/ // H/T to https://carnotcycle.wordpress.com/2012/08/04/how-to-convert-relative-humidity-to-absolute-humidity/ float AbsoluteHumidityComponent::vapor_density(float es, float hr, float ta) { // es = saturated vapor pressure (kPa) diff --git a/esphome/components/adc/__init__.py b/esphome/components/adc/__init__.py index 15dc447b6c..96c8334a6d 100644 --- a/esphome/components/adc/__init__.py +++ b/esphome/components/adc/__init__.py @@ -1,15 +1,17 @@ from esphome import pins import esphome.codegen as cg -from esphome.components.esp32 import VARIANT_ESP32P4, get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import CONF_ANALOG, CONF_INPUT, CONF_NUMBER, PLATFORM_ESP8266 @@ -99,6 +101,13 @@ ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { 5: adc_channel_t.ADC_CHANNEL_5, 6: adc_channel_t.ADC_CHANNEL_6, }, + # https://docs.espressif.com/projects/esp-idf/en/latest/esp32c61/api-reference/peripherals/gpio.html + VARIANT_ESP32C61: { + 1: adc_channel_t.ADC_CHANNEL_0, + 3: adc_channel_t.ADC_CHANNEL_1, + 4: adc_channel_t.ADC_CHANNEL_2, + 5: adc_channel_t.ADC_CHANNEL_3, + }, # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32h2/include/soc/adc_channel.h VARIANT_ESP32H2: { 1: adc_channel_t.ADC_CHANNEL_0, @@ -107,6 +116,17 @@ ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { 4: adc_channel_t.ADC_CHANNEL_3, 5: adc_channel_t.ADC_CHANNEL_4, }, + # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32p4/include/soc/adc_channel.h + VARIANT_ESP32P4: { + 16: adc_channel_t.ADC_CHANNEL_0, + 17: adc_channel_t.ADC_CHANNEL_1, + 18: adc_channel_t.ADC_CHANNEL_2, + 19: adc_channel_t.ADC_CHANNEL_3, + 20: adc_channel_t.ADC_CHANNEL_4, + 21: adc_channel_t.ADC_CHANNEL_5, + 22: adc_channel_t.ADC_CHANNEL_6, + 23: adc_channel_t.ADC_CHANNEL_7, + }, # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s2/include/soc/adc_channel.h VARIANT_ESP32S2: { 1: adc_channel_t.ADC_CHANNEL_0, @@ -133,16 +153,6 @@ ESP32_VARIANT_ADC1_PIN_TO_CHANNEL = { 9: adc_channel_t.ADC_CHANNEL_8, 10: adc_channel_t.ADC_CHANNEL_9, }, - VARIANT_ESP32P4: { - 16: adc_channel_t.ADC_CHANNEL_0, - 17: adc_channel_t.ADC_CHANNEL_1, - 18: adc_channel_t.ADC_CHANNEL_2, - 19: adc_channel_t.ADC_CHANNEL_3, - 20: adc_channel_t.ADC_CHANNEL_4, - 21: adc_channel_t.ADC_CHANNEL_5, - 22: adc_channel_t.ADC_CHANNEL_6, - 23: adc_channel_t.ADC_CHANNEL_7, - }, } # pin to adc2 channel mapping @@ -173,8 +183,19 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { VARIANT_ESP32C5: {}, # no ADC2 # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32c6/include/soc/adc_channel.h VARIANT_ESP32C6: {}, # no ADC2 + # ESP32-C61 has no ADC2 + VARIANT_ESP32C61: {}, # no ADC2 # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32h2/include/soc/adc_channel.h VARIANT_ESP32H2: {}, # no ADC2 + # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32p4/include/soc/adc_channel.h + VARIANT_ESP32P4: { + 49: adc_channel_t.ADC_CHANNEL_0, + 50: adc_channel_t.ADC_CHANNEL_1, + 51: adc_channel_t.ADC_CHANNEL_2, + 52: adc_channel_t.ADC_CHANNEL_3, + 53: adc_channel_t.ADC_CHANNEL_4, + 54: adc_channel_t.ADC_CHANNEL_5, + }, # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32s2/include/soc/adc_channel.h VARIANT_ESP32S2: { 11: adc_channel_t.ADC_CHANNEL_0, @@ -201,14 +222,6 @@ ESP32_VARIANT_ADC2_PIN_TO_CHANNEL = { 19: adc_channel_t.ADC_CHANNEL_8, 20: adc_channel_t.ADC_CHANNEL_9, }, - VARIANT_ESP32P4: { - 49: adc_channel_t.ADC_CHANNEL_0, - 50: adc_channel_t.ADC_CHANNEL_1, - 51: adc_channel_t.ADC_CHANNEL_2, - 52: adc_channel_t.ADC_CHANNEL_3, - 53: adc_channel_t.ADC_CHANNEL_4, - 54: adc_channel_t.ADC_CHANNEL_5, - }, } diff --git a/esphome/components/adc/adc_sensor_esp32.cpp b/esphome/components/adc/adc_sensor_esp32.cpp index ab6a89fce0..120cb1c926 100644 --- a/esphome/components/adc/adc_sensor_esp32.cpp +++ b/esphome/components/adc/adc_sensor_esp32.cpp @@ -42,10 +42,11 @@ void ADCSensor::setup() { adc_oneshot_unit_init_cfg_t init_config = {}; // Zero initialize init_config.unit_id = this->adc_unit_; init_config.ulp_mode = ADC_ULP_MODE_DISABLE; -#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || USE_ESP32_VARIANT_ESP32H2 +#if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 init_config.clk_src = ADC_DIGI_CLK_SRC_DEFAULT; #endif // USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || - // USE_ESP32_VARIANT_ESP32H2 + // USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 esp_err_t err = adc_oneshot_new_unit(&init_config, &ADCSensor::shared_adc_handles[this->adc_unit_]); if (err != ESP_OK) { ESP_LOGE(TAG, "Error initializing %s: %d", LOG_STR_ARG(adc_unit_to_str(this->adc_unit_)), err); @@ -74,7 +75,7 @@ void ADCSensor::setup() { adc_cali_handle_t handle = nullptr; #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 // RISC-V variants and S3 use curve fitting calibration adc_cali_curve_fitting_config_t cali_config = {}; // Zero initialize first #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) @@ -111,7 +112,7 @@ void ADCSensor::setup() { ESP_LOGW(TAG, "Line fitting calibration failed with error %d, will use uncalibrated readings", err); this->setup_flags_.calibration_complete = false; } -#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32S3 || ESP32H2 +#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32C61 || ESP32H2 || ESP32P4 || ESP32S3 } this->setup_flags_.init_complete = true; @@ -186,11 +187,11 @@ float ADCSensor::sample_fixed_attenuation_() { ESP_LOGW(TAG, "ADC calibration conversion failed with error %d, disabling calibration", err); if (this->calibration_handle_ != nullptr) { #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); #else // Other ESP32 variants use line fitting calibration adc_cali_delete_scheme_line_fitting(this->calibration_handle_); -#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32S3 || ESP32H2 +#endif // USE_ESP32_VARIANT_ESP32C3 || ESP32C5 || ESP32C6 || ESP32C61 || ESP32H2 || ESP32P4 || ESP32S3 this->calibration_handle_ = nullptr; } } @@ -219,7 +220,7 @@ float ADCSensor::sample_autorange_() { if (this->calibration_handle_ != nullptr) { // Delete old calibration handle #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(this->calibration_handle_); #else adc_cali_delete_scheme_line_fitting(this->calibration_handle_); @@ -231,7 +232,7 @@ float ADCSensor::sample_autorange_() { adc_cali_handle_t handle = nullptr; #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_curve_fitting_config_t cali_config = {}; #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 3, 0) cali_config.chan = this->channel_; @@ -266,7 +267,7 @@ float ADCSensor::sample_autorange_() { ESP_LOGW(TAG, "ADC read failed in autorange with error %d", err); if (handle != nullptr) { #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(handle); #else adc_cali_delete_scheme_line_fitting(handle); @@ -288,7 +289,7 @@ float ADCSensor::sample_autorange_() { } // Clean up calibration handle #if USE_ESP32_VARIANT_ESP32C3 || USE_ESP32_VARIANT_ESP32C5 || USE_ESP32_VARIANT_ESP32C6 || \ - USE_ESP32_VARIANT_ESP32S3 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 + USE_ESP32_VARIANT_ESP32C61 || USE_ESP32_VARIANT_ESP32H2 || USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S3 adc_cali_delete_scheme_curve_fitting(handle); #else adc_cali_delete_scheme_line_fitting(handle); diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 2910643dfb..88618acef4 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -27,12 +27,13 @@ from esphome.const import ( CONF_SERVICE, CONF_SERVICES, CONF_TAG, + CONF_THEN, CONF_TRIGGER_ID, CONF_VARIABLES, ) -from esphome.core import CORE, ID, CoroPriority, coroutine_with_priority -from esphome.cpp_generator import TemplateArgsType -from esphome.types import ConfigType +from esphome.core import CORE, ID, CoroPriority, EsphomeError, coroutine_with_priority +from esphome.cpp_generator import MockObj, TemplateArgsType +from esphome.types import ConfigFragmentType, ConfigType _LOGGER = logging.getLogger(__name__) @@ -63,17 +64,21 @@ HomeAssistantActionResponseTrigger = api_ns.class_( "HomeAssistantActionResponseTrigger", automation.Trigger ) APIConnectedCondition = api_ns.class_("APIConnectedCondition", Condition) +APIRespondAction = api_ns.class_("APIRespondAction", automation.Action) +APIUnregisterServiceCallAction = api_ns.class_( + "APIUnregisterServiceCallAction", automation.Action +) UserServiceTrigger = api_ns.class_("UserServiceTrigger", automation.Trigger) ListEntitiesServicesArgument = api_ns.class_("ListEntitiesServicesArgument") -SERVICE_ARG_NATIVE_TYPES = { - "bool": bool, +SERVICE_ARG_NATIVE_TYPES: dict[str, MockObj] = { + "bool": cg.bool_, "int": cg.int32, - "float": float, + "float": cg.float_, "string": cg.std_string, - "bool[]": cg.FixedVector.template(bool).operator("const").operator("ref"), + "bool[]": cg.FixedVector.template(cg.bool_).operator("const").operator("ref"), "int[]": cg.FixedVector.template(cg.int32).operator("const").operator("ref"), - "float[]": cg.FixedVector.template(float).operator("const").operator("ref"), + "float[]": cg.FixedVector.template(cg.float_).operator("const").operator("ref"), "string[]": cg.FixedVector.template(cg.std_string) .operator("const") .operator("ref"), @@ -102,6 +107,85 @@ def validate_encryption_key(value): return value +CONF_SUPPORTS_RESPONSE = "supports_response" + +# Enum values in api::enums namespace +enums_ns = api_ns.namespace("enums") +SUPPORTS_RESPONSE_OPTIONS = { + "none": enums_ns.SUPPORTS_RESPONSE_NONE, + "optional": enums_ns.SUPPORTS_RESPONSE_OPTIONAL, + "only": enums_ns.SUPPORTS_RESPONSE_ONLY, + "status": enums_ns.SUPPORTS_RESPONSE_STATUS, +} + + +def _auto_detect_supports_response(config: ConfigType) -> ConfigType: + """Auto-detect supports_response based on api.respond usage in the action's then block. + + - If api.respond with data found: set to "optional" (unless user explicitly set) + - If api.respond without data found: set to "status" (unless user explicitly set) + - If no api.respond found: set to "none" (unless user explicitly set) + """ + + def scan_actions(items: ConfigFragmentType) -> tuple[bool, bool]: + """Recursively scan actions for api.respond. + + Returns: (found, has_data) tuple - has_data is True if ANY api.respond has data + """ + found_any = False + has_data_any = False + + if isinstance(items, list): + for item in items: + found, has_data = scan_actions(item) + if found: + found_any = True + has_data_any = has_data_any or has_data + elif isinstance(items, dict): + # Check if this is an api.respond action + if "api.respond" in items: + respond_config = items["api.respond"] + has_data = isinstance(respond_config, dict) and "data" in respond_config + return True, has_data + # Recursively check all values + for value in items.values(): + found, has_data = scan_actions(value) + if found: + found_any = True + has_data_any = has_data_any or has_data + + return found_any, has_data_any + + then = config.get(CONF_THEN, []) + action_name = config.get(CONF_ACTION) + found, has_data = scan_actions(then) + + # If user explicitly set supports_response, validate and use that + if CONF_SUPPORTS_RESPONSE in config: + user_value = config[CONF_SUPPORTS_RESPONSE] + # Validate: "only" requires api.respond with data + if user_value == "only" and not has_data: + raise cv.Invalid( + f"Action '{action_name}' has supports_response=only but no api.respond " + "action with 'data:' was found. Use 'status' for responses without data, " + "or add 'data:' to your api.respond action." + ) + return config + + # Auto-detect based on api.respond usage + if found: + config[CONF_SUPPORTS_RESPONSE] = "optional" if has_data else "status" + else: + config[CONF_SUPPORTS_RESPONSE] = "none" + + return config + + +def _validate_supports_response(value): + """Validate supports_response after auto-detection has set the value.""" + return cv.enum(SUPPORTS_RESPONSE_OPTIONS, lower=True)(value) + + ACTIONS_SCHEMA = automation.validate_automation( { cv.GenerateID(CONF_TRIGGER_ID): cv.declare_id(UserServiceTrigger), @@ -112,10 +196,20 @@ ACTIONS_SCHEMA = automation.validate_automation( cv.validate_id_name: cv.one_of(*SERVICE_ARG_NATIVE_TYPES, lower=True), } ), + # No default - auto-detected by _auto_detect_supports_response + cv.Optional(CONF_SUPPORTS_RESPONSE): cv.enum( + SUPPORTS_RESPONSE_OPTIONS, lower=True + ), }, cv.All( cv.has_exactly_one_key(CONF_SERVICE, CONF_ACTION), cv.rename_key(CONF_SERVICE, CONF_ACTION), + _auto_detect_supports_response, + # Re-validate supports_response after auto-detection sets it + cv.Schema( + {cv.Required(CONF_SUPPORTS_RESPONSE): _validate_supports_response}, + extra=cv.ALLOW_EXTRA, + ), ), ) @@ -152,7 +246,7 @@ def _validate_api_config(config: ConfigType) -> ConfigType: _LOGGER.warning( "API 'password' authentication has been deprecated since May 2022 and will be removed in version 2026.1.0. " "Please migrate to the 'encryption' configuration. " - "See https://esphome.io/components/api.html#configuration-variables" + "See https://esphome.io/components/api/#configuration-variables" ) return config @@ -242,7 +336,7 @@ CONFIG_SCHEMA = cv.All( @coroutine_with_priority(CoroPriority.WEB) -async def to_code(config): +async def to_code(config: ConfigType) -> None: var = cg.new_Pvariable(config[CONF_ID]) await cg.register_component(var, config) @@ -279,20 +373,61 @@ async def to_code(config): # Collect all triggers first, then register all at once with initializer_list triggers: list[cg.Pvariable] = [] for conf in actions: - template_args = [] - func_args = [] - service_arg_names = [] + func_args: list[tuple[MockObj, str]] = [] + service_template_args: list[MockObj] = [] # User service argument types + + # Determine supports_response mode + # cv.enum returns the key with enum_value attribute containing the MockObj + supports_response_key = conf[CONF_SUPPORTS_RESPONSE] + supports_response = supports_response_key.enum_value + is_none = supports_response_key == "none" + is_optional = supports_response_key == "optional" + + # Add call_id and return_response based on supports_response mode + # These must match the C++ Trigger template arguments + # - none: no extra args + # - status: call_id only (for reporting success/error without data) + # - only: call_id only (response always expected with data) + # - optional: call_id + return_response (client decides) + if not is_none: + # call_id is present for "optional", "only", and "status" + func_args.append((cg.uint32, "call_id")) + # return_response only present for "optional" + if is_optional: + func_args.append((cg.bool_, "return_response")) + + service_arg_names: list[str] = [] for name, var_ in conf[CONF_VARIABLES].items(): native = SERVICE_ARG_NATIVE_TYPES[var_] - template_args.append(native) + service_template_args.append(native) func_args.append((native, name)) service_arg_names.append(name) - templ = cg.TemplateArguments(*template_args) + # Template args: supports_response mode, then user service arg types + templ = cg.TemplateArguments(supports_response, *service_template_args) trigger = cg.new_Pvariable( - conf[CONF_TRIGGER_ID], templ, conf[CONF_ACTION], service_arg_names + conf[CONF_TRIGGER_ID], + templ, + conf[CONF_ACTION], + service_arg_names, ) triggers.append(trigger) - await automation.build_automation(trigger, func_args, conf) + auto = await automation.build_automation(trigger, func_args, conf) + + # For non-none response modes, automatically append unregister action + # This ensures the call is unregistered after all actions complete (including async ones) + if not is_none: + arg_types = [arg[0] for arg in func_args] + action_templ = cg.TemplateArguments(*arg_types) + unregister_id = ID( + f"{conf[CONF_TRIGGER_ID]}__unregister", + is_declaration=True, + type=APIUnregisterServiceCallAction.template(action_templ), + ) + unregister_action = cg.new_Pvariable( + unregister_id, + var, + ) + cg.add(auto.add_actions([unregister_action])) # Register all services at once - single allocation, no reallocations cg.add(var.initialize_user_services(triggers)) @@ -538,6 +673,80 @@ async def homeassistant_tag_scanned_to_code(config, action_id, template_arg, arg return var +CONF_SUCCESS = "success" +CONF_ERROR_MESSAGE = "error_message" + + +def _validate_api_respond_data(config): + """Set flag during validation so AUTO_LOAD can include json component.""" + if CONF_DATA in config: + CORE.data.setdefault(DOMAIN, {})[CONF_CAPTURE_RESPONSE] = True + return config + + +API_RESPOND_ACTION_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.use_id(APIServer), + cv.Optional(CONF_SUCCESS, default=True): cv.templatable(cv.boolean), + cv.Optional(CONF_ERROR_MESSAGE, default=""): cv.templatable(cv.string), + cv.Optional(CONF_DATA): cv.lambda_, + } + ), + _validate_api_respond_data, +) + + +@automation.register_action( + "api.respond", + APIRespondAction, + API_RESPOND_ACTION_SCHEMA, +) +async def api_respond_to_code( + config: ConfigType, + action_id: ID, + template_arg: cg.TemplateArguments, + args: TemplateArgsType, +) -> MockObj: + # Validate that api.respond is used inside an API action context. + # We can't easily validate this at config time since the schema validation + # doesn't have access to the parent action context. Validating here in to_code + # is still much better than a cryptic C++ compile error. + has_call_id = any(name == "call_id" for _, name in args) + if not has_call_id: + raise EsphomeError( + "api.respond can only be used inside an API action's 'then:' block. " + "The 'call_id' variable is required to send a response." + ) + + cg.add_define("USE_API_USER_DEFINED_ACTION_RESPONSES") + serv = await cg.get_variable(config[CONF_ID]) + var = cg.new_Pvariable(action_id, template_arg, serv) + + # Check if we're in optional mode (has return_response arg) + is_optional = any(name == "return_response" for _, name in args) + if is_optional: + cg.add(var.set_is_optional_mode(True)) + + templ = await cg.templatable(config[CONF_SUCCESS], args, cg.bool_) + cg.add(var.set_success(templ)) + + templ = await cg.templatable(config[CONF_ERROR_MESSAGE], args, cg.std_string) + cg.add(var.set_error_message(templ)) + + if CONF_DATA in config: + cg.add_define("USE_API_USER_DEFINED_ACTION_RESPONSES_JSON") + # Lambda populates the JsonObject root - no return value needed + lambda_ = await cg.process_lambda( + config[CONF_DATA], + args + [(cg.JsonObject, "root")], + return_type=cg.void, + ) + cg.add(var.set_data(lambda_)) + + return var + + API_CONNECTED_CONDITION_SCHEMA = cv.Schema( { cv.GenerateID(): cv.use_id(APIServer), diff --git a/esphome/components/api/api.proto b/esphome/components/api/api.proto index 5450c2536c..50af5061c0 100644 --- a/esphome/components/api/api.proto +++ b/esphome/components/api/api.proto @@ -579,7 +579,7 @@ message LightCommandRequest { bool has_flash_length = 16; uint32 flash_length = 17; bool has_effect = 18; - string effect = 19; + string effect = 19 [(pointer_to_buffer) = true]; uint32 device_id = 28 [(field_ifdef) = "USE_DEVICES"]; } @@ -855,6 +855,14 @@ enum ServiceArgType { SERVICE_ARG_TYPE_FLOAT_ARRAY = 6; SERVICE_ARG_TYPE_STRING_ARRAY = 7; } +enum SupportsResponseType { + SUPPORTS_RESPONSE_NONE = 0; + SUPPORTS_RESPONSE_OPTIONAL = 1; + SUPPORTS_RESPONSE_ONLY = 2; + // Status-only response - reports success/error without data payload + // Value is higher to avoid conflicts with future Home Assistant values + SUPPORTS_RESPONSE_STATUS = 100; +} message ListEntitiesServicesArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; string name = 1; @@ -868,6 +876,7 @@ message ListEntitiesServicesResponse { string name = 1; fixed32 key = 2; repeated ListEntitiesServicesArgument args = 3 [(fixed_vector) = true]; + SupportsResponseType supports_response = 4; } message ExecuteServiceArgument { option (ifdef) = "USE_API_USER_DEFINED_ACTIONS"; @@ -890,6 +899,21 @@ message ExecuteServiceRequest { fixed32 key = 1; repeated ExecuteServiceArgument args = 2 [(fixed_vector) = true]; + uint32 call_id = 3 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES"]; + bool return_response = 4 [(field_ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES"]; +} + +// Message sent by ESPHome to Home Assistant with service execution response data +message ExecuteServiceResponse { + option (id) = 131; + option (source) = SOURCE_SERVER; + option (no_delay) = true; + option (ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES"; + + uint32 call_id = 1; // Matches the call_id from ExecuteServiceRequest + bool success = 2; // Whether the service execution succeeded + string error_message = 3; // Error message if success = false + bytes response_data = 4 [(pointer_to_buffer) = true, (field_ifdef) = "USE_API_USER_DEFINED_ACTION_RESPONSES_JSON"]; } // ==================== CAMERA ==================== @@ -1171,7 +1195,7 @@ message SelectCommandRequest { option (base_class) = "CommandProtoMessage"; fixed32 key = 1; - string state = 2; + string state = 2 [(pointer_to_buffer) = true]; uint32 device_id = 3 [(field_ifdef) = "USE_DEVICES"]; } diff --git a/esphome/components/api/api_connection.cpp b/esphome/components/api/api_connection.cpp index 9ad45dc6b7..5186e5afda 100644 --- a/esphome/components/api/api_connection.cpp +++ b/esphome/components/api/api_connection.cpp @@ -6,11 +6,17 @@ #ifdef USE_API_PLAINTEXT #include "api_frame_helper_plaintext.h" #endif +#ifdef USE_API_USER_DEFINED_ACTIONS +#include "user_services.h" +#endif #include #include #include #include #include +#ifdef USE_ESP8266 +#include +#endif #include "esphome/components/network/util.h" #include "esphome/core/application.h" #include "esphome/core/entity_base.h" @@ -527,7 +533,7 @@ void APIConnection::light_command(const LightCommandRequest &msg) { if (msg.has_flash_length) call.set_flash_length(msg.flash_length); if (msg.has_effect) - call.set_effect(msg.effect); + call.set_effect(reinterpret_cast(msg.effect), msg.effect_len); call.perform(); } #endif @@ -899,7 +905,7 @@ uint16_t APIConnection::try_send_select_info(EntityBase *entity, APIConnection * } void APIConnection::select_command(const SelectCommandRequest &msg) { ENTITY_COMMAND_MAKE_CALL(select::Select, select, select) - call.set_option(msg.state); + call.set_option(reinterpret_cast(msg.state), msg.state_len); call.perform(); } #endif @@ -1468,35 +1474,64 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { resp.set_compilation_time(App.get_compilation_time_ref()); - // Compile-time StringRef constants for manufacturers + // Manufacturer string - define once, handle ESP8266 PROGMEM separately #if defined(USE_ESP8266) || defined(USE_ESP32) - static constexpr auto MANUFACTURER = StringRef::from_lit("Espressif"); +#define ESPHOME_MANUFACTURER "Espressif" #elif defined(USE_RP2040) - static constexpr auto MANUFACTURER = StringRef::from_lit("Raspberry Pi"); +#define ESPHOME_MANUFACTURER "Raspberry Pi" #elif defined(USE_BK72XX) - static constexpr auto MANUFACTURER = StringRef::from_lit("Beken"); +#define ESPHOME_MANUFACTURER "Beken" #elif defined(USE_LN882X) - static constexpr auto MANUFACTURER = StringRef::from_lit("Lightning"); +#define ESPHOME_MANUFACTURER "Lightning" #elif defined(USE_NRF52) - static constexpr auto MANUFACTURER = StringRef::from_lit("Nordic Semiconductor"); +#define ESPHOME_MANUFACTURER "Nordic Semiconductor" #elif defined(USE_RTL87XX) - static constexpr auto MANUFACTURER = StringRef::from_lit("Realtek"); +#define ESPHOME_MANUFACTURER "Realtek" #elif defined(USE_HOST) - static constexpr auto MANUFACTURER = StringRef::from_lit("Host"); +#define ESPHOME_MANUFACTURER "Host" #endif - resp.set_manufacturer(MANUFACTURER); +#ifdef USE_ESP8266 + // ESP8266 requires PROGMEM for flash storage, copy to stack for memcpy compatibility + static const char MANUFACTURER_PROGMEM[] PROGMEM = ESPHOME_MANUFACTURER; + char manufacturer_buf[sizeof(MANUFACTURER_PROGMEM)]; + memcpy_P(manufacturer_buf, MANUFACTURER_PROGMEM, sizeof(MANUFACTURER_PROGMEM)); + resp.set_manufacturer(StringRef(manufacturer_buf, sizeof(MANUFACTURER_PROGMEM) - 1)); +#else + static constexpr auto MANUFACTURER = StringRef::from_lit(ESPHOME_MANUFACTURER); + resp.set_manufacturer(MANUFACTURER); +#endif +#undef ESPHOME_MANUFACTURER + +#ifdef USE_ESP8266 + static const char MODEL_PROGMEM[] PROGMEM = ESPHOME_BOARD; + char model_buf[sizeof(MODEL_PROGMEM)]; + memcpy_P(model_buf, MODEL_PROGMEM, sizeof(MODEL_PROGMEM)); + resp.set_model(StringRef(model_buf, sizeof(MODEL_PROGMEM) - 1)); +#else static constexpr auto MODEL = StringRef::from_lit(ESPHOME_BOARD); resp.set_model(MODEL); +#endif #ifdef USE_DEEP_SLEEP resp.has_deep_sleep = deep_sleep::global_has_deep_sleep; #endif #ifdef ESPHOME_PROJECT_NAME +#ifdef USE_ESP8266 + static const char PROJECT_NAME_PROGMEM[] PROGMEM = ESPHOME_PROJECT_NAME; + static const char PROJECT_VERSION_PROGMEM[] PROGMEM = ESPHOME_PROJECT_VERSION; + char project_name_buf[sizeof(PROJECT_NAME_PROGMEM)]; + char project_version_buf[sizeof(PROJECT_VERSION_PROGMEM)]; + memcpy_P(project_name_buf, PROJECT_NAME_PROGMEM, sizeof(PROJECT_NAME_PROGMEM)); + memcpy_P(project_version_buf, PROJECT_VERSION_PROGMEM, sizeof(PROJECT_VERSION_PROGMEM)); + resp.set_project_name(StringRef(project_name_buf, sizeof(PROJECT_NAME_PROGMEM) - 1)); + resp.set_project_version(StringRef(project_version_buf, sizeof(PROJECT_VERSION_PROGMEM) - 1)); +#else static constexpr auto PROJECT_NAME = StringRef::from_lit(ESPHOME_PROJECT_NAME); static constexpr auto PROJECT_VERSION = StringRef::from_lit(ESPHOME_PROJECT_VERSION); resp.set_project_name(PROJECT_NAME); resp.set_project_version(PROJECT_VERSION); #endif +#endif #ifdef USE_WEBSERVER resp.webserver_port = USE_WEBSERVER_PORT; #endif @@ -1545,7 +1580,12 @@ bool APIConnection::send_device_info_response(const DeviceInfoRequest &msg) { #ifdef USE_API_HOMEASSISTANT_STATES void APIConnection::on_home_assistant_state_response(const HomeAssistantStateResponse &msg) { for (auto &it : this->parent_->get_state_subs()) { - if (it.entity_id == msg.entity_id && it.attribute.value() == msg.attribute) { + // Compare entity_id and attribute with message fields + bool entity_match = (strcmp(it.entity_id, msg.entity_id.c_str()) == 0); + bool attribute_match = (it.attribute != nullptr && strcmp(it.attribute, msg.attribute.c_str()) == 0) || + (it.attribute == nullptr && msg.attribute.empty()); + + if (entity_match && attribute_match) { it.callback(msg.state); } } @@ -1554,15 +1594,54 @@ void APIConnection::on_home_assistant_state_response(const HomeAssistantStateRes #ifdef USE_API_USER_DEFINED_ACTIONS void APIConnection::execute_service(const ExecuteServiceRequest &msg) { bool found = false; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + // Register the call and get a unique server-generated action_call_id + // This avoids collisions when multiple clients use the same call_id + uint32_t action_call_id = 0; + if (msg.call_id != 0) { + action_call_id = this->parent_->register_active_action_call(msg.call_id, this); + } + // Use the overload that passes action_call_id separately (avoids copying msg) + for (auto *service : this->parent_->get_user_services()) { + if (service->execute_service(msg, action_call_id)) { + found = true; + } + } +#else for (auto *service : this->parent_->get_user_services()) { if (service->execute_service(msg)) { found = true; } } +#endif if (!found) { ESP_LOGV(TAG, "Could not find service"); } + // Note: For services with supports_response != none, the call is unregistered + // by an automatically appended APIUnregisterServiceCallAction at the end of + // the action list. This ensures async actions (delays, waits) complete first. } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +void APIConnection::send_execute_service_response(uint32_t call_id, bool success, const std::string &error_message) { + ExecuteServiceResponse resp; + resp.call_id = call_id; + resp.success = success; + resp.set_error_message(StringRef(error_message)); + this->send_message(resp, ExecuteServiceResponse::MESSAGE_TYPE); +} +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +void APIConnection::send_execute_service_response(uint32_t call_id, bool success, const std::string &error_message, + const uint8_t *response_data, size_t response_data_len) { + ExecuteServiceResponse resp; + resp.call_id = call_id; + resp.success = success; + resp.set_error_message(StringRef(error_message)); + resp.response_data = response_data; + resp.response_data_len = response_data_len; + this->send_message(resp, ExecuteServiceResponse::MESSAGE_TYPE); +} +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -1590,7 +1669,7 @@ bool APIConnection::send_noise_encryption_set_key_response(const NoiseEncryption } else { ESP_LOGW(TAG, "Failed to clear encryption key"); } - } else if (base64_decode(msg.key, psk.data(), msg.key.size()) != psk.size()) { + } else if (base64_decode(msg.key, psk.data(), psk.size()) != psk.size()) { ESP_LOGW(TAG, "Invalid encryption key length"); } else if (!this->parent_->save_noise_psk(psk, true)) { ESP_LOGW(TAG, "Failed to save encryption key"); @@ -1662,13 +1741,13 @@ void APIConnection::DeferredBatch::add_item(EntityBase *entity, MessageCreator c for (auto &item : items) { if (item.entity == entity && item.message_type == message_type) { // Replace with new creator - item.creator = std::move(creator); + item.creator = creator; return; } } // No existing item found, add new one - items.emplace_back(entity, std::move(creator), message_type, estimated_size); + items.emplace_back(entity, creator, message_type, estimated_size); } void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, MessageCreator creator, uint8_t message_type, @@ -1677,7 +1756,7 @@ void APIConnection::DeferredBatch::add_item_front(EntityBase *entity, MessageCre // This avoids expensive vector::insert which shifts all elements // Note: We only ever have one high-priority message at a time (ping OR disconnect) // If we're disconnecting, pings are blocked, so this simple swap is sufficient - items.emplace_back(entity, std::move(creator), message_type, estimated_size); + items.emplace_back(entity, creator, message_type, estimated_size); if (items.size() > 1) { // Swap the new high-priority item to the front std::swap(items.front(), items.back()); @@ -1885,8 +1964,8 @@ void APIConnection::process_state_subscriptions_() { SubscribeHomeAssistantStateResponse resp; resp.set_entity_id(StringRef(it.entity_id)); - // Avoid string copy by directly using the optional's value if it exists - resp.set_attribute(it.attribute.has_value() ? StringRef(it.attribute.value()) : StringRef("")); + // Avoid string copy by using the const char* pointer if it exists + resp.set_attribute(it.attribute != nullptr ? StringRef(it.attribute) : StringRef("")); resp.once = it.once; if (this->send_message(resp, SubscribeHomeAssistantStateResponse::MESSAGE_TYPE)) { diff --git a/esphome/components/api/api_connection.h b/esphome/components/api/api_connection.h index 05af0ccde7..b50be5d0d4 100644 --- a/esphome/components/api/api_connection.h +++ b/esphome/components/api/api_connection.h @@ -223,6 +223,13 @@ class APIConnection final : public APIServerConnection { #endif #ifdef USE_API_USER_DEFINED_ACTIONS void execute_service(const ExecuteServiceRequest &msg) override; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + void send_execute_service_response(uint32_t call_id, bool success, const std::string &error_message); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + void send_execute_service_response(uint32_t call_id, bool success, const std::string &error_message, + const uint8_t *response_data, size_t response_data_len); +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif #ifdef USE_API_NOISE bool send_noise_encryption_set_key_response(const NoiseEncryptionSetKeyRequest &msg) override; @@ -505,28 +512,9 @@ class APIConnection final : public APIServerConnection { class MessageCreator { public: - // Constructor for function pointer MessageCreator(MessageCreatorPtr ptr) { data_.function_ptr = ptr; } - - // 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; - MessageCreator &operator=(const MessageCreator &other) = delete; - - // Move constructor - MessageCreator(MessageCreator &&other) noexcept : data_(other.data_) { other.data_.function_ptr = nullptr; } - - // Move assignment - MessageCreator &operator=(MessageCreator &&other) noexcept { - if (this != &other) { - data_ = other.data_; - other.data_.function_ptr = nullptr; - } - return *this; - } - // Call operator - uses message_type to determine union type uint16_t operator()(EntityBase *entity, APIConnection *conn, uint32_t remaining_size, bool is_single, uint8_t message_type) const; @@ -535,7 +523,7 @@ class APIConnection final : public APIServerConnection { union Data { MessageCreatorPtr function_ptr; const char *const_char_ptr; - } data_; // 4 bytes on 32-bit, 8 bytes on 64-bit - same as before + } data_; // 4 bytes on 32-bit, 8 bytes on 64-bit }; // Generic batching mechanism for both state updates and entity info @@ -548,7 +536,7 @@ class APIConnection final : public APIServerConnection { // Constructor for creating BatchItem BatchItem(EntityBase *entity, MessageCreator creator, uint8_t message_type, uint8_t estimated_size) - : entity(entity), creator(std::move(creator)), message_type(message_type), estimated_size(estimated_size) {} + : entity(entity), creator(creator), message_type(message_type), estimated_size(estimated_size) {} }; std::vector items; @@ -716,12 +704,12 @@ class APIConnection final : public APIServerConnection { } // Fall back to scheduled batching - return this->schedule_message_(entity, std::move(creator), message_type, estimated_size); + return this->schedule_message_(entity, 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); + this->deferred_batch_.add_item(entity, creator, message_type, estimated_size); return this->schedule_batch_(); } diff --git a/esphome/components/api/api_frame_helper_noise.cpp b/esphome/components/api/api_frame_helper_noise.cpp index ae69f0b673..1d6f32ee9d 100644 --- a/esphome/components/api/api_frame_helper_noise.cpp +++ b/esphome/components/api/api_frame_helper_noise.cpp @@ -539,7 +539,8 @@ APIError APINoiseFrameHelper::init_handshake_() { if (aerr != APIError::OK) return aerr; // set_prologue copies it into handshakestate, so we can get rid of it now - prologue_ = {}; + // Use swap idiom to actually release memory (= {} only clears size, not capacity) + std::vector().swap(prologue_); err = noise_handshakestate_start(handshake_); aerr = handle_noise_error_(err, LOG_STR("noise_handshakestate_start"), APIError::HANDSHAKESTATE_SETUP_FAILED); diff --git a/esphome/components/api/api_pb2.cpp b/esphome/components/api/api_pb2.cpp index c131815456..4a89ee78e1 100644 --- a/esphome/components/api/api_pb2.cpp +++ b/esphome/components/api/api_pb2.cpp @@ -611,9 +611,12 @@ bool LightCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } bool LightCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 19: - this->effect = value.as_string(); + case 19: { + // Use raw data directly to avoid allocation + this->effect = value.data(); + this->effect_len = value.size(); break; + } default: return false; } @@ -1010,11 +1013,13 @@ void ListEntitiesServicesResponse::encode(ProtoWriteBuffer buffer) const { for (auto &it : this->args) { buffer.encode_message(3, it, true); } + buffer.encode_uint32(4, static_cast(this->supports_response)); } void ListEntitiesServicesResponse::calculate_size(ProtoSize &size) const { size.add_length(1, this->name_ref_.size()); size.add_fixed32(1, this->key); size.add_repeated_message(1, this->args); + size.add_uint32(1, static_cast(this->supports_response)); } bool ExecuteServiceArgument::decode_varint(uint32_t field_id, ProtoVarInt value) { switch (field_id) { @@ -1075,6 +1080,23 @@ void ExecuteServiceArgument::decode(const uint8_t *buffer, size_t length) { this->string_array.init(count_string_array); ProtoDecodableMessage::decode(buffer, length); } +bool ExecuteServiceRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { + switch (field_id) { +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + case 3: + this->call_id = value.as_uint32(); + break; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + case 4: + this->return_response = value.as_bool(); + break; +#endif + default: + return false; + } + return true; +} bool ExecuteServiceRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { case 2: @@ -1102,6 +1124,24 @@ void ExecuteServiceRequest::decode(const uint8_t *buffer, size_t length) { ProtoDecodableMessage::decode(buffer, length); } #endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +void ExecuteServiceResponse::encode(ProtoWriteBuffer buffer) const { + buffer.encode_uint32(1, this->call_id); + buffer.encode_bool(2, this->success); + buffer.encode_string(3, this->error_message_ref_); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + buffer.encode_bytes(4, this->response_data, this->response_data_len); +#endif +} +void ExecuteServiceResponse::calculate_size(ProtoSize &size) const { + size.add_uint32(1, this->call_id); + size.add_bool(1, this->success); + size.add_length(1, this->error_message_ref_.size()); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + size.add_length(4, this->response_data_len); +#endif +} +#endif #ifdef USE_CAMERA void ListEntitiesCameraResponse::encode(ProtoWriteBuffer buffer) const { buffer.encode_string(1, this->object_id_ref_); @@ -1532,9 +1572,12 @@ bool SelectCommandRequest::decode_varint(uint32_t field_id, ProtoVarInt value) { } bool SelectCommandRequest::decode_length(uint32_t field_id, ProtoLengthDelimited value) { switch (field_id) { - case 2: - this->state = value.as_string(); + case 2: { + // Use raw data directly to avoid allocation + this->state = value.data(); + this->state_len = value.size(); break; + } default: return false; } diff --git a/esphome/components/api/api_pb2.h b/esphome/components/api/api_pb2.h index 74d3834bf5..f23a62fc3c 100644 --- a/esphome/components/api/api_pb2.h +++ b/esphome/components/api/api_pb2.h @@ -75,6 +75,12 @@ enum ServiceArgType : uint32_t { SERVICE_ARG_TYPE_FLOAT_ARRAY = 6, SERVICE_ARG_TYPE_STRING_ARRAY = 7, }; +enum SupportsResponseType : uint32_t { + SUPPORTS_RESPONSE_NONE = 0, + SUPPORTS_RESPONSE_OPTIONAL = 1, + SUPPORTS_RESPONSE_ONLY = 2, + SUPPORTS_RESPONSE_STATUS = 100, +}; #endif #ifdef USE_CLIMATE enum ClimateMode : uint32_t { @@ -834,7 +840,7 @@ class LightStateResponse final : public StateResponseProtoMessage { class LightCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 32; - static constexpr uint8_t ESTIMATED_SIZE = 112; + static constexpr uint8_t ESTIMATED_SIZE = 122; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "light_command_request"; } #endif @@ -863,7 +869,8 @@ class LightCommandRequest final : public CommandProtoMessage { bool has_flash_length{false}; uint32_t flash_length{0}; bool has_effect{false}; - std::string effect{}; + const uint8_t *effect{nullptr}; + uint16_t effect_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif @@ -1257,7 +1264,7 @@ class ListEntitiesServicesArgument final : public ProtoMessage { class ListEntitiesServicesResponse final : public ProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 41; - static constexpr uint8_t ESTIMATED_SIZE = 48; + static constexpr uint8_t ESTIMATED_SIZE = 50; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "list_entities_services_response"; } #endif @@ -1265,6 +1272,7 @@ class ListEntitiesServicesResponse final : public ProtoMessage { void set_name(const StringRef &ref) { this->name_ref_ = ref; } uint32_t key{0}; FixedVector args{}; + enums::SupportsResponseType supports_response{}; void encode(ProtoWriteBuffer buffer) const override; void calculate_size(ProtoSize &size) const override; #ifdef HAS_PROTO_MESSAGE_DUMP @@ -1297,12 +1305,18 @@ class ExecuteServiceArgument final : public ProtoDecodableMessage { class ExecuteServiceRequest final : public ProtoDecodableMessage { public: static constexpr uint8_t MESSAGE_TYPE = 42; - static constexpr uint8_t ESTIMATED_SIZE = 39; + static constexpr uint8_t ESTIMATED_SIZE = 45; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "execute_service_request"; } #endif uint32_t key{0}; FixedVector args{}; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + uint32_t call_id{0}; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + bool return_response{false}; +#endif void decode(const uint8_t *buffer, size_t length) override; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; @@ -1311,6 +1325,32 @@ class ExecuteServiceRequest final : public ProtoDecodableMessage { protected: bool decode_32bit(uint32_t field_id, Proto32Bit value) override; bool decode_length(uint32_t field_id, ProtoLengthDelimited value) override; + bool decode_varint(uint32_t field_id, ProtoVarInt value) override; +}; +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +class ExecuteServiceResponse final : public ProtoMessage { + public: + static constexpr uint8_t MESSAGE_TYPE = 131; + static constexpr uint8_t ESTIMATED_SIZE = 34; +#ifdef HAS_PROTO_MESSAGE_DUMP + const char *message_name() const override { return "execute_service_response"; } +#endif + uint32_t call_id{0}; + bool success{false}; + StringRef error_message_ref_{}; + void set_error_message(const StringRef &ref) { this->error_message_ref_ = ref; } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + const uint8_t *response_data{nullptr}; + uint16_t response_data_len{0}; +#endif + void encode(ProtoWriteBuffer buffer) const override; + void calculate_size(ProtoSize &size) const override; +#ifdef HAS_PROTO_MESSAGE_DUMP + void dump_to(std::string &out) const override; +#endif + + protected: }; #endif #ifdef USE_CAMERA @@ -1565,11 +1605,12 @@ class SelectStateResponse final : public StateResponseProtoMessage { class SelectCommandRequest final : public CommandProtoMessage { public: static constexpr uint8_t MESSAGE_TYPE = 54; - static constexpr uint8_t ESTIMATED_SIZE = 18; + static constexpr uint8_t ESTIMATED_SIZE = 28; #ifdef HAS_PROTO_MESSAGE_DUMP const char *message_name() const override { return "select_command_request"; } #endif - std::string state{}; + const uint8_t *state{nullptr}; + uint16_t state_len{0}; #ifdef HAS_PROTO_MESSAGE_DUMP void dump_to(std::string &out) const override; #endif diff --git a/esphome/components/api/api_pb2_dump.cpp b/esphome/components/api/api_pb2_dump.cpp index bea7fc53c4..5e271f41cb 100644 --- a/esphome/components/api/api_pb2_dump.cpp +++ b/esphome/components/api/api_pb2_dump.cpp @@ -231,6 +231,20 @@ template<> const char *proto_enum_to_string(enums::Servic return "UNKNOWN"; } } +template<> const char *proto_enum_to_string(enums::SupportsResponseType value) { + switch (value) { + case enums::SUPPORTS_RESPONSE_NONE: + return "SUPPORTS_RESPONSE_NONE"; + case enums::SUPPORTS_RESPONSE_OPTIONAL: + return "SUPPORTS_RESPONSE_OPTIONAL"; + case enums::SUPPORTS_RESPONSE_ONLY: + return "SUPPORTS_RESPONSE_ONLY"; + case enums::SUPPORTS_RESPONSE_STATUS: + return "SUPPORTS_RESPONSE_STATUS"; + default: + return "UNKNOWN"; + } +} #endif #ifdef USE_CLIMATE template<> const char *proto_enum_to_string(enums::ClimateMode value) { @@ -985,7 +999,9 @@ void LightCommandRequest::dump_to(std::string &out) const { dump_field(out, "has_flash_length", this->has_flash_length); dump_field(out, "flash_length", this->flash_length); dump_field(out, "has_effect", this->has_effect); - dump_field(out, "effect", this->effect); + out.append(" effect: "); + out.append(format_hex_pretty(this->effect, this->effect_len)); + out.append("\n"); #ifdef USE_DEVICES dump_field(out, "device_id", this->device_id); #endif @@ -1194,6 +1210,7 @@ void ListEntitiesServicesResponse::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } + dump_field(out, "supports_response", static_cast(this->supports_response)); } void ExecuteServiceArgument::dump_to(std::string &out) const { MessageDumpHelper helper(out, "ExecuteServiceArgument"); @@ -1223,6 +1240,25 @@ void ExecuteServiceRequest::dump_to(std::string &out) const { it.dump_to(out); out.append("\n"); } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + dump_field(out, "call_id", this->call_id); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + dump_field(out, "return_response", this->return_response); +#endif +} +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +void ExecuteServiceResponse::dump_to(std::string &out) const { + MessageDumpHelper helper(out, "ExecuteServiceResponse"); + dump_field(out, "call_id", this->call_id); + dump_field(out, "success", this->success); + dump_field(out, "error_message", this->error_message_ref_); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + out.append(" response_data: "); + out.append(format_hex_pretty(this->response_data, this->response_data_len)); + out.append("\n"); +#endif } #endif #ifdef USE_CAMERA @@ -1419,7 +1455,9 @@ void SelectStateResponse::dump_to(std::string &out) const { void SelectCommandRequest::dump_to(std::string &out) const { MessageDumpHelper helper(out, "SelectCommandRequest"); dump_field(out, "key", this->key); - dump_field(out, "state", this->state); + out.append(" state: "); + out.append(format_hex_pretty(this->state, this->state_len)); + out.append("\n"); #ifdef USE_DEVICES dump_field(out, "device_id", this->device_id); #endif diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 4168761c74..b1a5ee5d57 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -4,8 +4,8 @@ #include "api_connection.h" #include "esphome/components/network/util.h" #include "esphome/core/application.h" -#include "esphome/core/defines.h" #include "esphome/core/controller_registry.h" +#include "esphome/core/defines.h" #include "esphome/core/hal.h" #include "esphome/core/log.h" #include "esphome/core/util.h" @@ -52,11 +52,6 @@ void APIServer::setup() { #endif #endif - // Schedule reboot if no clients connect within timeout - if (this->reboot_timeout_ != 0) { - this->schedule_reboot_timeout_(); - } - this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0); // monitored for incoming connections if (this->socket_ == nullptr) { ESP_LOGW(TAG, "Could not create socket"); @@ -110,16 +105,13 @@ void APIServer::setup() { camera::Camera::instance()->add_listener(this); } #endif -} -void APIServer::schedule_reboot_timeout_() { - this->status_set_warning(); - this->set_timeout("api_reboot", this->reboot_timeout_, []() { - if (!global_api_server->is_connected()) { - ESP_LOGE(TAG, "No clients; rebooting"); - App.reboot(); - } - }); + // Initialize last_connected_ for reboot timeout tracking + this->last_connected_ = App.get_loop_component_start_time(); + // Set warning status if reboot timeout is enabled + if (this->reboot_timeout_ != 0) { + this->status_set_warning(); + } } void APIServer::loop() { @@ -147,15 +139,24 @@ void APIServer::loop() { this->clients_.emplace_back(conn); conn->start(); - // Clear warning status and cancel reboot when first client connects + // First client connected - clear warning and update timestamp if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) { this->status_clear_warning(); - this->cancel_timeout("api_reboot"); + this->last_connected_ = App.get_loop_component_start_time(); } } } if (this->clients_.empty()) { + // Check reboot timeout - done in loop to avoid scheduler heap churn + // (cancelled scheduler items sit in heap memory until their scheduled time) + if (this->reboot_timeout_ != 0) { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_connected_ > this->reboot_timeout_) { + ESP_LOGE(TAG, "No clients; rebooting"); + App.reboot(); + } + } return; } @@ -185,6 +186,9 @@ void APIServer::loop() { // Rare case: handle disconnection #ifdef USE_API_CLIENT_DISCONNECTED_TRIGGER this->client_disconnected_trigger_->trigger(client->client_info_.name, client->client_info_.peername); +#endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + this->unregister_active_action_calls_for_connection(client.get()); #endif ESP_LOGV(TAG, "Remove connection %s", client->client_info_.name.c_str()); @@ -194,9 +198,10 @@ void APIServer::loop() { } this->clients_.pop_back(); - // Schedule reboot when last client disconnects + // Last client disconnected - set warning and start tracking for reboot timeout if (this->clients_.empty() && this->reboot_timeout_ != 0) { - this->schedule_reboot_timeout_(); + this->status_set_warning(); + this->last_connected_ = App.get_loop_component_start_time(); } // Don't increment client_index since we need to process the swapped element } @@ -414,25 +419,56 @@ void APIServer::handle_action_response(uint32_t call_id, bool success, const std #endif // USE_API_HOMEASSISTANT_SERVICES #ifdef USE_API_HOMEASSISTANT_STATES +// Helper to add subscription (reduces duplication) +void APIServer::add_state_subscription_(const char *entity_id, const char *attribute, + std::function f, bool once) { + this->state_subs_.push_back(HomeAssistantStateSubscription{ + .entity_id = entity_id, .attribute = attribute, .callback = std::move(f), .once = once, + // entity_id_dynamic_storage and attribute_dynamic_storage remain nullptr (no heap allocation) + }); +} + +// Helper to add subscription with heap-allocated strings (reduces duplication) +void APIServer::add_state_subscription_(std::string entity_id, optional attribute, + std::function f, bool once) { + HomeAssistantStateSubscription sub; + // Allocate heap storage for the strings + sub.entity_id_dynamic_storage = std::make_unique(std::move(entity_id)); + sub.entity_id = sub.entity_id_dynamic_storage->c_str(); + + if (attribute.has_value()) { + sub.attribute_dynamic_storage = std::make_unique(std::move(attribute.value())); + sub.attribute = sub.attribute_dynamic_storage->c_str(); + } else { + sub.attribute = nullptr; + } + + sub.callback = std::move(f); + sub.once = once; + this->state_subs_.push_back(std::move(sub)); +} + +// New const char* overload (for internal components - zero allocation) +void APIServer::subscribe_home_assistant_state(const char *entity_id, const char *attribute, + std::function f) { + this->add_state_subscription_(entity_id, attribute, std::move(f), false); +} + +void APIServer::get_home_assistant_state(const char *entity_id, const char *attribute, + std::function f) { + this->add_state_subscription_(entity_id, attribute, std::move(f), true); +} + +// Existing std::string overload (for custom_api_device.h - heap allocation) void APIServer::subscribe_home_assistant_state(std::string entity_id, optional attribute, std::function f) { - this->state_subs_.push_back(HomeAssistantStateSubscription{ - .entity_id = std::move(entity_id), - .attribute = std::move(attribute), - .callback = std::move(f), - .once = false, - }); + this->add_state_subscription_(std::move(entity_id), std::move(attribute), std::move(f), false); } void APIServer::get_home_assistant_state(std::string entity_id, optional attribute, std::function f) { - this->state_subs_.push_back(HomeAssistantStateSubscription{ - .entity_id = std::move(entity_id), - .attribute = std::move(attribute), - .callback = std::move(f), - .once = true, - }); -}; + this->add_state_subscription_(std::move(entity_id), std::move(attribute), std::move(f), true); +} const std::vector &APIServer::get_state_subs() const { return this->state_subs_; @@ -583,5 +619,84 @@ bool APIServer::teardown() { return this->clients_.empty(); } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +// Timeout for action calls - matches aioesphomeapi client timeout (default 30s) +// Can be overridden via USE_API_ACTION_CALL_TIMEOUT_MS define for testing +#ifndef USE_API_ACTION_CALL_TIMEOUT_MS +#define USE_API_ACTION_CALL_TIMEOUT_MS 30000 // NOLINT +#endif + +uint32_t APIServer::register_active_action_call(uint32_t client_call_id, APIConnection *conn) { + uint32_t action_call_id = this->next_action_call_id_++; + // Handle wraparound (skip 0 as it means "no call") + if (this->next_action_call_id_ == 0) { + this->next_action_call_id_ = 1; + } + this->active_action_calls_.push_back({action_call_id, client_call_id, conn}); + + // Schedule automatic cleanup after timeout (client will have given up by then) + this->set_timeout(str_sprintf("action_call_%u", action_call_id), USE_API_ACTION_CALL_TIMEOUT_MS, + [this, action_call_id]() { + ESP_LOGD(TAG, "Action call %u timed out", action_call_id); + this->unregister_active_action_call(action_call_id); + }); + + return action_call_id; +} + +void APIServer::unregister_active_action_call(uint32_t action_call_id) { + // Cancel the timeout for this action call + this->cancel_timeout(str_sprintf("action_call_%u", action_call_id)); + + // Swap-and-pop is more efficient than remove_if for unordered vectors + for (size_t i = 0; i < this->active_action_calls_.size(); i++) { + if (this->active_action_calls_[i].action_call_id == action_call_id) { + std::swap(this->active_action_calls_[i], this->active_action_calls_.back()); + this->active_action_calls_.pop_back(); + return; + } + } +} + +void APIServer::unregister_active_action_calls_for_connection(APIConnection *conn) { + // Remove all active action calls for disconnected connection using swap-and-pop + for (size_t i = 0; i < this->active_action_calls_.size();) { + if (this->active_action_calls_[i].connection == conn) { + // Cancel the timeout for this action call + this->cancel_timeout(str_sprintf("action_call_%u", this->active_action_calls_[i].action_call_id)); + + std::swap(this->active_action_calls_[i], this->active_action_calls_.back()); + this->active_action_calls_.pop_back(); + // Don't increment i - need to check the swapped element + } else { + i++; + } + } +} + +void APIServer::send_action_response(uint32_t action_call_id, bool success, const std::string &error_message) { + for (auto &call : this->active_action_calls_) { + if (call.action_call_id == action_call_id) { + call.connection->send_execute_service_response(call.client_call_id, success, error_message); + return; + } + } + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); +} +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +void APIServer::send_action_response(uint32_t action_call_id, bool success, const std::string &error_message, + const uint8_t *response_data, size_t response_data_len) { + for (auto &call : this->active_action_calls_) { + if (call.action_call_id == action_call_id) { + call.connection->send_execute_service_response(call.client_call_id, success, error_message, response_data, + response_data_len); + return; + } + } + ESP_LOGW(TAG, "Cannot send response: no active call found for action_call_id %u", action_call_id); +} +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES + } // namespace esphome::api #endif diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 3089bb1d35..ad7d8bf63d 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -12,9 +12,6 @@ #include "esphome/core/log.h" #include "list_entities.h" #include "subscribe_state.h" -#ifdef USE_API_USER_DEFINED_ACTIONS -#include "user_services.h" -#endif #ifdef USE_LOGGER #include "esphome/components/logger/logger.h" #endif @@ -22,11 +19,15 @@ #include "esphome/components/camera/camera.h" #endif -#include #include namespace esphome::api { +#ifdef USE_API_USER_DEFINED_ACTIONS +// Forward declaration - full definition in user_services.h +class UserServiceDescriptor; +#endif + #ifdef USE_API_NOISE struct SavedNoisePsk { psk_t psk; @@ -154,6 +155,19 @@ class APIServer : public Component, // Only compile push_back method when custom_services: true (external components) void register_user_service(UserServiceDescriptor *descriptor) { this->user_services_.push_back(descriptor); } #endif +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + // Action call context management - supports concurrent calls from multiple clients + // Returns server-generated action_call_id to avoid collisions when clients use same call_id + uint32_t register_active_action_call(uint32_t client_call_id, APIConnection *conn); + void unregister_active_action_call(uint32_t action_call_id); + void unregister_active_action_calls_for_connection(APIConnection *conn); + // Send response for a specific action call (uses action_call_id, sends client_call_id in response) + void send_action_response(uint32_t action_call_id, bool success, const std::string &error_message); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + void send_action_response(uint32_t action_call_id, bool success, const std::string &error_message, + const uint8_t *response_data, size_t response_data_len); +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif #ifdef USE_HOMEASSISTANT_TIME void request_time(); @@ -176,16 +190,27 @@ class APIServer : public Component, #ifdef USE_API_HOMEASSISTANT_STATES struct HomeAssistantStateSubscription { - std::string entity_id; - optional attribute; + const char *entity_id; // Pointer to flash (internal) or heap (external) + const char *attribute; // Pointer to flash or nullptr (nullptr means no attribute) std::function callback; bool once; + + // Dynamic storage for external components using std::string API (custom_api_device.h) + // These are only allocated when using the std::string overload (nullptr for const char* overload) + std::unique_ptr entity_id_dynamic_storage; + std::unique_ptr attribute_dynamic_storage; }; + // New const char* overload (for internal components - zero allocation) + void subscribe_home_assistant_state(const char *entity_id, const char *attribute, std::function f); + void get_home_assistant_state(const char *entity_id, const char *attribute, std::function f); + + // Existing std::string overload (for custom_api_device.h - heap allocation) void subscribe_home_assistant_state(std::string entity_id, optional attribute, std::function f); void get_home_assistant_state(std::string entity_id, optional attribute, std::function f); + const std::vector &get_state_subs() const; #endif #ifdef USE_API_USER_DEFINED_ACTIONS @@ -202,11 +227,17 @@ class APIServer : public Component, #endif protected: - void schedule_reboot_timeout_(); #ifdef USE_API_NOISE bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, const psk_t &active_psk, bool make_active); #endif // USE_API_NOISE +#ifdef USE_API_HOMEASSISTANT_STATES + // Helper methods to reduce code duplication + void add_state_subscription_(const char *entity_id, const char *attribute, std::function f, + bool once); + void add_state_subscription_(std::string entity_id, optional attribute, + std::function f, bool once); +#endif // USE_API_HOMEASSISTANT_STATES // Pointers and pointer-like types first (4 bytes each) std::unique_ptr socket_ = nullptr; #ifdef USE_API_CLIENT_CONNECTED_TRIGGER @@ -218,6 +249,7 @@ class APIServer : public Component, // 4-byte aligned types uint32_t reboot_timeout_{300000}; + uint32_t last_connected_{0}; // Vectors and strings (12 bytes each on 32-bit) std::vector> clients_; @@ -230,6 +262,17 @@ class APIServer : public Component, #endif #ifdef USE_API_USER_DEFINED_ACTIONS std::vector user_services_; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + // Active action calls - supports concurrent calls from multiple clients + // Uses server-generated action_call_id to avoid collisions when multiple clients use same call_id + struct ActiveActionCall { + uint32_t action_call_id; // Server-generated unique ID (passed to actions) + uint32_t client_call_id; // Client's original call_id (used in response) + APIConnection *connection; + }; + std::vector active_action_calls_; + uint32_t next_action_call_id_{1}; // Counter for generating unique action_call_ids +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES #endif #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES struct PendingActionResponse { diff --git a/esphome/components/api/custom_api_device.h b/esphome/components/api/custom_api_device.h index 1006d07533..5e9165326d 100644 --- a/esphome/components/api/custom_api_device.h +++ b/esphome/components/api/custom_api_device.h @@ -16,7 +16,10 @@ template class CustomAPIDeviceService : public UserS : UserServiceDynamic(name, arg_names), obj_(obj), callback_(callback) {} protected: - void execute(Ts... x) override { (this->obj_->*this->callback_)(x...); } // NOLINT + // CustomAPIDevice services don't support action responses - ignore call_id and return_response + void execute(uint32_t /*call_id*/, bool /*return_response*/, Ts... x) override { + (this->obj_->*this->callback_)(x...); // NOLINT + } T *obj_; void (T::*callback_)(Ts...); diff --git a/esphome/components/api/homeassistant_service.h b/esphome/components/api/homeassistant_service.h index d00e9e6257..2da6e15362 100644 --- a/esphome/components/api/homeassistant_service.h +++ b/esphome/components/api/homeassistant_service.h @@ -12,10 +12,17 @@ #endif #include "esphome/core/automation.h" #include "esphome/core/helpers.h" +#include "esphome/core/string_ref.h" namespace esphome::api { template class TemplatableStringValue : public TemplatableValue { + // Verify that const char* uses the base class STATIC_STRING optimization (no heap allocation) + // rather than being wrapped in a lambda. The base class constructor for const char* is more + // specialized than the templated constructor here, so it should be selected. + static_assert(std::is_constructible_v, const char *>, + "Base class must have const char* constructor for STATIC_STRING optimization"); + private: // Helper to convert value to string - handles the case where value is already a string template static std::string value_to_string(T &&val) { return to_string(std::forward(val)); } @@ -46,23 +53,25 @@ template class TemplatableKeyValuePair { // Keys are always string literals from YAML dictionary keys (e.g., "code", "event") // and never templatable values or lambdas. Only the value parameter can be a lambda/template. - // Using pass-by-value with std::move allows optimal performance for both lvalues and rvalues. - template TemplatableKeyValuePair(std::string key, T value) : key(std::move(key)), value(value) {} + // Using const char* avoids std::string heap allocation - keys remain in flash. + template TemplatableKeyValuePair(const char *key, T value) : key(key), value(value) {} - std::string key; + const char *key{nullptr}; TemplatableStringValue value; }; #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES // Represents the response data from a Home Assistant action +// Note: This class holds a StringRef to the error_message from the protobuf message. +// The protobuf message must outlive the ActionResponse (which is guaranteed since +// the callback is invoked synchronously while the message is on the stack). class ActionResponse { public: - ActionResponse(bool success, std::string error_message = "") - : success_(success), error_message_(std::move(error_message)) {} + ActionResponse(bool success, const std::string &error_message) : success_(success), error_message_(error_message) {} #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON - ActionResponse(bool success, std::string error_message, const uint8_t *data, size_t data_len) - : success_(success), error_message_(std::move(error_message)) { + ActionResponse(bool success, const std::string &error_message, const uint8_t *data, size_t data_len) + : success_(success), error_message_(error_message) { if (data == nullptr || data_len == 0) return; this->json_document_ = json::parse_json(data, data_len); @@ -70,7 +79,8 @@ class ActionResponse { #endif bool is_success() const { return this->success_; } - const std::string &get_error_message() const { return this->error_message_; } + // Returns reference to error message - can be implicitly converted to std::string if needed + const StringRef &get_error_message() const { return this->error_message_; } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON // Get data as parsed JSON object (const version returns read-only view) @@ -79,7 +89,7 @@ class ActionResponse { protected: bool success_; - std::string error_message_; + StringRef error_message_; #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES_JSON JsonDocument json_document_; #endif @@ -105,14 +115,15 @@ template class HomeAssistantServiceCallAction : public Action void add_data(K &&key, V &&value) { - this->add_kv_(this->data_, std::forward(key), std::forward(value)); + // Using const char* for keys avoids std::string heap allocation - keys remain in flash. + template void add_data(const char *key, V &&value) { + this->add_kv_(this->data_, key, std::forward(value)); } - template void add_data_template(K &&key, V &&value) { - this->add_kv_(this->data_template_, std::forward(key), std::forward(value)); + template void add_data_template(const char *key, V &&value) { + this->add_kv_(this->data_template_, key, std::forward(value)); } - template void add_variable(K &&key, V &&value) { - this->add_kv_(this->variables_, std::forward(key), std::forward(value)); + template void add_variable(const char *key, V &&value) { + this->add_kv_(this->variables_, key, std::forward(value)); } #ifdef USE_API_HOMEASSISTANT_ACTION_RESPONSES @@ -185,10 +196,11 @@ template class HomeAssistantServiceCallAction : public Action void add_kv_(FixedVector> &vec, K &&key, V &&value) { + // Helper to add key-value pairs to FixedVectors + // Keys are always string literals (const char*), values can be lambdas/templates + template void add_kv_(FixedVector> &vec, const char *key, V &&value) { auto &kv = vec.emplace_back(); - kv.key = std::forward(key); + kv.key = key; kv.value = std::forward(value); } diff --git a/esphome/components/api/list_entities.cpp b/esphome/components/api/list_entities.cpp index e18fc17801..b4d1454153 100644 --- a/esphome/components/api/list_entities.cpp +++ b/esphome/components/api/list_entities.cpp @@ -5,6 +5,9 @@ #include "esphome/core/application.h" #include "esphome/core/log.h" #include "esphome/core/util.h" +#ifdef USE_API_USER_DEFINED_ACTIONS +#include "user_services.h" +#endif namespace esphome::api { diff --git a/esphome/components/api/user_services.h b/esphome/components/api/user_services.h index d9c13c520b..001add626f 100644 --- a/esphome/components/api/user_services.h +++ b/esphome/components/api/user_services.h @@ -1,20 +1,31 @@ #pragma once +#include #include #include -#include "esphome/core/component.h" -#include "esphome/core/automation.h" #include "api_pb2.h" +#include "esphome/core/automation.h" +#include "esphome/core/component.h" +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON +#include "esphome/components/json/json_util.h" +#endif #ifdef USE_API_USER_DEFINED_ACTIONS namespace esphome::api { +// Forward declaration - full definition in api_server.h +class APIServer; + class UserServiceDescriptor { public: virtual ListEntitiesServicesResponse encode_list_service_response() = 0; virtual bool execute_service(const ExecuteServiceRequest &req) = 0; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + // Overload that accepts server-generated action_call_id (avoids client call_id collisions) + virtual bool execute_service(const ExecuteServiceRequest &req, uint32_t action_call_id) = 0; +#endif bool is_internal() { return false; } }; @@ -27,8 +38,9 @@ template enums::ServiceArgType to_service_arg_type(); // Stores only pointers to string literals in flash - no heap allocation template class UserServiceBase : public UserServiceDescriptor { public: - UserServiceBase(const char *name, const std::array &arg_names) - : name_(name), arg_names_(arg_names) { + UserServiceBase(const char *name, const std::array &arg_names, + enums::SupportsResponseType supports_response = enums::SUPPORTS_RESPONSE_NONE) + : name_(name), arg_names_(arg_names), supports_response_(supports_response) { this->key_ = fnv1_hash(name); } @@ -36,6 +48,7 @@ template class UserServiceBase : public UserServiceDescriptor { ListEntitiesServicesResponse msg; msg.set_name(StringRef(this->name_)); msg.key = this->key_; + msg.supports_response = this->supports_response_; std::array arg_types = {to_service_arg_type()...}; msg.args.init(sizeof...(Ts)); for (size_t i = 0; i < sizeof...(Ts); i++) { @@ -51,21 +64,37 @@ template class UserServiceBase : public UserServiceDescriptor { return false; if (req.args.size() != sizeof...(Ts)) return false; - this->execute_(req.args, std::make_index_sequence{}); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + this->execute_(req.args, req.call_id, req.return_response, std::make_index_sequence{}); +#else + this->execute_(req.args, 0, false, std::make_index_sequence{}); +#endif return true; } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + bool execute_service(const ExecuteServiceRequest &req, uint32_t action_call_id) override { + if (req.key != this->key_) + return false; + if (req.args.size() != sizeof...(Ts)) + return false; + this->execute_(req.args, action_call_id, req.return_response, std::make_index_sequence{}); + return true; + } +#endif + protected: - virtual void execute(Ts... x) = 0; + virtual void execute(uint32_t call_id, bool return_response, Ts... x) = 0; template - void execute_(const ArgsContainer &args, std::index_sequence type) { - this->execute((get_execute_arg_value(args[S]))...); + void execute_(const ArgsContainer &args, uint32_t call_id, bool return_response, std::index_sequence /*type*/) { + this->execute(call_id, return_response, (get_execute_arg_value(args[S]))...); } // Pointers to string literals in flash - no heap allocation const char *name_; std::array arg_names_; uint32_t key_{0}; + enums::SupportsResponseType supports_response_{enums::SUPPORTS_RESPONSE_NONE}; }; // Separate class for custom_api_device services (rare case) @@ -81,6 +110,7 @@ template class UserServiceDynamic : public UserServiceDescriptor ListEntitiesServicesResponse msg; msg.set_name(StringRef(this->name_)); msg.key = this->key_; + msg.supports_response = enums::SUPPORTS_RESPONSE_NONE; // Dynamic services don't support responses yet std::array arg_types = {to_service_arg_type()...}; msg.args.init(sizeof...(Ts)); for (size_t i = 0; i < sizeof...(Ts); i++) { @@ -96,15 +126,31 @@ template class UserServiceDynamic : public UserServiceDescriptor return false; if (req.args.size() != sizeof...(Ts)) return false; - this->execute_(req.args, std::make_index_sequence{}); +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + this->execute_(req.args, req.call_id, req.return_response, std::make_index_sequence{}); +#else + this->execute_(req.args, 0, false, std::make_index_sequence{}); +#endif return true; } +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES + // Dynamic services don't support responses yet, but need to implement the interface + bool execute_service(const ExecuteServiceRequest &req, uint32_t action_call_id) override { + if (req.key != this->key_) + return false; + if (req.args.size() != sizeof...(Ts)) + return false; + this->execute_(req.args, action_call_id, req.return_response, std::make_index_sequence{}); + return true; + } +#endif + protected: - virtual void execute(Ts... x) = 0; + virtual void execute(uint32_t call_id, bool return_response, Ts... x) = 0; template - void execute_(const ArgsContainer &args, std::index_sequence type) { - this->execute((get_execute_arg_value(args[S]))...); + void execute_(const ArgsContainer &args, uint32_t call_id, bool return_response, std::index_sequence /*type*/) { + this->execute(call_id, return_response, (get_execute_arg_value(args[S]))...); } // Heap-allocated strings for runtime-generated names @@ -113,15 +159,149 @@ template class UserServiceDynamic : public UserServiceDescriptor uint32_t key_{0}; }; -template class UserServiceTrigger : public UserServiceBase, public Trigger { +// Primary template declaration +template class UserServiceTrigger; + +// Specialization for NONE - no extra trigger arguments +template +class UserServiceTrigger : public UserServiceBase, public Trigger { public: - // Constructor for static names (YAML-defined services - used by code generator) UserServiceTrigger(const char *name, const std::array &arg_names) - : UserServiceBase(name, arg_names) {} + : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_NONE) {} protected: - void execute(Ts... x) override { this->trigger(x...); } // NOLINT + void execute(uint32_t /*call_id*/, bool /*return_response*/, Ts... x) override { this->trigger(x...); } +}; + +// Specialization for OPTIONAL - call_id and return_response trigger arguments +template +class UserServiceTrigger : public UserServiceBase, + public Trigger { + public: + UserServiceTrigger(const char *name, const std::array &arg_names) + : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_OPTIONAL) {} + + protected: + void execute(uint32_t call_id, bool return_response, Ts... x) override { + this->trigger(call_id, return_response, x...); + } +}; + +// Specialization for ONLY - just call_id trigger argument +template +class UserServiceTrigger : public UserServiceBase, + public Trigger { + public: + UserServiceTrigger(const char *name, const std::array &arg_names) + : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_ONLY) {} + + protected: + void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); } +}; + +// Specialization for STATUS - just call_id trigger argument (reports success/error without data) +template +class UserServiceTrigger : public UserServiceBase, + public Trigger { + public: + UserServiceTrigger(const char *name, const std::array &arg_names) + : UserServiceBase(name, arg_names, enums::SUPPORTS_RESPONSE_STATUS) {} + + protected: + void execute(uint32_t call_id, bool /*return_response*/, Ts... x) override { this->trigger(call_id, x...); } }; } // namespace esphome::api #endif // USE_API_USER_DEFINED_ACTIONS + +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES +// Include full definition of APIServer for template implementation +// Must be outside namespace to avoid including STL headers inside namespace +#include "api_server.h" + +namespace esphome::api { + +template class APIRespondAction : public Action { + public: + explicit APIRespondAction(APIServer *parent) : parent_(parent) {} + + template void set_success(V success) { this->success_ = success; } + template void set_error_message(V error) { this->error_message_ = error; } + void set_is_optional_mode(bool is_optional) { this->is_optional_mode_ = is_optional; } + +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + void set_data(std::function func) { + this->json_builder_ = std::move(func); + this->has_data_ = true; + } +#endif + + void play(const Ts &...x) override { + // Extract call_id from first argument - it's always first for optional/only/status modes + auto args = std::make_tuple(x...); + uint32_t call_id = std::get<0>(args); + + bool success = this->success_.value(x...); + std::string error_message = this->error_message_.value(x...); + +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + if (this->has_data_) { + // For optional mode, check return_response (second arg) to decide if client wants data + // Use nested if constexpr to avoid compile error when tuple doesn't have enough elements + // (std::tuple_element_t is evaluated before the && short-circuit, so we must nest) + if constexpr (sizeof...(Ts) >= 2) { + if constexpr (std::is_same_v>, bool>) { + if (this->is_optional_mode_) { + bool return_response = std::get<1>(args); + if (!return_response) { + // Client doesn't want response data, just send success/error + this->parent_->send_action_response(call_id, success, error_message); + return; + } + } + } + } + // Build and send JSON response + json::JsonBuilder builder; + this->json_builder_(x..., builder.root()); + std::string json_str = builder.serialize(); + this->parent_->send_action_response(call_id, success, error_message, + reinterpret_cast(json_str.data()), json_str.size()); + return; + } +#endif + this->parent_->send_action_response(call_id, success, error_message); + } + + protected: + APIServer *parent_; + TemplatableValue success_{true}; + TemplatableValue error_message_{""}; +#ifdef USE_API_USER_DEFINED_ACTION_RESPONSES_JSON + std::function json_builder_; + bool has_data_{false}; +#endif + bool is_optional_mode_{false}; +}; + +// Action to unregister a service call after execution completes +// Automatically appended to the end of action lists for non-none response modes +template class APIUnregisterServiceCallAction : public Action { + public: + explicit APIUnregisterServiceCallAction(APIServer *parent) : parent_(parent) {} + + void play(const Ts &...x) override { + // Extract call_id from first argument - same convention as APIRespondAction + auto args = std::make_tuple(x...); + uint32_t call_id = std::get<0>(args); + if (call_id != 0) { + this->parent_->unregister_active_action_call(call_id); + } + } + + protected: + APIServer *parent_; +}; + +} // namespace esphome::api +#endif // USE_API_USER_DEFINED_ACTION_RESPONSES diff --git a/esphome/components/bedjet/climate/__init__.py b/esphome/components/bedjet/climate/__init__.py index e9c5510256..0da2107d43 100644 --- a/esphome/components/bedjet/climate/__init__.py +++ b/esphome/components/bedjet/climate/__init__.py @@ -44,7 +44,7 @@ CONFIG_SCHEMA = ( cv.Optional(ble_client.CONF_BLE_CLIENT_ID): cv.invalid( "The 'ble_client_id' option has been removed. Please migrate " "to the new `bedjet_id` option in the `bedjet` component.\n" - "See https://esphome.io/components/climate/bedjet.html" + "See https://esphome.io/components/climate/bedjet/" ), cv.Optional(CONF_TIME_ID): cv.invalid( "The 'time_id' option has been moved to the `bedjet` component." diff --git a/esphome/components/binary_sensor/binary_sensor.cpp b/esphome/components/binary_sensor/binary_sensor.cpp index 92b8db5c51..86b7350aa8 100644 --- a/esphome/components/binary_sensor/binary_sensor.cpp +++ b/esphome/components/binary_sensor/binary_sensor.cpp @@ -34,13 +34,20 @@ void BinarySensor::publish_initial_state(bool new_state) { void BinarySensor::send_state_internal(bool new_state) { // copy the new state to the visible property for backwards compatibility, before any callbacks this->state = new_state; - // Note that set_state_ de-dups and will only trigger callbacks if the state has actually changed - if (this->set_state_(new_state)) { - ESP_LOGD(TAG, "'%s': New state is %s", this->get_name().c_str(), ONOFF(new_state)); + // Note that set_new_state_ de-dups and will only trigger callbacks if the state has actually changed + this->set_new_state(new_state); +} + +bool BinarySensor::set_new_state(const optional &new_state) { + if (StatefulEntityBase::set_new_state(new_state)) { + // weirdly, this file could be compiled even without USE_BINARY_SENSOR defined #if defined(USE_BINARY_SENSOR) && defined(USE_CONTROLLER_REGISTRY) ControllerRegistry::notify_binary_sensor_update(this); #endif + ESP_LOGD(TAG, "'%s': %s", this->get_name().c_str(), ONOFFMAYBE(new_state)); + return true; } + return false; } void BinarySensor::add_filter(Filter *filter) { diff --git a/esphome/components/binary_sensor/binary_sensor.h b/esphome/components/binary_sensor/binary_sensor.h index 0dca3e1520..83c992bfed 100644 --- a/esphome/components/binary_sensor/binary_sensor.h +++ b/esphome/components/binary_sensor/binary_sensor.h @@ -61,6 +61,8 @@ class BinarySensor : public StatefulEntityBase, public EntityBase_DeviceCl protected: Filter *filter_list_{nullptr}; + + bool set_new_state(const optional &new_state) override; }; class BinarySensorInitiallyOff : public BinarySensor { diff --git a/esphome/components/bme680_bsec/__init__.py b/esphome/components/bme680_bsec/__init__.py index 8a8d74b5f3..06e641d34d 100644 --- a/esphome/components/bme680_bsec/__init__.py +++ b/esphome/components/bme680_bsec/__init__.py @@ -69,7 +69,7 @@ CONFIG_SCHEMA = cv.All( cv.only_on_esp8266, cv.All( cv.only_on_esp32, - esp32.only_on_variant(supported=[esp32.const.VARIANT_ESP32]), + esp32.only_on_variant(supported=[esp32.VARIANT_ESP32]), ), ), ) diff --git a/esphome/components/captive_portal/captive_portal.cpp b/esphome/components/captive_portal/captive_portal.cpp index 4eb00835b1..e1f92d2d2b 100644 --- a/esphome/components/captive_portal/captive_portal.cpp +++ b/esphome/components/captive_portal/captive_portal.cpp @@ -65,12 +65,6 @@ 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 ae9b9dfba0..f48c286f0c 100644 --- a/esphome/components/captive_portal/captive_portal.h +++ b/esphome/components/captive_portal/captive_portal.h @@ -40,10 +40,6 @@ 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/cc1101/__init__.py b/esphome/components/cc1101/__init__.py new file mode 100644 index 0000000000..1971817fb1 --- /dev/null +++ b/esphome/components/cc1101/__init__.py @@ -0,0 +1,311 @@ +from esphome import automation, pins +from esphome.automation import maybe_simple_id +import esphome.codegen as cg +from esphome.components import spi +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET +import esphome.config_validation as cv +from esphome.const import ( + CONF_CHANNEL, + CONF_DATA, + CONF_FREQUENCY, + CONF_ID, + CONF_WAIT_TIME, +) +from esphome.core import ID + +CODEOWNERS = ["@lygris", "@gabest11"] +DEPENDENCIES = ["spi"] +MULTI_CONF = True + +ns = cg.esphome_ns.namespace("cc1101") +CC1101Component = ns.class_("CC1101Component", cg.Component, spi.SPIDevice) + +# Config keys +CONF_OUTPUT_POWER = "output_power" +CONF_RX_ATTENUATION = "rx_attenuation" +CONF_DC_BLOCKING_FILTER = "dc_blocking_filter" +CONF_IF_FREQUENCY = "if_frequency" +CONF_FILTER_BANDWIDTH = "filter_bandwidth" +CONF_CHANNEL_SPACING = "channel_spacing" +CONF_FSK_DEVIATION = "fsk_deviation" +CONF_MSK_DEVIATION = "msk_deviation" +CONF_SYMBOL_RATE = "symbol_rate" +CONF_SYNC_MODE = "sync_mode" +CONF_CARRIER_SENSE_ABOVE_THRESHOLD = "carrier_sense_above_threshold" +CONF_MODULATION_TYPE = "modulation_type" +CONF_MANCHESTER = "manchester" +CONF_NUM_PREAMBLE = "num_preamble" +CONF_SYNC1 = "sync1" +CONF_SYNC0 = "sync0" +CONF_MAGN_TARGET = "magn_target" +CONF_MAX_LNA_GAIN = "max_lna_gain" +CONF_MAX_DVGA_GAIN = "max_dvga_gain" +CONF_CARRIER_SENSE_ABS_THR = "carrier_sense_abs_thr" +CONF_CARRIER_SENSE_REL_THR = "carrier_sense_rel_thr" +CONF_LNA_PRIORITY = "lna_priority" +CONF_FILTER_LENGTH_FSK_MSK = "filter_length_fsk_msk" +CONF_FILTER_LENGTH_ASK_OOK = "filter_length_ask_ook" +CONF_FREEZE = "freeze" +CONF_HYST_LEVEL = "hyst_level" + +# Packet mode config keys +CONF_PACKET_MODE = "packet_mode" +CONF_PACKET_LENGTH = "packet_length" +CONF_WHITENING = "whitening" +CONF_GDO0_PIN = "gdo0_pin" + +# Enums +SyncMode = ns.enum("SyncMode", True) +SYNC_MODE = { + "None": SyncMode.SYNC_MODE_NONE, + "15/16": SyncMode.SYNC_MODE_15_16, + "16/16": SyncMode.SYNC_MODE_16_16, + "30/32": SyncMode.SYNC_MODE_30_32, +} + +Modulation = ns.enum("Modulation", True) +MODULATION = { + "2-FSK": Modulation.MODULATION_2_FSK, + "GFSK": Modulation.MODULATION_GFSK, + "ASK/OOK": Modulation.MODULATION_ASK_OOK, + "4-FSK": Modulation.MODULATION_4_FSK, + "MSK": Modulation.MODULATION_MSK, +} + +RxAttenuation = ns.enum("RxAttenuation", True) +RX_ATTENUATION = { + "0dB": RxAttenuation.RX_ATTENUATION_0DB, + "6dB": RxAttenuation.RX_ATTENUATION_6DB, + "12dB": RxAttenuation.RX_ATTENUATION_12DB, + "18dB": RxAttenuation.RX_ATTENUATION_18DB, +} + +MagnTarget = ns.enum("MagnTarget", True) +MAGN_TARGET = { + "24dB": MagnTarget.MAGN_TARGET_24DB, + "27dB": MagnTarget.MAGN_TARGET_27DB, + "30dB": MagnTarget.MAGN_TARGET_30DB, + "33dB": MagnTarget.MAGN_TARGET_33DB, + "36dB": MagnTarget.MAGN_TARGET_36DB, + "38dB": MagnTarget.MAGN_TARGET_38DB, + "40dB": MagnTarget.MAGN_TARGET_40DB, + "42dB": MagnTarget.MAGN_TARGET_42DB, +} + +MaxLnaGain = ns.enum("MaxLnaGain", True) +MAX_LNA_GAIN = { + "Default": MaxLnaGain.MAX_LNA_GAIN_DEFAULT, + "2.6dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_2P6DB, + "6.1dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_6P1DB, + "7.4dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_7P4DB, + "9.2dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_9P2DB, + "11.5dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_11P5DB, + "14.6dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_14P6DB, + "17.1dB": MaxLnaGain.MAX_LNA_GAIN_MINUS_17P1DB, +} + +MaxDvgaGain = ns.enum("MaxDvgaGain", True) +MAX_DVGA_GAIN = { + "Default": MaxDvgaGain.MAX_DVGA_GAIN_DEFAULT, + "-1": MaxDvgaGain.MAX_DVGA_GAIN_MINUS_1, + "-2": MaxDvgaGain.MAX_DVGA_GAIN_MINUS_2, + "-3": MaxDvgaGain.MAX_DVGA_GAIN_MINUS_3, +} + +CarrierSenseRelThr = ns.enum("CarrierSenseRelThr", True) +CARRIER_SENSE_REL_THR = { + "Default": CarrierSenseRelThr.CARRIER_SENSE_REL_THR_DEFAULT, + "+6dB": CarrierSenseRelThr.CARRIER_SENSE_REL_THR_PLUS_6DB, + "+10dB": CarrierSenseRelThr.CARRIER_SENSE_REL_THR_PLUS_10DB, + "+14dB": CarrierSenseRelThr.CARRIER_SENSE_REL_THR_PLUS_14DB, +} + +FilterLengthFskMsk = ns.enum("FilterLengthFskMsk", True) +FILTER_LENGTH_FSK_MSK = { + "8": FilterLengthFskMsk.FILTER_LENGTH_8DB, + "16": FilterLengthFskMsk.FILTER_LENGTH_16DB, + "32": FilterLengthFskMsk.FILTER_LENGTH_32DB, + "64": FilterLengthFskMsk.FILTER_LENGTH_64DB, +} + +FilterLengthAskOok = ns.enum("FilterLengthAskOok", True) +FILTER_LENGTH_ASK_OOK = { + "4dB": FilterLengthAskOok.FILTER_LENGTH_4DB, + "8dB": FilterLengthAskOok.FILTER_LENGTH_8DB, + "12dB": FilterLengthAskOok.FILTER_LENGTH_12DB, + "16dB": FilterLengthAskOok.FILTER_LENGTH_16DB, +} + +Freeze = ns.enum("Freeze", True) +FREEZE = { + "Default": Freeze.FREEZE_DEFAULT, + "On Sync": Freeze.FREEZE_ON_SYNC, + "Analog Only": Freeze.FREEZE_ANALOG_ONLY, + "Analog And Digital": Freeze.FREEZE_ANALOG_AND_DIGITAL, +} + +WaitTime = ns.enum("WaitTime", True) +WAIT_TIME = { + "8": WaitTime.WAIT_TIME_8_SAMPLES, + "16": WaitTime.WAIT_TIME_16_SAMPLES, + "24": WaitTime.WAIT_TIME_24_SAMPLES, + "32": WaitTime.WAIT_TIME_32_SAMPLES, +} + +HystLevel = ns.enum("HystLevel", True) +HYST_LEVEL = { + "None": HystLevel.HYST_LEVEL_NONE, + "Low": HystLevel.HYST_LEVEL_LOW, + "Medium": HystLevel.HYST_LEVEL_MEDIUM, + "High": HystLevel.HYST_LEVEL_HIGH, +} + +# Config key -> Validator mapping +CONFIG_MAP = { + CONF_OUTPUT_POWER: cv.float_range(min=-30.0, max=11.0), + CONF_RX_ATTENUATION: cv.enum(RX_ATTENUATION, upper=False), + CONF_DC_BLOCKING_FILTER: cv.boolean, + CONF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=300000000, max=928000000)), + CONF_IF_FREQUENCY: cv.All(cv.frequency, cv.float_range(min=25000, max=788000)), + CONF_FILTER_BANDWIDTH: cv.All(cv.frequency, cv.float_range(min=58000, max=812000)), + CONF_CHANNEL: cv.uint8_t, + CONF_CHANNEL_SPACING: cv.All(cv.frequency, cv.float_range(min=25000, max=405000)), + CONF_FSK_DEVIATION: cv.All(cv.frequency, cv.float_range(min=1500, max=381000)), + CONF_MSK_DEVIATION: cv.int_range(min=1, max=8), + CONF_SYMBOL_RATE: cv.float_range(min=600, max=500000), + CONF_SYNC_MODE: cv.enum(SYNC_MODE, upper=False), + CONF_CARRIER_SENSE_ABOVE_THRESHOLD: cv.boolean, + CONF_MODULATION_TYPE: cv.enum(MODULATION, upper=False), + CONF_MANCHESTER: cv.boolean, + CONF_NUM_PREAMBLE: cv.int_range(min=0, max=7), + CONF_SYNC1: cv.hex_uint8_t, + CONF_SYNC0: cv.hex_uint8_t, + CONF_MAGN_TARGET: cv.enum(MAGN_TARGET, upper=False), + CONF_MAX_LNA_GAIN: cv.enum(MAX_LNA_GAIN, upper=False), + CONF_MAX_DVGA_GAIN: cv.enum(MAX_DVGA_GAIN, upper=False), + CONF_CARRIER_SENSE_ABS_THR: cv.int_range(min=-8, max=7), + CONF_CARRIER_SENSE_REL_THR: cv.enum(CARRIER_SENSE_REL_THR, upper=False), + CONF_LNA_PRIORITY: cv.boolean, + CONF_FILTER_LENGTH_FSK_MSK: cv.enum(FILTER_LENGTH_FSK_MSK, upper=False), + CONF_FILTER_LENGTH_ASK_OOK: cv.enum(FILTER_LENGTH_ASK_OOK, upper=False), + CONF_FREEZE: cv.enum(FREEZE, upper=False), + CONF_WAIT_TIME: cv.enum(WAIT_TIME, upper=False), + CONF_HYST_LEVEL: cv.enum(HYST_LEVEL, upper=False), + CONF_PACKET_MODE: cv.boolean, + CONF_PACKET_LENGTH: cv.uint8_t, + CONF_CRC_ENABLE: cv.boolean, + CONF_WHITENING: cv.boolean, +} + + +def _validate_packet_mode(config): + if config.get(CONF_PACKET_MODE, False): + if CONF_GDO0_PIN not in config: + raise cv.Invalid("gdo0_pin is required when packet_mode is enabled") + if CONF_PACKET_LENGTH not in config: + raise cv.Invalid("packet_length is required when packet_mode is enabled") + if config[CONF_PACKET_LENGTH] > 64: + raise cv.Invalid("packet_length must be <= 64 (FIFO size)") + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(CC1101Component), + cv.Optional(CONF_GDO0_PIN): pins.internal_gpio_input_pin_schema, + cv.Optional(CONF_ON_PACKET): automation.validate_automation(single=True), + } + ) + .extend({cv.Optional(key): validator for key, validator in CONFIG_MAP.items()}) + .extend(cv.COMPONENT_SCHEMA) + .extend(spi.spi_device_schema(cs_pin_required=True)), + _validate_packet_mode, +) + + +async def to_code(config): + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + await spi.register_spi_device(var, config) + + for key in CONFIG_MAP: + if key in config: + cg.add(getattr(var, f"set_{key}")(config[key])) + + if CONF_GDO0_PIN in config: + gdo0_pin = await cg.gpio_pin_expression(config[CONF_GDO0_PIN]) + cg.add(var.set_gdo0_pin(gdo0_pin)) + if CONF_ON_PACKET in config: + await automation.build_automation( + var.get_packet_trigger(), + [ + (cg.std_vector.template(cg.uint8), "x"), + (cg.float_, "rssi"), + (cg.uint8, "lqi"), + ], + config[CONF_ON_PACKET], + ) + + +# Actions +BeginTxAction = ns.class_("BeginTxAction", automation.Action) +BeginRxAction = ns.class_("BeginRxAction", automation.Action) +ResetAction = ns.class_("ResetAction", automation.Action) +SetIdleAction = ns.class_("SetIdleAction", automation.Action) +SendPacketAction = ns.class_( + "SendPacketAction", automation.Action, cg.Parented.template(CC1101Component) +) + +CC1101_ACTION_SCHEMA = cv.Schema( + maybe_simple_id({cv.GenerateID(CONF_ID): cv.use_id(CC1101Component)}) +) + + +@automation.register_action("cc1101.begin_tx", BeginTxAction, CC1101_ACTION_SCHEMA) +@automation.register_action("cc1101.begin_rx", BeginRxAction, CC1101_ACTION_SCHEMA) +@automation.register_action("cc1101.reset", ResetAction, CC1101_ACTION_SCHEMA) +@automation.register_action("cc1101.set_idle", SetIdleAction, CC1101_ACTION_SCHEMA) +async def cc1101_action_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 + + +def validate_raw_data(value): + if isinstance(value, str): + return value.encode("utf-8") + if isinstance(value, list): + return cv.Schema([cv.hex_uint8_t])(value) + raise cv.Invalid( + "data must either be a string wrapped in quotes or a list of bytes" + ) + + +SEND_PACKET_ACTION_SCHEMA = cv.maybe_simple_value( + { + cv.GenerateID(): cv.use_id(CC1101Component), + cv.Required(CONF_DATA): cv.templatable(validate_raw_data), + }, + key=CONF_DATA, +) + + +@automation.register_action( + "cc1101.send_packet", SendPacketAction, SEND_PACKET_ACTION_SCHEMA +) +async def send_packet_action_to_code(config, action_id, template_arg, args): + var = cg.new_Pvariable(action_id, template_arg) + await cg.register_parented(var, config[CONF_ID]) + data = config[CONF_DATA] + if isinstance(data, bytes): + data = list(data) + if cg.is_template(data): + templ = await cg.templatable(data, args, cg.std_vector.template(cg.uint8)) + cg.add(var.set_data_template(templ)) + else: + # Generate static array in flash to avoid RAM copy + arr_id = ID(f"{action_id}_data", is_declaration=True, type=cg.uint8) + arr = cg.static_const_array(arr_id, cg.ArrayInitializer(*data)) + cg.add(var.set_data_static(arr, len(data))) + return var diff --git a/esphome/components/cc1101/cc1101.cpp b/esphome/components/cc1101/cc1101.cpp new file mode 100644 index 0000000000..5b6eb545bc --- /dev/null +++ b/esphome/components/cc1101/cc1101.cpp @@ -0,0 +1,674 @@ +#include "cc1101.h" +#include "cc1101pa.h" +#include "esphome/core/helpers.h" +#include "esphome/core/log.h" +#include + +namespace esphome::cc1101 { + +static const char *const TAG = "cc1101"; + +static void split_float(float value, int mbits, uint8_t &e, uint32_t &m) { + int e_tmp; + float m_tmp = std::frexp(value, &e_tmp); + if (e_tmp <= mbits) { + e = 0; + m = 0; + return; + } + e = static_cast(e_tmp - mbits - 1); + m = static_cast(((m_tmp * 2 - 1) * (1 << (mbits + 1))) + 1) >> 1; + if (m == (1UL << mbits)) { + e = e + 1; + m = 0; + } +} + +CC1101Component::CC1101Component() { + // Datasheet defaults + memset(&this->state_, 0, sizeof(this->state_)); + this->state_.GDO2_CFG = 0x0D; // Serial Data (for RX on GDO2) + this->state_.GDO1_CFG = 0x2E; + this->state_.GDO0_CFG = 0x0D; // Serial Data (for RX on GDO0 / TX Input) + this->state_.FIFO_THR = 7; + this->state_.SYNC1 = 0xD3; + this->state_.SYNC0 = 0x91; + this->state_.PKTLEN = 0xFF; + this->state_.APPEND_STATUS = 1; + this->state_.LENGTH_CONFIG = 1; + this->state_.CRC_EN = 1; + this->state_.WHITE_DATA = 1; + this->state_.FREQ_IF = 0x0F; + this->state_.FREQ2 = 0x1E; + this->state_.FREQ1 = 0xC4; + this->state_.FREQ0 = 0xEC; + this->state_.DRATE_E = 0x0C; + this->state_.CHANBW_E = 0x02; + this->state_.DRATE_M = 0x22; + this->state_.SYNC_MODE = 2; + this->state_.CHANSPC_E = 2; + this->state_.NUM_PREAMBLE = 2; + this->state_.CHANSPC_M = 0xF8; + this->state_.DEVIATION_M = 7; + this->state_.DEVIATION_E = 4; + this->state_.RX_TIME = 7; + this->state_.CCA_MODE = 3; + this->state_.PO_TIMEOUT = 1; + this->state_.FOC_LIMIT = 2; + this->state_.FOC_POST_K = 1; + this->state_.FOC_PRE_K = 2; + this->state_.FOC_BS_CS_GATE = 1; + this->state_.BS_POST_KP = 1; + this->state_.BS_POST_KI = 1; + this->state_.BS_PRE_KP = 2; + this->state_.BS_PRE_KI = 1; + this->state_.MAGN_TARGET = 3; + this->state_.AGC_LNA_PRIORITY = 1; + this->state_.FILTER_LENGTH = 1; + this->state_.WAIT_TIME = 1; + this->state_.HYST_LEVEL = 2; + this->state_.WOREVT1 = 0x87; + this->state_.WOREVT0 = 0x6B; + this->state_.RC_CAL = 1; + this->state_.EVENT1 = 7; + this->state_.RC_PD = 1; + this->state_.MIX_CURRENT = 2; + this->state_.LODIV_BUF_CURRENT_RX = 1; + this->state_.LNA2MIX_CURRENT = 1; + this->state_.LNA_CURRENT = 1; + this->state_.LODIV_BUF_CURRENT_TX = 1; + this->state_.FSCAL3_LO = 9; + this->state_.CHP_CURR_CAL_EN = 2; + this->state_.FSCAL3_HI = 2; + this->state_.FSCAL2 = 0x0A; + this->state_.FSCAL1 = 0x20; + this->state_.FSCAL0 = 0x0D; + this->state_.RCCTRL1 = 0x41; + this->state_.FSTEST = 0x59; + this->state_.PTEST = 0x7F; + this->state_.AGCTEST = 0x3F; + this->state_.TEST2 = 0x88; + this->state_.TEST1 = 0x31; + this->state_.TEST0_LO = 1; + this->state_.VCO_SEL_CAL_EN = 1; + this->state_.TEST0_HI = 2; + + // PKTCTRL0 + this->state_.PKT_FORMAT = 3; + this->state_.LENGTH_CONFIG = 2; + this->state_.FS_AUTOCAL = 1; + + // Default Settings + this->set_frequency(433920); + this->set_if_frequency(153); + this->set_filter_bandwidth(203); + this->set_channel(0); + this->set_channel_spacing(200); + this->set_symbol_rate(5000); + this->set_sync_mode(SyncMode::SYNC_MODE_NONE); + this->set_carrier_sense_above_threshold(true); + this->set_modulation_type(Modulation::MODULATION_ASK_OOK); + this->set_magn_target(MagnTarget::MAGN_TARGET_42DB); + this->set_max_lna_gain(MaxLnaGain::MAX_LNA_GAIN_DEFAULT); + this->set_max_dvga_gain(MaxDvgaGain::MAX_DVGA_GAIN_MINUS_3); + this->set_lna_priority(false); + this->set_wait_time(WaitTime::WAIT_TIME_32_SAMPLES); + + // CRITICAL: Initialize PA Table to avoid transmitting 0 power (Silence) + memset(this->pa_table_, 0, sizeof(this->pa_table_)); + this->set_output_power(10.0f); +} + +void CC1101Component::setup() { + this->spi_setup(); + this->cs_->digital_write(true); + delayMicroseconds(1); + this->cs_->digital_write(false); + delayMicroseconds(1); + this->cs_->digital_write(true); + delayMicroseconds(41); + this->cs_->digital_write(false); + delay(5); + + this->strobe_(Command::RES); + delay(5); + + this->read_(Register::PARTNUM); + this->read_(Register::VERSION); + this->chip_id_ = encode_uint16(this->state_.PARTNUM, this->state_.VERSION); + ESP_LOGD(TAG, "CC1101 found! Chip ID: 0x%04X", this->chip_id_); + if (this->state_.VERSION == 0 || this->state_.PARTNUM == 0xFF) { + ESP_LOGE(TAG, "Failed to verify CC1101."); + this->mark_failed(); + return; + } + + // Setup GDO0 pin if configured + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->setup(); + } + + this->initialized_ = true; + + for (uint8_t i = 0; i <= static_cast(Register::TEST0); i++) { + if (i == static_cast(Register::FSTEST) || i == static_cast(Register::AGCTEST)) { + continue; + } + this->write_(static_cast(i)); + } + this->set_output_power(this->output_power_requested_); + this->strobe_(Command::RX); + + // Defer pin mode setup until after all components have completed setup() + // This handles the case where remote_transmitter runs after CC1101 and changes pin mode + if (this->gdo0_pin_ != nullptr) { + this->defer([this]() { this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); }); + } +} + +void CC1101Component::loop() { + if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO) || this->gdo0_pin_ == nullptr || + !this->gdo0_pin_->digital_read()) { + return; + } + + // Read state + this->read_(Register::RXBYTES); + uint8_t rx_bytes = this->state_.NUM_RXBYTES; + bool overflow = this->state_.RXFIFO_OVERFLOW; + if (overflow || rx_bytes == 0) { + ESP_LOGW(TAG, "RX FIFO overflow, flushing"); + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return; + } + + // Read packet + uint8_t payload_length; + if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { + this->read_(Register::FIFO, &payload_length, 1); + } else { + payload_length = this->state_.PKTLEN; + } + if (payload_length == 0 || payload_length > 64) { + ESP_LOGW(TAG, "Invalid payload length: %u", payload_length); + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return; + } + this->packet_.resize(payload_length); + this->read_(Register::FIFO, this->packet_.data(), payload_length); + + // Read status and trigger + uint8_t status[2]; + this->read_(Register::FIFO, status, 2); + int8_t rssi_raw = static_cast(status[0]); + float rssi = (rssi_raw * RSSI_STEP) - RSSI_OFFSET; + bool crc_ok = (status[1] & STATUS_CRC_OK_MASK) != 0; + uint8_t lqi = status[1] & STATUS_LQI_MASK; + if (this->state_.CRC_EN == 0 || crc_ok) { + this->packet_trigger_->trigger(this->packet_, rssi, lqi); + } + + // Return to rx + this->enter_idle_(); + this->strobe_(Command::FRX); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); +} + +void CC1101Component::dump_config() { + static const char *const MODULATION_NAMES[] = {"2-FSK", "GFSK", "UNUSED", "ASK/OOK", + "4-FSK", "UNUSED", "UNUSED", "MSK"}; + int32_t freq = static_cast(this->state_.FREQ2 << 16 | this->state_.FREQ1 << 8 | this->state_.FREQ0) * + XTAL_FREQUENCY / (1 << 16); + float symbol_rate = (((256.0f + this->state_.DRATE_M) * (1 << this->state_.DRATE_E)) / (1 << 28)) * XTAL_FREQUENCY; + float bw = XTAL_FREQUENCY / (8.0f * (4 + this->state_.CHANBW_M) * (1 << this->state_.CHANBW_E)); + ESP_LOGCONFIG(TAG, "CC1101:"); + LOG_PIN(" CS Pin: ", this->cs_); + ESP_LOGCONFIG(TAG, + " Chip ID: 0x%04X\n" + " Frequency: %" PRId32 " Hz\n" + " Channel: %u\n" + " Modulation: %s\n" + " Symbol Rate: %.0f baud\n" + " Filter Bandwidth: %.1f Hz\n" + " Output Power: %.1f dBm", + this->chip_id_, freq, this->state_.CHANNR, MODULATION_NAMES[this->state_.MOD_FORMAT & 0x07], + symbol_rate, bw, this->output_power_effective_); +} + +void CC1101Component::begin_tx() { + // Ensure Packet Format is 3 (Async Serial) + this->write_(Register::PKTCTRL0, 0x32); + ESP_LOGV(TAG, "Beginning TX sequence"); + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->pin_mode(gpio::FLAG_OUTPUT); + } + this->strobe_(Command::TX); + if (!this->wait_for_state_(State::TX, 50)) { + ESP_LOGW(TAG, "Timed out waiting for TX state!"); + } +} + +void CC1101Component::begin_rx() { + ESP_LOGV(TAG, "Beginning RX sequence"); + if (this->gdo0_pin_ != nullptr) { + this->gdo0_pin_->pin_mode(gpio::FLAG_INPUT); + } + this->strobe_(Command::RX); +} + +void CC1101Component::reset() { + this->strobe_(Command::RES); + this->setup(); +} + +void CC1101Component::set_idle() { + ESP_LOGV(TAG, "Setting IDLE state"); + this->enter_idle_(); +} + +bool CC1101Component::wait_for_state_(State target_state, uint32_t timeout_ms) { + uint32_t start = millis(); + while (millis() - start < timeout_ms) { + this->read_(Register::MARCSTATE); + State s = static_cast(this->state_.MARC_STATE); + if (s == target_state) { + return true; + } + delayMicroseconds(100); + } + return false; +} + +void CC1101Component::enter_idle_() { + this->strobe_(Command::IDLE); + this->wait_for_state_(State::IDLE); +} + +uint8_t CC1101Component::strobe_(Command cmd) { + uint8_t index = static_cast(cmd); + if (cmd < Command::RES || cmd > Command::NOP) { + return 0xFF; + } + this->enable(); + uint8_t status_byte = this->transfer_byte(index); + this->disable(); + return status_byte; +} + +void CC1101Component::write_(Register reg) { + uint8_t index = static_cast(reg); + this->enable(); + this->write_byte(index); + this->write_array(&this->state_.regs()[index], 1); + this->disable(); +} + +void CC1101Component::write_(Register reg, uint8_t value) { + uint8_t index = static_cast(reg); + this->state_.regs()[index] = value; + this->write_(reg); +} + +void CC1101Component::write_(Register reg, const uint8_t *buffer, size_t length) { + uint8_t index = static_cast(reg); + this->enable(); + this->write_byte(index | BUS_WRITE | BUS_BURST); + this->write_array(buffer, length); + this->disable(); +} + +void CC1101Component::read_(Register reg) { + uint8_t index = static_cast(reg); + this->enable(); + this->write_byte(index | BUS_READ | BUS_BURST); + this->state_.regs()[index] = this->transfer_byte(0); + this->disable(); +} + +void CC1101Component::read_(Register reg, uint8_t *buffer, size_t length) { + uint8_t index = static_cast(reg); + this->enable(); + this->write_byte(index | BUS_READ | BUS_BURST); + this->read_array(buffer, length); + this->disable(); +} + +CC1101Error CC1101Component::transmit_packet(const std::vector &packet) { + if (this->state_.PKT_FORMAT != static_cast(PacketFormat::PACKET_FORMAT_FIFO)) { + return CC1101Error::PARAMS; + } + + // Write packet + this->enter_idle_(); + this->strobe_(Command::FTX); + if (this->state_.LENGTH_CONFIG == static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE)) { + this->write_(Register::FIFO, static_cast(packet.size())); + } + this->write_(Register::FIFO, packet.data(), packet.size()); + this->strobe_(Command::TX); + if (!this->wait_for_state_(State::IDLE, 1000)) { + ESP_LOGW(TAG, "TX timeout"); + this->enter_idle_(); + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return CC1101Error::TIMEOUT; + } + + // Return to rx + this->strobe_(Command::RX); + this->wait_for_state_(State::RX); + return CC1101Error::NONE; +} + +// Setters +void CC1101Component::set_output_power(float value) { + this->output_power_requested_ = value; + int32_t freq = static_cast(this->state_.FREQ2 << 16 | this->state_.FREQ1 << 8 | this->state_.FREQ0) * + XTAL_FREQUENCY / (1 << 16); + uint8_t a = 0xC0; + if (freq >= 300000000 && freq <= 348000000) { + a = PowerTableItem::find(PA_TABLE_315, sizeof(PA_TABLE_315) / sizeof(PA_TABLE_315[0]), value); + } else if (freq >= 378000000 && freq <= 464000000) { + a = PowerTableItem::find(PA_TABLE_433, sizeof(PA_TABLE_433) / sizeof(PA_TABLE_433[0]), value); + } else if (freq >= 779000000 && freq < 900000000) { + a = PowerTableItem::find(PA_TABLE_868, sizeof(PA_TABLE_868) / sizeof(PA_TABLE_868[0]), value); + } else if (freq >= 900000000 && freq <= 928000000) { + a = PowerTableItem::find(PA_TABLE_915, sizeof(PA_TABLE_915) / sizeof(PA_TABLE_915[0]), value); + } + + if (static_cast(this->state_.MOD_FORMAT) == Modulation::MODULATION_ASK_OOK) { + this->pa_table_[0] = 0; + this->pa_table_[1] = a; + } else { + this->pa_table_[0] = a; + this->pa_table_[1] = 0; + } + this->output_power_effective_ = value; + if (this->initialized_) { + this->write_(Register::PATABLE, this->pa_table_, sizeof(this->pa_table_)); + } +} + +void CC1101Component::set_rx_attenuation(RxAttenuation value) { + this->state_.CLOSE_IN_RX = static_cast(value); + if (this->initialized_) { + this->write_(Register::FIFOTHR); + } +} + +void CC1101Component::set_dc_blocking_filter(bool value) { + this->state_.DEM_DCFILT_OFF = value ? 0 : 1; + if (this->initialized_) { + this->write_(Register::MDMCFG2); + } +} + +void CC1101Component::set_frequency(float value) { + int32_t freq = static_cast(value * (1 << 16) / XTAL_FREQUENCY); + this->state_.FREQ2 = static_cast(freq >> 16); + this->state_.FREQ1 = static_cast(freq >> 8); + this->state_.FREQ0 = static_cast(freq); + if (this->initialized_) { + this->enter_idle_(); + this->write_(Register::FREQ2); + this->write_(Register::FREQ1); + this->write_(Register::FREQ0); + this->strobe_(Command::RX); + } +} + +void CC1101Component::set_if_frequency(float value) { + this->state_.FREQ_IF = value * (1 << 10) / XTAL_FREQUENCY; + if (this->initialized_) { + this->write_(Register::FSCTRL1); + } +} + +void CC1101Component::set_filter_bandwidth(float value) { + uint8_t e; + uint32_t m; + split_float(XTAL_FREQUENCY / (value * 8), 2, e, m); + this->state_.CHANBW_E = e; + this->state_.CHANBW_M = static_cast(m); + if (this->initialized_) { + this->write_(Register::MDMCFG4); + } +} + +void CC1101Component::set_channel(uint8_t value) { + this->state_.CHANNR = value; + if (this->initialized_) { + this->enter_idle_(); + this->write_(Register::CHANNR); + this->strobe_(Command::RX); + } +} + +void CC1101Component::set_channel_spacing(float value) { + uint8_t e; + uint32_t m; + split_float(value * (1 << 18) / XTAL_FREQUENCY, 8, e, m); + this->state_.CHANSPC_E = e; + this->state_.CHANSPC_M = static_cast(m); + if (this->initialized_) { + this->write_(Register::MDMCFG1); + this->write_(Register::MDMCFG0); + } +} + +void CC1101Component::set_fsk_deviation(float value) { + uint8_t e; + uint32_t m; + split_float(value * (1 << 17) / XTAL_FREQUENCY, 3, e, m); + this->state_.DEVIATION_E = e; + this->state_.DEVIATION_M = static_cast(m); + if (this->initialized_) { + this->write_(Register::DEVIATN); + } +} + +void CC1101Component::set_msk_deviation(uint8_t value) { + this->state_.DEVIATION_E = 0; + this->state_.DEVIATION_M = value - 1; + if (this->initialized_) { + this->write_(Register::DEVIATN); + } +} + +void CC1101Component::set_symbol_rate(float value) { + uint8_t e; + uint32_t m; + split_float(value * (1 << 28) / XTAL_FREQUENCY, 8, e, m); + this->state_.DRATE_E = e; + this->state_.DRATE_M = static_cast(m); + if (this->initialized_) { + this->write_(Register::MDMCFG4); + this->write_(Register::MDMCFG3); + } +} + +void CC1101Component::set_sync_mode(SyncMode value) { + this->state_.SYNC_MODE = static_cast(value); + if (this->initialized_) { + this->write_(Register::MDMCFG2); + } +} + +void CC1101Component::set_carrier_sense_above_threshold(bool value) { + this->state_.CARRIER_SENSE_ABOVE_THRESHOLD = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::MDMCFG2); + } +} + +void CC1101Component::set_modulation_type(Modulation value) { + this->state_.MOD_FORMAT = static_cast(value); + this->state_.PA_POWER = value == Modulation::MODULATION_ASK_OOK ? 1 : 0; + if (this->initialized_) { + this->enter_idle_(); + this->set_output_power(this->output_power_requested_); + this->write_(Register::MDMCFG2); + this->write_(Register::FREND0); + this->strobe_(Command::RX); + } +} + +void CC1101Component::set_manchester(bool value) { + this->state_.MANCHESTER_EN = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::MDMCFG2); + } +} + +void CC1101Component::set_num_preamble(uint8_t value) { + this->state_.NUM_PREAMBLE = value; + if (this->initialized_) { + this->write_(Register::MDMCFG1); + } +} + +void CC1101Component::set_sync1(uint8_t value) { + this->state_.SYNC1 = value; + if (this->initialized_) { + this->write_(Register::SYNC1); + } +} + +void CC1101Component::set_sync0(uint8_t value) { + this->state_.SYNC0 = value; + if (this->initialized_) { + this->write_(Register::SYNC0); + } +} + +void CC1101Component::set_magn_target(MagnTarget value) { + this->state_.MAGN_TARGET = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL2); + } +} + +void CC1101Component::set_max_lna_gain(MaxLnaGain value) { + this->state_.MAX_LNA_GAIN = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL2); + } +} + +void CC1101Component::set_max_dvga_gain(MaxDvgaGain value) { + this->state_.MAX_DVGA_GAIN = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL2); + } +} + +void CC1101Component::set_carrier_sense_abs_thr(int8_t value) { + this->state_.CARRIER_SENSE_ABS_THR = static_cast(value & 0b1111); + if (this->initialized_) { + this->write_(Register::AGCCTRL1); + } +} + +void CC1101Component::set_carrier_sense_rel_thr(CarrierSenseRelThr value) { + this->state_.CARRIER_SENSE_REL_THR = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL1); + } +} + +void CC1101Component::set_lna_priority(bool value) { + this->state_.AGC_LNA_PRIORITY = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::AGCCTRL1); + } +} + +void CC1101Component::set_filter_length_fsk_msk(FilterLengthFskMsk value) { + this->state_.FILTER_LENGTH = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL0); + } +} + +void CC1101Component::set_filter_length_ask_ook(FilterLengthAskOok value) { + this->state_.FILTER_LENGTH = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL0); + } +} + +void CC1101Component::set_freeze(Freeze value) { + this->state_.AGC_FREEZE = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL0); + } +} + +void CC1101Component::set_wait_time(WaitTime value) { + this->state_.WAIT_TIME = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL0); + } +} + +void CC1101Component::set_hyst_level(HystLevel value) { + this->state_.HYST_LEVEL = static_cast(value); + if (this->initialized_) { + this->write_(Register::AGCCTRL0); + } +} + +void CC1101Component::set_packet_mode(bool value) { + this->state_.PKT_FORMAT = + static_cast(value ? PacketFormat::PACKET_FORMAT_FIFO : PacketFormat::PACKET_FORMAT_ASYNC_SERIAL); + if (value) { + // Configure GDO0 for FIFO status (asserts on RX FIFO threshold or end of packet) + this->state_.GDO0_CFG = 0x01; + // Set max RX FIFO threshold to ensure we only trigger on end-of-packet + this->state_.FIFO_THR = 15; + } else { + // Configure GDO0 for serial data (async serial mode) + this->state_.GDO0_CFG = 0x0D; + } + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + this->write_(Register::IOCFG0); + this->write_(Register::FIFOTHR); + } +} + +void CC1101Component::set_packet_length(uint8_t value) { + if (value == 0) { + this->state_.LENGTH_CONFIG = static_cast(LengthConfig::LENGTH_CONFIG_VARIABLE); + } else { + this->state_.LENGTH_CONFIG = static_cast(LengthConfig::LENGTH_CONFIG_FIXED); + this->state_.PKTLEN = value; + } + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + this->write_(Register::PKTLEN); + } +} + +void CC1101Component::set_crc_enable(bool value) { + this->state_.CRC_EN = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + } +} + +void CC1101Component::set_whitening(bool value) { + this->state_.WHITE_DATA = value ? 1 : 0; + if (this->initialized_) { + this->write_(Register::PKTCTRL0); + } +} + +} // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101.h b/esphome/components/cc1101/cc1101.h new file mode 100644 index 0000000000..b896f7e974 --- /dev/null +++ b/esphome/components/cc1101/cc1101.h @@ -0,0 +1,153 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/components/spi/spi.h" +#include "esphome/core/automation.h" +#include "cc1101defs.h" +#include + +namespace esphome::cc1101 { + +enum class CC1101Error { NONE = 0, TIMEOUT, PARAMS, CRC_ERROR, FIFO_OVERFLOW }; + +class CC1101Component : public Component, + public spi::SPIDevice { + public: + CC1101Component(); + + void setup() override; + void loop() override; + void dump_config() override; + + // Actions + void begin_tx(); + void begin_rx(); + void reset(); + void set_idle(); + + // GDO Pin Configuration + void set_gdo0_pin(InternalGPIOPin *pin) { this->gdo0_pin_ = pin; } + + // Configuration Setters + void set_output_power(float value); + void set_rx_attenuation(RxAttenuation value); + void set_dc_blocking_filter(bool value); + + // Tuner settings + void set_frequency(float value); + void set_if_frequency(float value); + void set_filter_bandwidth(float value); + void set_channel(uint8_t value); + void set_channel_spacing(float value); + void set_fsk_deviation(float value); + void set_msk_deviation(uint8_t value); + void set_symbol_rate(float value); + void set_sync_mode(SyncMode value); + void set_carrier_sense_above_threshold(bool value); + void set_modulation_type(Modulation value); + void set_manchester(bool value); + void set_num_preamble(uint8_t value); + void set_sync1(uint8_t value); + void set_sync0(uint8_t value); + + // AGC settings + void set_magn_target(MagnTarget value); + void set_max_lna_gain(MaxLnaGain value); + void set_max_dvga_gain(MaxDvgaGain value); + void set_carrier_sense_abs_thr(int8_t value); + void set_carrier_sense_rel_thr(CarrierSenseRelThr value); + void set_lna_priority(bool value); + void set_filter_length_fsk_msk(FilterLengthFskMsk value); + void set_filter_length_ask_ook(FilterLengthAskOok value); + void set_freeze(Freeze value); + void set_wait_time(WaitTime value); + void set_hyst_level(HystLevel value); + + // Packet mode settings + void set_packet_mode(bool value); + void set_packet_length(uint8_t value); + void set_crc_enable(bool value); + void set_whitening(bool value); + + // Packet mode operations + CC1101Error transmit_packet(const std::vector &packet); + Trigger, float, uint8_t> *get_packet_trigger() const { return this->packet_trigger_; } + + protected: + uint16_t chip_id_{0}; + bool initialized_{false}; + + float output_power_requested_{10.0f}; + float output_power_effective_{10.0f}; + uint8_t pa_table_[PA_TABLE_SIZE]{}; + + CC1101State state_; + + // GDO pin for packet reception + InternalGPIOPin *gdo0_pin_{nullptr}; + + // Packet handling + Trigger, float, uint8_t> *packet_trigger_{new Trigger, float, uint8_t>()}; + std::vector packet_; + + // Low-level Helpers + uint8_t strobe_(Command cmd); + void write_(Register reg); + void write_(Register reg, uint8_t value); + void write_(Register reg, const uint8_t *buffer, size_t length); + void read_(Register reg); + void read_(Register reg, uint8_t *buffer, size_t length); + + // State Management + bool wait_for_state_(State target_state, uint32_t timeout_ms = 100); + void enter_idle_(); +}; + +// Action Wrappers +template class BeginTxAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->begin_tx(); } +}; + +template class BeginRxAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->begin_rx(); } +}; + +template class ResetAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->reset(); } +}; + +template class SetIdleAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->set_idle(); } +}; + +template class SendPacketAction : public Action, public Parented { + public: + void set_data_template(std::function(Ts...)> func) { this->data_func_ = func; } + void set_data_static(const uint8_t *data, size_t len) { + this->data_static_ = data; + this->data_static_len_ = len; + } + + void play(const Ts &...x) override { + if (this->data_func_) { + auto data = this->data_func_(x...); + this->parent_->transmit_packet(data); + } else if (this->data_static_ != nullptr) { + std::vector data(this->data_static_, this->data_static_ + this->data_static_len_); + this->parent_->transmit_packet(data); + } + } + + protected: + std::function(Ts...)> data_func_{}; + const uint8_t *data_static_{nullptr}; + size_t data_static_len_{0}; +}; + +} // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101defs.h b/esphome/components/cc1101/cc1101defs.h new file mode 100644 index 0000000000..1bc42f5859 --- /dev/null +++ b/esphome/components/cc1101/cc1101defs.h @@ -0,0 +1,667 @@ +#pragma once + +#include + +namespace esphome::cc1101 { + +static constexpr float XTAL_FREQUENCY = 26000000; + +static constexpr float RSSI_OFFSET = 74.0f; +static constexpr float RSSI_STEP = 0.5f; + +static constexpr uint8_t STATUS_CRC_OK_MASK = 0x80; +static constexpr uint8_t STATUS_LQI_MASK = 0x7F; + +static constexpr uint8_t BUS_BURST = 0x40; +static constexpr uint8_t BUS_READ = 0x80; +static constexpr uint8_t BUS_WRITE = 0x00; +static constexpr uint8_t BYTES_IN_RXFIFO = 0x7F; // byte number in RXfifo +static constexpr size_t PA_TABLE_SIZE = 8; + +enum class Register : uint8_t { + IOCFG2 = 0x00, // GDO2 output pin configuration + IOCFG1 = 0x01, // GDO1 output pin configuration + IOCFG0 = 0x02, // GDO0 output pin configuration + FIFOTHR = 0x03, // RX FIFO and TX FIFO thresholds + SYNC1 = 0x04, // Sync word, high INT8U + SYNC0 = 0x05, // Sync word, low INT8U + PKTLEN = 0x06, // Packet length + PKTCTRL1 = 0x07, // Packet automation control + PKTCTRL0 = 0x08, // Packet automation control + ADDR = 0x09, // Device address + CHANNR = 0x0A, // Channel number + FSCTRL1 = 0x0B, // Frequency synthesizer control + FSCTRL0 = 0x0C, // Frequency synthesizer control + FREQ2 = 0x0D, // Frequency control word, high INT8U + FREQ1 = 0x0E, // Frequency control word, middle INT8U + FREQ0 = 0x0F, // Frequency control word, low INT8U + MDMCFG4 = 0x10, // Modem configuration + MDMCFG3 = 0x11, // Modem configuration + MDMCFG2 = 0x12, // Modem configuration + MDMCFG1 = 0x13, // Modem configuration + MDMCFG0 = 0x14, // Modem configuration + DEVIATN = 0x15, // Modem deviation setting + MCSM2 = 0x16, // Main Radio Control State Machine configuration + MCSM1 = 0x17, // Main Radio Control State Machine configuration + MCSM0 = 0x18, // Main Radio Control State Machine configuration + FOCCFG = 0x19, // Frequency Offset Compensation configuration + BSCFG = 0x1A, // Bit Synchronization configuration + AGCCTRL2 = 0x1B, // AGC control + AGCCTRL1 = 0x1C, // AGC control + AGCCTRL0 = 0x1D, // AGC control + WOREVT1 = 0x1E, // High INT8U Event 0 timeout + WOREVT0 = 0x1F, // Low INT8U Event 0 timeout + WORCTRL = 0x20, // Wake On Radio control + FREND1 = 0x21, // Front end RX configuration + FREND0 = 0x22, // Front end TX configuration + FSCAL3 = 0x23, // Frequency synthesizer calibration + FSCAL2 = 0x24, // Frequency synthesizer calibration + FSCAL1 = 0x25, // Frequency synthesizer calibration + FSCAL0 = 0x26, // Frequency synthesizer calibration + RCCTRL1 = 0x27, // RC oscillator configuration + RCCTRL0 = 0x28, // RC oscillator configuration + FSTEST = 0x29, // Frequency synthesizer calibration control + PTEST = 0x2A, // Production test + AGCTEST = 0x2B, // AGC test + TEST2 = 0x2C, // Various test settings + TEST1 = 0x2D, // Various test settings + TEST0 = 0x2E, // Various test settings + UNUSED = 0x2F, + PARTNUM = 0x30, + VERSION = 0x31, + FREQEST = 0x32, + LQI = 0x33, + RSSI = 0x34, + MARCSTATE = 0x35, + WORTIME1 = 0x36, + WORTIME0 = 0x37, + PKTSTATUS = 0x38, + VCO_VC_DAC = 0x39, + TXBYTES = 0x3A, + RXBYTES = 0x3B, + RCCTRL1_STATUS = 0x3C, + RCCTRL0_STATUS = 0x3D, + PATABLE = 0x3E, + FIFO = 0x3F, +}; + +enum class Command : uint8_t { + RES = 0x30, // Reset chip. + FSTXON = 0x31, // Enable and calibrate frequency synthesizer + XOFF = 0x32, // Turn off crystal oscillator. + CAL = 0x33, // Calibrate frequency synthesizer and turn it off + RX = 0x34, // Enable RX. + TX = 0x35, // Enable TX. + IDLE = 0x36, // Exit RX / TX + // 0x37 is RESERVED / UNDEFINED in CC1101 Datasheet + WOR = 0x38, // Start automatic RX polling sequence (Wake-on-Radio) + PWD = 0x39, // Enter power down mode when CSn goes high. + FRX = 0x3A, // Flush the RX FIFO buffer. + FTX = 0x3B, // Flush the TX FIFO buffer. + WORRST = 0x3C, // Reset real time clock. + NOP = 0x3D, // No operation. +}; + +enum class State : uint8_t { + SLEEP, + IDLE, + XOFF, + VCOON_MC, + REGON_MC, + MANCAL, + VCOON, + REGON, + STARTCAL, + BWBOOST, + FS_LOCK, + IFADCON, + ENDCAL, + RX, + RX_END, + RX_RST, + TXRX_SWITCH, + RXFIFO_OVERFLOW, + FSTXON, + TX, + TX_END, + RXTX_SWITCH, + TXFIFO_UNDERFLOW, +}; + +enum class RxAttenuation : uint8_t { + RX_ATTENUATION_0DB, + RX_ATTENUATION_6DB, + RX_ATTENUATION_12DB, + RX_ATTENUATION_18DB, +}; + +enum class SyncMode : uint8_t { + SYNC_MODE_NONE, + SYNC_MODE_15_16, + SYNC_MODE_16_16, + SYNC_MODE_30_32, + SYNC_MODE_NONE_CS, + SYNC_MODE_15_16_CS, + SYNC_MODE_16_16_CS, + SYNC_MODE_30_32_CS, +}; + +enum class Modulation : uint8_t { + MODULATION_2_FSK, + MODULATION_GFSK, + MODULATION_UNUSED_2, + MODULATION_ASK_OOK, + MODULATION_4_FSK, + MODULATION_UNUSED_5, + MODULATION_UNUSED_6, + MODULATION_MSK, +}; + +enum class MagnTarget : uint8_t { + MAGN_TARGET_24DB, + MAGN_TARGET_27DB, + MAGN_TARGET_30DB, + MAGN_TARGET_33DB, + MAGN_TARGET_36DB, + MAGN_TARGET_38DB, + MAGN_TARGET_40DB, + MAGN_TARGET_42DB, +}; + +enum class MaxLnaGain : uint8_t { + MAX_LNA_GAIN_DEFAULT, + MAX_LNA_GAIN_MINUS_2P6DB, + MAX_LNA_GAIN_MINUS_6P1DB, + MAX_LNA_GAIN_MINUS_7P4DB, + MAX_LNA_GAIN_MINUS_9P2DB, + MAX_LNA_GAIN_MINUS_11P5DB, + MAX_LNA_GAIN_MINUS_14P6DB, + MAX_LNA_GAIN_MINUS_17P1DB, +}; + +enum class MaxDvgaGain : uint8_t { + MAX_DVGA_GAIN_DEFAULT, + MAX_DVGA_GAIN_MINUS_1, + MAX_DVGA_GAIN_MINUS_2, + MAX_DVGA_GAIN_MINUS_3, +}; + +enum class CarrierSenseRelThr : uint8_t { + CARRIER_SENSE_REL_THR_DEFAULT, + CARRIER_SENSE_REL_THR_PLUS_6DB, + CARRIER_SENSE_REL_THR_PLUS_10DB, + CARRIER_SENSE_REL_THR_PLUS_14DB, +}; + +enum class FilterLengthFskMsk : uint8_t { + FILTER_LENGTH_8DB, + FILTER_LENGTH_16DB, + FILTER_LENGTH_32DB, + FILTER_LENGTH_64DB, +}; + +enum class FilterLengthAskOok : uint8_t { + FILTER_LENGTH_4DB, + FILTER_LENGTH_8DB, + FILTER_LENGTH_12DB, + FILTER_LENGTH_16DB, +}; + +enum class Freeze : uint8_t { + FREEZE_DEFAULT, + FREEZE_ON_SYNC, + FREEZE_ANALOG_ONLY, + FREEZE_ANALOG_AND_DIGITAL, +}; + +enum class WaitTime : uint8_t { + WAIT_TIME_8_SAMPLES, + WAIT_TIME_16_SAMPLES, + WAIT_TIME_24_SAMPLES, + WAIT_TIME_32_SAMPLES, +}; + +enum class HystLevel : uint8_t { + HYST_LEVEL_NONE, + HYST_LEVEL_LOW, + HYST_LEVEL_MEDIUM, + HYST_LEVEL_HIGH, +}; + +enum class PacketFormat : uint8_t { + PACKET_FORMAT_FIFO, + PACKET_FORMAT_SYNC_SERIAL, + PACKET_FORMAT_RANDOM_TX, + PACKET_FORMAT_ASYNC_SERIAL, +}; + +enum class LengthConfig : uint8_t { + LENGTH_CONFIG_FIXED, + LENGTH_CONFIG_VARIABLE, + LENGTH_CONFIG_INFINITE, +}; + +struct __attribute__((packed)) CC1101State { + // Byte array accessors for bulk SPI transfers + uint8_t *regs() { return reinterpret_cast(this); } + const uint8_t *regs() const { return reinterpret_cast(this); } + + // 0x00 + union { + uint8_t IOCFG2; + struct { + uint8_t GDO2_CFG : 6; + uint8_t GDO2_INV : 1; + uint8_t : 1; + }; + }; + // 0x01 + union { + uint8_t IOCFG1; + struct { + uint8_t GDO1_CFG : 6; + uint8_t GDO1_INV : 1; + uint8_t GDO_DS : 1; // GDO, not GD0 + }; + }; + // 0x02 + union { + uint8_t IOCFG0; + struct { + uint8_t GDO0_CFG : 6; + uint8_t GDO0_INV : 1; + uint8_t TEMP_SENSOR_ENABLE : 1; + }; + }; + // 0x03 + union { + uint8_t FIFOTHR; + struct { + uint8_t FIFO_THR : 4; + uint8_t CLOSE_IN_RX : 2; // RxAttenuation + uint8_t ADC_RETENTION : 1; + uint8_t : 1; + }; + }; + // 0x04 + uint8_t SYNC1; + // 0x05 + uint8_t SYNC0; + // 0x06 + uint8_t PKTLEN; + // 0x07 + union { + uint8_t PKTCTRL1; + struct { + uint8_t ADR_CHK : 2; + uint8_t APPEND_STATUS : 1; + uint8_t CRC_AUTOFLUSH : 1; + uint8_t : 1; + uint8_t PQT : 3; + }; + }; + // 0x08 + union { + uint8_t PKTCTRL0; + struct { + uint8_t LENGTH_CONFIG : 2; + uint8_t CRC_EN : 1; + uint8_t : 1; + uint8_t PKT_FORMAT : 2; + uint8_t WHITE_DATA : 1; + uint8_t : 1; + }; + }; + // 0x09 + uint8_t ADDR; + // 0x0A + uint8_t CHANNR; + // 0x0B + union { + uint8_t FSCTRL1; + struct { + uint8_t FREQ_IF : 5; + uint8_t RESERVED : 1; // hm? + uint8_t : 2; + }; + }; + // 0x0C + uint8_t FSCTRL0; + // 0x0D + uint8_t FREQ2; // [7:6] always zero + // 0x0E + uint8_t FREQ1; + // 0x0F + uint8_t FREQ0; + // 0x10 + union { + uint8_t MDMCFG4; + struct { + uint8_t DRATE_E : 4; + uint8_t CHANBW_M : 2; + uint8_t CHANBW_E : 2; + }; + }; + // 0x11 + union { + uint8_t MDMCFG3; + struct { + uint8_t DRATE_M : 8; + }; + }; + // 0x12 + union { + uint8_t MDMCFG2; + struct { + uint8_t SYNC_MODE : 2; + uint8_t CARRIER_SENSE_ABOVE_THRESHOLD : 1; + uint8_t MANCHESTER_EN : 1; + uint8_t MOD_FORMAT : 3; // Modulation + uint8_t DEM_DCFILT_OFF : 1; + }; + }; + // 0x13 + union { + uint8_t MDMCFG1; + struct { + uint8_t CHANSPC_E : 2; + uint8_t : 2; + uint8_t NUM_PREAMBLE : 3; + uint8_t FEC_EN : 1; + }; + }; + // 0x14 + union { + uint8_t MDMCFG0; + struct { + uint8_t CHANSPC_M : 8; + }; + }; + // 0x15 + union { + uint8_t DEVIATN; + struct { + uint8_t DEVIATION_M : 3; + uint8_t : 1; + uint8_t DEVIATION_E : 3; + uint8_t : 1; + }; + }; + // 0x16 + union { + uint8_t MCSM2; + struct { + uint8_t RX_TIME : 3; + uint8_t RX_TIME_QUAL : 1; + uint8_t RX_TIME_RSSI : 1; + uint8_t : 3; + }; + }; + // 0x17 + union { + uint8_t MCSM1; + struct { + uint8_t TXOFF_MODE : 2; + uint8_t RXOFF_MODE : 2; + uint8_t CCA_MODE : 2; + uint8_t : 2; + }; + }; + // 0x18 + union { + uint8_t MCSM0; + struct { + uint8_t XOSC_FORCE_ON : 1; + uint8_t PIN_CTRL_EN : 1; + uint8_t PO_TIMEOUT : 2; + uint8_t FS_AUTOCAL : 2; + uint8_t : 2; + }; + }; + // 0x19 + union { + uint8_t FOCCFG; + struct { + uint8_t FOC_LIMIT : 2; + uint8_t FOC_POST_K : 1; + uint8_t FOC_PRE_K : 2; + uint8_t FOC_BS_CS_GATE : 1; + uint8_t : 2; + }; + }; + // 0x1A + union { + uint8_t BSCFG; + struct { + uint8_t BS_LIMIT : 2; + uint8_t BS_POST_KP : 1; + uint8_t BS_POST_KI : 1; + uint8_t BS_PRE_KP : 2; + uint8_t BS_PRE_KI : 2; + }; + }; + // 0x1B + union { + uint8_t AGCCTRL2; + struct { + uint8_t MAGN_TARGET : 3; // MagnTarget + uint8_t MAX_LNA_GAIN : 3; // MaxLnaGain + uint8_t MAX_DVGA_GAIN : 2; // MaxDvgaGain + }; + }; + // 0x1C + union { + uint8_t AGCCTRL1; + struct { + uint8_t CARRIER_SENSE_ABS_THR : 4; + uint8_t CARRIER_SENSE_REL_THR : 2; // CarrierSenseRelThr + uint8_t AGC_LNA_PRIORITY : 1; + uint8_t : 1; + }; + }; + // 0x1D + union { + uint8_t AGCCTRL0; + struct { + uint8_t FILTER_LENGTH : 2; // FilterLengthFskMsk or FilterLengthAskOok + uint8_t AGC_FREEZE : 2; // Freeze + uint8_t WAIT_TIME : 2; // WaitTime + uint8_t HYST_LEVEL : 2; // HystLevel + }; + }; + // 0x1E + uint8_t WOREVT1; + // 0x1F + uint8_t WOREVT0; + // 0x20 + union { + uint8_t WORCTRL; + struct { + uint8_t WOR_RES : 2; + uint8_t : 1; + uint8_t RC_CAL : 1; + uint8_t EVENT1 : 3; + uint8_t RC_PD : 1; + }; + }; + // 0x21 + union { + uint8_t FREND1; + struct { + uint8_t MIX_CURRENT : 2; + uint8_t LODIV_BUF_CURRENT_RX : 2; + uint8_t LNA2MIX_CURRENT : 2; + uint8_t LNA_CURRENT : 2; + }; + }; + // 0x22 + union { + uint8_t FREND0; + struct { + uint8_t PA_POWER : 3; + uint8_t : 1; + uint8_t LODIV_BUF_CURRENT_TX : 2; + uint8_t : 2; + }; + }; + // 0x23 + union { + uint8_t FSCAL3; + struct { + uint8_t FSCAL3_LO : 4; + uint8_t CHP_CURR_CAL_EN : 2; // Disable charge pump calibration stage when 0. + uint8_t FSCAL3_HI : 2; + }; + }; + // 0x24 + union { + // uint8_t FSCAL2; + struct { + uint8_t FSCAL2 : 5; + uint8_t VCO_CORE_H_EN : 1; + uint8_t : 2; + }; + }; + // 0x25 + union { + // uint8_t FSCAL1; + struct { + uint8_t FSCAL1 : 6; + uint8_t : 2; + }; + }; + // 0x26 + union { + // uint8_t FSCAL0; + struct { + uint8_t FSCAL0 : 7; + uint8_t : 1; + }; + }; + // 0x27 + union { + // uint8_t RCCTRL1; + struct { + uint8_t RCCTRL1 : 7; + uint8_t : 1; + }; + }; + // 0x28 + union { + // uint8_t RCCTRL0; + struct { + uint8_t RCCTRL0 : 7; + uint8_t : 1; + }; + }; + // 0x29 + uint8_t FSTEST; + // 0x2A + uint8_t PTEST; + // 0x2B + uint8_t AGCTEST; + // 0x2C + uint8_t TEST2; + // 0x2D + uint8_t TEST1; + // 0x2E + union { + uint8_t TEST0; + struct { + uint8_t TEST0_LO : 1; + uint8_t VCO_SEL_CAL_EN : 1; // Enable VCO selection calibration stage when 1 + uint8_t TEST0_HI : 6; + }; + }; + // 0x2F + uint8_t REG_2F; + // 0x30 + uint8_t PARTNUM; + // 0x31 + uint8_t VERSION; + // 0x32 + union { + uint8_t FREQEST; + struct { + int8_t FREQOFF_EST : 8; + }; + }; + // 0x33 + union { + uint8_t LQI; + struct { + uint8_t LQI_EST : 7; + uint8_t LQI_CRC_OK : 1; + }; + }; + // 0x34 + int8_t RSSI; + // 0x35 + union { + // uint8_t MARCSTATE; + struct { + uint8_t MARC_STATE : 5; // State + uint8_t : 3; + }; + }; + // 0x36 + uint8_t WORTIME1; + // 0x37 + uint8_t WORTIME0; + // 0x38 + union { + uint8_t PKTSTATUS; + struct { + uint8_t GDO0 : 1; + uint8_t : 1; + uint8_t GDO2 : 1; + uint8_t SFD : 1; + uint8_t CCA : 1; + uint8_t PQT_REACHED : 1; + uint8_t CS : 1; + uint8_t CRC_OK : 1; // same as LQI_CRC_OK? + }; + }; + // 0x39 + uint8_t VCO_VC_DAC; + // 0x3A + union { + uint8_t TXBYTES; + struct { + uint8_t NUM_TXBYTES : 7; + uint8_t TXFIFO_UNDERFLOW : 1; + }; + }; + // 0x3B + union { + uint8_t RXBYTES; + struct { + uint8_t NUM_RXBYTES : 7; + uint8_t RXFIFO_OVERFLOW : 1; + }; + }; + // 0x3C + union { + // uint8_t RCCTRL1_STATUS; + struct { + uint8_t RCCTRL1_STATUS : 7; + uint8_t : 1; + }; + }; + // 0x3D + union { + // uint8_t RCCTRL0_STATUS; + struct { + uint8_t RCCTRL0_STATUS : 7; + uint8_t : 1; + }; + }; + // 0x3E + uint8_t REG_3E; + // 0x3F + uint8_t REG_3F; +}; + +static_assert(sizeof(CC1101State) == 0x40, "CC1101State size mismatch"); + +} // namespace esphome::cc1101 diff --git a/esphome/components/cc1101/cc1101pa.h b/esphome/components/cc1101/cc1101pa.h new file mode 100644 index 0000000000..e5e7a47c51 --- /dev/null +++ b/esphome/components/cc1101/cc1101pa.h @@ -0,0 +1,174 @@ +#pragma once + +#include +#include +#include + +namespace esphome::cc1101 { + +// CC1101 Design Note DN013 + +struct PowerTableItem { + uint8_t value; + uint8_t dbm_diff; // starts from 12.0, diff to previous entry, scaled by 10 + + static uint8_t find(const PowerTableItem *items, size_t count, float &dbm_target) { + int32_t dbmi = 120; + int32_t dbmi_target = static_cast(std::lround(dbm_target * 10)); + for (size_t i = 0; i < count; i++) { + dbmi -= items[i].dbm_diff; + if (dbmi_target >= dbmi) { + // Skip invalid PA settings (magic numbers derived from TI DN013/SmartRC logic) + if (items[i].value >= 0x61 && items[i].value <= 0x6F) { + continue; + } + dbm_target = static_cast(dbmi) / 10.0f; + return items[i].value; + } + } + dbm_target = -30.0f; + return 0x03; + } +}; + +static const PowerTableItem PA_TABLE_315[] = { + {0xC0, 14}, // C0 10.6 -35.3 -44.4 -57.8 -53.8 -58.3 -57.2 -57.8 -56.7 28.5 + {0xC3, 10}, // C3 9.6 -39.2 -45.3 -59.0 -54.2 -59.0 -57.5 -58.3 -57.2 26.2 + {0xC6, 11}, // C6 8.5 -43.2 -46.3 -59.2 -54.7 -59.1 -57.7 -58.3 -57.4 24.4 + {0xC9, 10}, // C9 7.5 -47.0 -47.3 -58.9 -55.0 -59.0 -57.9 -58.4 -57.5 23.0 + {0x81, 12}, // 81 6.3 -49.2 -45.7 -57.3 -53.6 -59.0 -56.0 -56.5 -57.5 19.5 + {0x85, 13}, // 85 5.0 -51.0 -47.2 -59.8 -54.2 -59.0 -56.9 -57.9 -58.0 18.3 + {0x88, 11}, // 88 3.9 -46.6 -48.1 -60.0 -55.0 -58.9 -57.5 -58.2 -58.2 17.4 + {0xCF, 11}, // CF 2.8 -49.8 -51.3 -57.6 -56.8 -59.1 -58.4 -58.1 -58.3 18.0 + {0x8D, 11}, // 8D 1.7 -43.8 -49.5 -58.9 -56.3 -58.8 -58.2 -58.4 -58.5 15.8 + {0x50, 10}, // 50 0.7 -59.2 -51.2 -59.0 -56.5 -59.0 -58.3 -58.3 -58.2 15.3 + {0x40, 10}, // 40 -0.3 -58.2 -52.1 -59.4 -56.9 -59.0 -58.4 -58.4 -58.3 14.7 + {0x3D, 10}, // 3D -1.3 -54.4 -48.4 -59.8 -57.5 -58.9 -58.3 -58.5 -58.5 19.3 + {0x55, 10}, // 55 -2.3 -56.7 -53.6 -59.7 -57.5 -59.1 -58.7 -58.4 -58.4 13.7 + {0x39, 11}, // 39 -3.4 -50.9 -49.5 -59.8 -58.0 -59.0 -58.5 -58.4 -58.4 16.8 + {0x2B, 15}, // 2B -4.9 -51.2 -50.4 -59.9 -58.0 -58.9 -58.7 -58.3 -58.4 15.6 + {0x29, 16}, // 29 -6.5 -51.8 -51.6 -59.9 -58.4 -59.0 -58.8 -58.3 -58.3 14.7 + {0x28, 10}, // 28 -7.5 -52.2 -52.5 -60.0 -58.6 -59.0 -58.8 -58.2 -58.4 14.3 + {0x27, 11}, // 27 -8.6 -52.9 -53.1 -60.0 -58.8 -59.1 -58.8 -58.3 -58.5 13.9 + {0x26, 12}, // 26 -9.8 -53.6 -54.3 -60.1 -58.7 -59.0 -58.7 -58.4 -58.4 13.4 + {0x25, 13}, // 25 -11.1 -54.3 -55.5 -60.1 -58.8 -59.1 -58.8 -58.4 -58.4 13.0 + {0x33, 11}, // 33 -12.2 -55.0 -56.3 -60.0 -58.7 -59.0 -58.9 -58.4 -58.4 12.8 + {0x1F, 11}, // 1F -13.3 -55.6 -57.2 -60.0 -58.8 -58.9 -58.9 -58.3 -58.4 12.4 + {0x1D, 12}, // 1D -14.5 -56.0 -58.0 -60.0 -58.8 -59.1 -58.7 -58.4 -58.5 12.1 + {0x32, 11}, // 32 -15.6 -56.9 -58.8 -59.9 -58.8 -59.0 -58.8 -58.3 -58.5 12.2 + {0x1A, 10}, // 1A -16.6 -57.3 -59.5 -59.9 -58.8 -59.1 -58.8 -58.4 -58.4 11.8 + {0x18, 19}, // 18 -18.5 -57.8 -60.3 -60.0 -58.8 -59.0 -58.9 -58.2 -58.5 11.6 + {0x17, 11}, // 17 -19.6 -58.7 -60.9 -60.0 -58.7 -58.9 -58.9 -58.5 -58.4 11.4 + {0x0C, 11}, // C -20.7 -59.4 -61.1 -60.0 -58.8 -59.1 -58.9 -58.4 -58.3 11.3 + {0x0A, 15}, // A -22.2 -59.9 -61.9 -60.0 -58.9 -59.0 -58.9 -58.4 -58.5 11.2 + {0x08, 18}, // 8 -24.0 -60.5 -62.5 -60.0 -58.7 -59.1 -58.8 -58.3 -58.5 11.1 + {0x07, 11}, // 7 -25.1 -61.3 -62.9 -60.1 -58.8 -59.1 -58.8 -58.4 -58.4 11.0 + {0x06, 13}, // 6 -26.4 -61.6 -63.2 -60.1 -58.7 -59.0 -58.9 -58.5 -58.5 11.0 + {0x05, 13}, // 5 -27.7 -62.3 -63.4 -60.1 -58.7 -59.2 -58.8 -58.4 -58.5 10.9 + {0x04, 19}, // 4 -29.6 -62.7 -63.6 -59.9 -58.7 -59.0 -58.9 -58.4 -58.4 10.8 +}; + +static const PowerTableItem PA_TABLE_433[] = { + {0xC0, 21}, // C0 9.9 -43.4 -45.0 -53.9 -55.2 -55.8 -52.3 -55.6 29.1 + {0xC3, 11}, // C3 8.8 -49.3 -45.9 -55.9 -55.4 -57.2 -52.6 -57.5 26.9 + {0xC6, 10}, // C6 7.8 -56.2 -46.9 -56.9 -55.6 -58.2 -53.2 -57.9 25.2 + {0xC9, 10}, // C9 6.8 -56.1 -47.9 -57.3 -55.9 -58.5 -54.0 -56.9 23.8 + {0xCC, 10}, // CC 5.8 -52.8 -48.9 -57.0 -56.1 -58.4 -54.6 -56.2 22.6 + {0x85, 10}, // 85 4.8 -54.2 -53.0 -58.3 -55.0 -57.8 -56.8 -58.0 19.1 + {0x88, 12}, // 88 3.6 -56.2 -53.8 -58.3 -55.7 -58.1 -57.2 -58.2 18.2 + {0x8B, 13}, // 8B 2.3 -57.7 -54.5 -58.0 -56.3 -58.1 -57.5 -58.2 17.3 + {0x8E, 19}, // 8E 0.4 -58.0 -55.5 -57.8 -57.4 -58.2 -58.1 -58.4 16.2 + {0x40, 12}, // 40 -0.8 -59.7 -56.1 -58.2 -57.7 -58.4 -58.3 -58.2 15.4 + {0x3C, 13}, // 3C -2.1 -60.6 -57.3 -58.2 -58.0 -58.5 -58.4 -58.5 19.3 + {0x3A, 10}, // 3A -3.1 -59.5 -57.5 -58.3 -58.3 -58.6 -58.1 -58.6 18.1 + {0x8F, 15}, // 8F -4.6 -52.2 -57.7 -58.1 -58.8 -58.4 -58.7 -58.3 14.4 + {0x37, 10}, // 37 -5.6 -56.8 -58.3 -58.3 -58.8 -58.4 -58.5 -58.4 16.2 + {0x36, 12}, // 36 -6.8 -56.8 -58.9 -58.3 -58.8 -58.3 -58.5 -58.5 15.6 + {0x28, 10}, // 28 -7.8 -56.6 -59.0 -58.2 -59.0 -58.4 -58.5 -58.4 15.1 + {0x26, 21}, // 26 -9.9 -57.0 -59.4 -58.3 -59.0 -58.4 -58.7 -58.4 14.3 + {0x25, 15}, // 25 -11.4 -57.3 -59.7 -58.4 -59.0 -58.3 -58.7 -58.5 13.9 + {0x24, 19}, // 24 -13.3 -57.9 -59.9 -58.2 -59.0 -58.6 -58.7 -58.5 13.5 + {0x1E, 10}, // 1E -14.3 -58.4 -59.8 -58.2 -59.0 -58.4 -58.6 -58.6 13.2 + {0x1C, 12}, // 1C -15.5 -58.6 -59.9 -58.4 -58.8 -58.6 -58.8 -58.5 12.9 + {0x1A, 15}, // 1A -17.0 -59.4 -59.9 -58.3 -59.1 -58.5 -58.7 -58.4 12.7 + {0x18, 18}, // 18 -18.8 -60.2 -59.9 -58.2 -59.0 -58.5 -58.7 -58.6 12.5 + {0x17, 10}, // 17 -19.8 -60.6 -59.9 -58.2 -58.9 -58.4 -58.7 -58.4 12.4 + {0x0C, 12}, // C -21.0 -61.1 -59.9 -58.4 -59.0 -58.5 -58.7 -58.6 12.3 + {0x15, 15}, // 15 -22.5 -61.7 -60.0 -58.2 -59.1 -58.3 -58.6 -58.7 12.2 + {0x08, 18}, // 8 -24.3 -62.3 -59.9 -58.3 -59.0 -58.4 -58.8 -58.5 12.1 + {0x07, 10}, // 7 -25.3 -62.6 -59.9 -58.2 -59.0 -58.6 -58.7 -58.5 12.0 + {0x06, 12}, // 6 -26.5 -63.2 -59.9 -58.3 -58.9 -58.5 -58.6 -58.6 12.0 + {0x05, 14}, // 5 -27.9 -63.5 -59.8 -58.3 -59.1 -58.5 -58.7 -58.4 11.9 + {0x04, 16}, // 4 -29.5 -63.7 -59.9 -58.3 -58.9 -58.5 -58.5 -58.5 11.9 +}; + +static const PowerTableItem PA_TABLE_868[] = { + {0xC0, 13}, // C0 10.7 -35.1 -58.6 -58.6 -57.5 -50.0 34.2 + {0xC3, 11}, // C3 9.6 -41.5 -58.5 -58.3 -57.4 -54.4 31.6 + {0xC6, 11}, // C6 8.5 -47.7 -58.5 -58.3 -57.6 -55.0 29.5 + {0xC9, 10}, // C9 7.5 -44.4 -58.5 -58.5 -57.7 -53.6 27.8 + {0xCC, 10}, // CC 6.5 -40.6 -58.6 -58.4 -57.6 -52.5 26.3 + {0xCE, 10}, // CE 5.5 -38.5 -58.5 -58.4 -57.8 -52.2 25.0 + {0x84, 11}, // 84 4.4 -35.3 -58.7 -58.5 -57.8 -55.8 20.3 + {0x87, 10}, // 87 3.4 -39.4 -58.6 -58.6 -57.8 -55.7 19.5 + {0xCF, 10}, // CF 2.4 -36.6 -58.6 -58.4 -57.7 -53.6 22.0 + {0x8C, 13}, // 8C 1.1 -50.2 -58.6 -58.5 -57.7 -55.9 17.9 + {0x50, 14}, // 50 -0.3 -42.1 -58.5 -58.5 -57.6 -57.1 16.9 + {0x40, 12}, // 40 -1.5 -43.2 -58.5 -58.7 -57.7 -57.2 16.1 + {0x3F, 11}, // 3F -2.6 -53.7 -58.6 -58.5 -57.8 -57.5 21.4 + {0x55, 10}, // 55 -3.6 -44.9 -58.6 -58.4 -57.8 -57.5 15.0 + {0x57, 12}, // 57 -4.8 -46.0 -58.6 -58.5 -57.6 -57.4 14.5 + {0x8F, 12}, // 8F -6.0 -51.6 -58.5 -58.6 -57.7 -57.1 15.0 + {0x2A, 14}, // 2A -7.4 -49.3 -58.5 -58.6 -57.7 -57.4 16.2 + {0x28, 16}, // 28 -9.0 -49.0 -58.5 -58.6 -57.7 -57.4 15.4 + {0x26, 20}, // 26 -11.0 -49.2 -58.5 -58.5 -57.7 -57.4 14.6 + {0x25, 15}, // 25 -12.5 -49.5 -58.6 -58.6 -57.8 -57.3 14.1 + {0x24, 18}, // 24 -14.3 -50.2 -58.5 -58.4 -57.8 -57.4 13.7 + {0x1D, 14}, // 1D -15.7 -50.7 -58.6 -58.6 -57.8 -57.5 13.3 + {0x1B, 13}, // 1B -17.0 -51.3 -58.5 -58.4 -57.7 -57.5 13.1 + {0x19, 16}, // 19 -18.6 -52.0 -58.6 -58.5 -57.8 -57.5 12.9 + {0x22, 10}, // 22 -19.6 -52.5 -58.5 -58.6 -57.7 -57.4 12.9 + {0x0D, 15}, // D -21.1 -53.3 -58.6 -58.6 -57.8 -57.4 12.6 + {0x0B, 12}, // B -22.3 -53.9 -58.6 -58.5 -57.8 -57.4 12.5 + {0x09, 15}, // 9 -23.8 -54.7 -58.5 -58.5 -57.8 -57.5 12.4 + {0x21, 10}, // 21 -24.8 -55.1 -58.5 -58.5 -57.7 -57.5 12.5 + {0x13, 17}, // 13 -26.5 -55.9 -58.6 -58.5 -57.6 -57.6 12.3 + {0x05, 12}, // 5 -27.7 -56.4 -58.4 -58.4 -57.7 -57.5 12.2 + {0x12, 12}, // 12 -28.9 -57.1 -58.4 -58.5 -57.7 -57.3 12.2 +}; + +static const PowerTableItem PA_TABLE_915[] = { + {0xC0, 26}, // C0 9.4 -33.5 -58.5 -58.4 -55.8 -32.6 31.8 + {0xC3, 11}, // C3 8.3 -41.5 -58.6 -58.4 -56.3 -38.0 29.3 + {0xC6, 11}, // C6 7.2 -42.5 -58.5 -58.4 -56.7 -40.5 27.4 + {0xC9, 10}, // C9 6.2 -37.6 -58.6 -58.4 -57.2 -38.8 25.9 + {0xCD, 12}, // CD 5.0 -34.2 -58.6 -58.5 -57.5 -37.3 24.3 + {0x84, 11}, // 84 3.9 -32.0 -58.6 -58.4 -57.7 -40.1 19.7 + {0x87, 10}, // 87 2.9 -36.5 -58.4 -58.5 -57.7 -39.6 18.9 + {0x8A, 11}, // 8A 1.8 -42.2 -58.5 -58.4 -57.7 -39.6 18.1 + {0x8D, 13}, // 8D 0.5 -46.8 -58.5 -58.5 -57.7 -40.4 17.3 + {0x8E, 11}, // 8E -0.6 -46.6 -58.5 -58.5 -57.8 -41.1 16.7 + {0x51, 10}, // 51 -1.6 -38.7 -58.4 -58.5 -57.7 -46.9 16.0 + {0x3E, 11}, // 3E -2.7 -50.0 -58.5 -58.4 -57.6 -55.3 20.7 + {0x3B, 11}, // 3B -3.8 -50.7 -58.6 -58.4 -57.6 -55.2 18.9 + {0x39, 13}, // 39 -5.1 -50.0 -58.5 -58.5 -57.6 -54.0 17.7 + {0x2B, 13}, // 2B -6.4 -47.6 -58.4 -58.4 -57.8 -52.1 16.5 + {0x36, 15}, // 36 -7.9 -46.9 -58.5 -58.4 -57.7 -51.2 15.8 + {0x35, 14}, // 35 -9.3 -46.7 -58.6 -58.4 -57.7 -50.7 15.2 + {0x26, 16}, // 26 -10.9 -47.0 -58.6 -58.4 -57.8 -50.9 14.5 + {0x25, 14}, // 25 -12.3 -47.2 -58.6 -58.3 -57.7 -51.0 14.1 + {0x24, 18}, // 24 -14.1 -48.1 -58.4 -58.4 -57.8 -51.4 13.7 + {0x1D, 14}, // 1D -15.5 -48.7 -58.4 -58.5 -57.7 -51.9 13.2 + {0x1B, 13}, // 1B -16.8 -49.3 -58.6 -58.4 -57.8 -52.3 13.0 + {0x19, 15}, // 19 -18.3 -50.2 -58.5 -58.5 -57.6 -52.8 12.8 + {0x18, 10}, // 18 -19.3 -50.6 -58.5 -58.5 -57.7 -53.1 12.7 + {0x17, 10}, // 17 -20.3 -51.2 -58.6 -58.5 -57.8 -53.1 12.6 + {0x0C, 11}, // C -21.4 -51.8 -58.4 -58.5 -57.7 -53.4 12.5 + {0x0A, 13}, // A -22.7 -52.6 -58.5 -58.4 -57.7 -53.6 12.4 + {0x08, 16}, // 8 -24.3 -53.6 -58.4 -58.4 -57.6 -54.1 12.3 + {0x13, 19}, // 13 -26.2 -54.6 -58.4 -58.5 -57.7 -54.3 12.2 + {0x05, 11}, // 5 -27.3 -55.3 -58.4 -58.4 -57.8 -54.5 12.1 + {0x12, 13}, // 12 -28.6 -55.9 -58.6 -58.5 -57.7 -54.7 12.1 + {0x03, 12}, // 3 -29.8 -56.9 -58.5 -58.4 -57.7 -54.7 12.0 +}; +} // namespace esphome::cc1101 diff --git a/esphome/components/climate/__init__.py b/esphome/components/climate/__init__.py index 5824e68141..b8e49db6c0 100644 --- a/esphome/components/climate/__init__.py +++ b/esphome/components/climate/__init__.py @@ -275,10 +275,13 @@ async def setup_climate_core_(var, config): visual = config[CONF_VISUAL] if (min_temp := visual.get(CONF_MIN_TEMPERATURE)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_min_temperature_override(min_temp)) if (max_temp := visual.get(CONF_MAX_TEMPERATURE)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_max_temperature_override(max_temp)) if (temp_step := visual.get(CONF_TEMPERATURE_STEP)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add( var.set_visual_temperature_step_override( temp_step[CONF_TARGET_TEMPERATURE], @@ -286,8 +289,10 @@ async def setup_climate_core_(var, config): ) ) if (min_humidity := visual.get(CONF_MIN_HUMIDITY)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_min_humidity_override(min_humidity)) if (max_humidity := visual.get(CONF_MAX_HUMIDITY)) is not None: + cg.add_define("USE_CLIMATE_VISUAL_OVERRIDES") cg.add(var.set_visual_max_humidity_override(max_humidity)) if (mqtt_id := config.get(CONF_MQTT_ID)) is not None: diff --git a/esphome/components/climate/climate.cpp b/esphome/components/climate/climate.cpp index b0fba6aa62..3bc20a17c6 100644 --- a/esphome/components/climate/climate.cpp +++ b/esphome/components/climate/climate.cpp @@ -473,26 +473,28 @@ void Climate::publish_state() { ClimateTraits Climate::get_traits() { auto traits = this->traits(); - if (this->visual_min_temperature_override_.has_value()) { - traits.set_visual_min_temperature(*this->visual_min_temperature_override_); +#ifdef USE_CLIMATE_VISUAL_OVERRIDES + if (!std::isnan(this->visual_min_temperature_override_)) { + traits.set_visual_min_temperature(this->visual_min_temperature_override_); } - if (this->visual_max_temperature_override_.has_value()) { - traits.set_visual_max_temperature(*this->visual_max_temperature_override_); + if (!std::isnan(this->visual_max_temperature_override_)) { + traits.set_visual_max_temperature(this->visual_max_temperature_override_); } - if (this->visual_target_temperature_step_override_.has_value()) { - traits.set_visual_target_temperature_step(*this->visual_target_temperature_step_override_); - traits.set_visual_current_temperature_step(*this->visual_current_temperature_step_override_); + if (!std::isnan(this->visual_target_temperature_step_override_)) { + traits.set_visual_target_temperature_step(this->visual_target_temperature_step_override_); + traits.set_visual_current_temperature_step(this->visual_current_temperature_step_override_); } - if (this->visual_min_humidity_override_.has_value()) { - traits.set_visual_min_humidity(*this->visual_min_humidity_override_); + if (!std::isnan(this->visual_min_humidity_override_)) { + traits.set_visual_min_humidity(this->visual_min_humidity_override_); } - if (this->visual_max_humidity_override_.has_value()) { - traits.set_visual_max_humidity(*this->visual_max_humidity_override_); + if (!std::isnan(this->visual_max_humidity_override_)) { + traits.set_visual_max_humidity(this->visual_max_humidity_override_); } - +#endif return traits; } +#ifdef USE_CLIMATE_VISUAL_OVERRIDES void Climate::set_visual_min_temperature_override(float visual_min_temperature_override) { this->visual_min_temperature_override_ = visual_min_temperature_override; } @@ -513,6 +515,7 @@ void Climate::set_visual_min_humidity_override(float visual_min_humidity_overrid void Climate::set_visual_max_humidity_override(float visual_max_humidity_override) { this->visual_max_humidity_override_ = visual_max_humidity_override; } +#endif ClimateCall Climate::make_call() { return ClimateCall(this); } diff --git a/esphome/components/climate/climate.h b/esphome/components/climate/climate.h index 28a73d8c05..82df4b815f 100644 --- a/esphome/components/climate/climate.h +++ b/esphome/components/climate/climate.h @@ -213,11 +213,13 @@ class Climate : public EntityBase { */ ClimateTraits get_traits(); +#ifdef USE_CLIMATE_VISUAL_OVERRIDES void set_visual_min_temperature_override(float visual_min_temperature_override); void set_visual_max_temperature_override(float visual_max_temperature_override); void set_visual_temperature_step_override(float target, float current); void set_visual_min_humidity_override(float visual_min_humidity_override); void set_visual_max_humidity_override(float visual_max_humidity_override); +#endif /// Check if a custom fan mode is currently active. bool has_custom_fan_mode() const { return this->custom_fan_mode_ != nullptr; } @@ -321,12 +323,14 @@ class Climate : public EntityBase { CallbackManager state_callback_{}; CallbackManager control_callback_{}; ESPPreferenceObject rtc_; - optional visual_min_temperature_override_{}; - optional visual_max_temperature_override_{}; - optional visual_target_temperature_step_override_{}; - optional visual_current_temperature_step_override_{}; - optional visual_min_humidity_override_{}; - optional visual_max_humidity_override_{}; +#ifdef USE_CLIMATE_VISUAL_OVERRIDES + float visual_min_temperature_override_{NAN}; + float visual_max_temperature_override_{NAN}; + float visual_target_temperature_step_override_{NAN}; + float visual_current_temperature_step_override_{NAN}; + float visual_min_humidity_override_{NAN}; + float visual_max_humidity_override_{NAN}; +#endif private: /** The active custom fan mode (private - enforces use of safe setters). diff --git a/esphome/components/const/__init__.py b/esphome/components/const/__init__.py index 12a69551f5..fcfafa0c1a 100644 --- a/esphome/components/const/__init__.py +++ b/esphome/components/const/__init__.py @@ -7,9 +7,11 @@ BYTE_ORDER_LITTLE = "little_endian" BYTE_ORDER_BIG = "big_endian" CONF_COLOR_DEPTH = "color_depth" +CONF_CRC_ENABLE = "crc_enable" CONF_DRAW_ROUNDING = "draw_rounding" CONF_ENABLED = "enabled" CONF_IGNORE_NOT_FOUND = "ignore_not_found" +CONF_ON_PACKET = "on_packet" CONF_ON_RECEIVE = "on_receive" CONF_ON_STATE_CHANGE = "on_state_change" CONF_REQUEST_HEADERS = "request_headers" diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp index f6280a75a1..5be93692c0 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.cpp @@ -9,27 +9,40 @@ void CST816Touchscreen::continue_setup_() { this->interrupt_pin_->setup(); this->attach_interrupt_(this->interrupt_pin_, gpio::INTERRUPT_FALLING_EDGE); } - if (this->read_byte(REG_CHIP_ID, &this->chip_id_)) { - switch (this->chip_id_) { - case CST820_CHIP_ID: - case CST826_CHIP_ID: - case CST716_CHIP_ID: - case CST816S_CHIP_ID: - case CST816D_CHIP_ID: - case CST816T_CHIP_ID: - break; - default: + + if (!this->read_byte(REG_CHIP_ID, &this->chip_id_) && !this->skip_probe_) { + this->status_set_error(LOG_STR("Failed to read chip ID")); + this->mark_failed(); + return; + } + + // CST826/CST836 return 0 for chip ID, need to read from factory ID register + if (this->chip_id_ == 0) { + if (!this->read_byte(REG_FACTORY_ID, &this->chip_id_) && !this->skip_probe_) { + this->status_set_error(LOG_STR("Failed to read chip ID")); + this->mark_failed(); + return; + } + } + + switch (this->chip_id_) { + case CST716_CHIP_ID: + case CST816S_CHIP_ID: + case CST816D_CHIP_ID: + case CST816T_CHIP_ID: + case CST820_CHIP_ID: + case CST826_CHIP_ID: + case CST836_CHIP_ID: + break; + default: + if (!this->skip_probe_) { ESP_LOGE(TAG, "Unknown chip ID: 0x%02X", this->chip_id_); this->status_set_error(LOG_STR("Unknown chip ID")); this->mark_failed(); return; - } - this->write_byte(REG_IRQ_CTL, IRQ_EN_MOTION); - } else if (!this->skip_probe_) { - this->status_set_error(LOG_STR("Failed to read chip id")); - this->mark_failed(); - return; + } } + this->write_byte(REG_IRQ_CTL, IRQ_EN_MOTION); if (this->x_raw_max_ == this->x_raw_min_) { this->x_raw_max_ = this->display_->get_native_width(); } @@ -80,11 +93,8 @@ void CST816Touchscreen::dump_config() { this->x_raw_min_, this->x_raw_max_, this->y_raw_min_, this->y_raw_max_); const char *name; switch (this->chip_id_) { - case CST820_CHIP_ID: - name = "CST820"; - break; - case CST826_CHIP_ID: - name = "CST826"; + case CST716_CHIP_ID: + name = "CST716"; break; case CST816S_CHIP_ID: name = "CST816S"; @@ -92,12 +102,18 @@ void CST816Touchscreen::dump_config() { case CST816D_CHIP_ID: name = "CST816D"; break; - case CST716_CHIP_ID: - name = "CST716"; - break; case CST816T_CHIP_ID: name = "CST816T"; break; + case CST820_CHIP_ID: + name = "CST820"; + break; + case CST826_CHIP_ID: + name = "CST826"; + break; + case CST836_CHIP_ID: + name = "CST836"; + break; default: name = "Unknown"; break; diff --git a/esphome/components/cst816/touchscreen/cst816_touchscreen.h b/esphome/components/cst816/touchscreen/cst816_touchscreen.h index 99ea085e37..99b93d8342 100644 --- a/esphome/components/cst816/touchscreen/cst816_touchscreen.h +++ b/esphome/components/cst816/touchscreen/cst816_touchscreen.h @@ -19,12 +19,14 @@ static const uint8_t REG_YPOS_HIGH = 0x05; static const uint8_t REG_YPOS_LOW = 0x06; static const uint8_t REG_DIS_AUTOSLEEP = 0xFE; static const uint8_t REG_CHIP_ID = 0xA7; +static const uint8_t REG_FACTORY_ID = 0xAA; static const uint8_t REG_FW_VERSION = 0xA9; static const uint8_t REG_SLEEP = 0xE5; static const uint8_t REG_IRQ_CTL = 0xFA; static const uint8_t IRQ_EN_MOTION = 0x70; static const uint8_t CST826_CHIP_ID = 0x11; +static const uint8_t CST836_CHIP_ID = 0x13; static const uint8_t CST820_CHIP_ID = 0xB7; static const uint8_t CST816S_CHIP_ID = 0xB4; static const uint8_t CST816D_CHIP_ID = 0xB6; diff --git a/esphome/components/deep_sleep/__init__.py b/esphome/components/deep_sleep/__init__.py index 19fb726016..8849fad7d6 100644 --- a/esphome/components/deep_sleep/__init__.py +++ b/esphome/components/deep_sleep/__init__.py @@ -1,15 +1,18 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import esp32, time -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + get_esp32_variant, ) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -52,7 +55,13 @@ WAKEUP_PINS = { 38, 39, ], + VARIANT_ESP32C2: [0, 1, 2, 3, 4, 5], VARIANT_ESP32C3: [0, 1, 2, 3, 4, 5], + VARIANT_ESP32C5: [0, 1, 2, 3, 4, 5, 6, 7], + VARIANT_ESP32C6: [0, 1, 2, 3, 4, 5, 6, 7], + VARIANT_ESP32C61: [0, 1, 2, 3, 4, 5, 6], + VARIANT_ESP32H2: [7, 8, 9, 10, 11, 12, 13, 14], + VARIANT_ESP32P4: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], VARIANT_ESP32S2: [ 0, 1, @@ -101,9 +110,6 @@ WAKEUP_PINS = { 20, 21, ], - VARIANT_ESP32C2: [0, 1, 2, 3, 4, 5], - VARIANT_ESP32C6: [0, 1, 2, 3, 4, 5, 6, 7], - VARIANT_ESP32H2: [7, 8, 9, 10, 11, 12, 13, 14], } @@ -122,10 +128,13 @@ def _validate_ex1_wakeup_mode(value): if value == "ANY_LOW": esp32.only_on_variant( supported=[ + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, - VARIANT_ESP32C6, - VARIANT_ESP32H2, ], msg_prefix="ANY_LOW", )(value) @@ -218,7 +227,9 @@ CONFIG_SCHEMA = cv.All( unsupported=[ VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, ], msg_prefix="Wakeup from touch", diff --git a/esphome/components/deep_sleep/deep_sleep_component.h b/esphome/components/deep_sleep/deep_sleep_component.h index 80381e767c..bca3aa5e4d 100644 --- a/esphome/components/deep_sleep/deep_sleep_component.h +++ b/esphome/components/deep_sleep/deep_sleep_component.h @@ -81,7 +81,7 @@ class DeepSleepComponent : public Component { #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void set_touch_wakeup(bool touch_wakeup); #endif diff --git a/esphome/components/deep_sleep/deep_sleep_esp32.cpp b/esphome/components/deep_sleep/deep_sleep_esp32.cpp index b93d9ce601..833be8e76c 100644 --- a/esphome/components/deep_sleep/deep_sleep_esp32.cpp +++ b/esphome/components/deep_sleep/deep_sleep_esp32.cpp @@ -18,6 +18,7 @@ namespace deep_sleep { // | ESP32-C3 | | | | βœ“ | // | ESP32-C5 | | (βœ“) | | (βœ“) | // | ESP32-C6 | | βœ“ | | βœ“ | +// | ESP32-C61 | | βœ“ | | βœ“ | // | ESP32-H2 | | βœ“ | | | // // Notes: @@ -55,7 +56,7 @@ void DeepSleepComponent::set_ext1_wakeup(Ext1Wakeup ext1_wakeup) { this->ext1_wa #endif #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) void DeepSleepComponent::set_touch_wakeup(bool touch_wakeup) { this->touch_wakeup_ = touch_wakeup; } #endif @@ -121,8 +122,9 @@ void DeepSleepComponent::deep_sleep_() { } #endif - // GPIO wakeup - C2, C3, C6 only -#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) + // GPIO wakeup - C2, C3, C6, C61 only +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32C61) if (this->wakeup_pin_ != nullptr) { const auto gpio_pin = gpio_num_t(this->wakeup_pin_->get_pin()); if (this->wakeup_pin_->get_flags() & gpio::FLAG_PULLUP) { @@ -155,7 +157,7 @@ void DeepSleepComponent::deep_sleep_() { // Touch wakeup - ESP32, S2, S3 only #if !defined(USE_ESP32_VARIANT_ESP32C2) && !defined(USE_ESP32_VARIANT_ESP32C3) && \ - !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32H2) + !defined(USE_ESP32_VARIANT_ESP32C6) && !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32H2) if (this->touch_wakeup_.has_value() && *(this->touch_wakeup_)) { esp_sleep_enable_touchpad_wakeup(); esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON); diff --git a/esphome/components/epaper_spi/display.py b/esphome/components/epaper_spi/display.py index ff5693c206..b7e71a3cae 100644 --- a/esphome/components/epaper_spi/display.py +++ b/esphome/components/epaper_spi/display.py @@ -41,6 +41,7 @@ AUTO_LOAD = ["split_buffer"] DEPENDENCIES = ["spi"] CONF_INIT_SEQUENCE_ID = "init_sequence_id" +CONF_MINIMUM_UPDATE_INTERVAL = "minimum_update_interval" epaper_spi_ns = cg.esphome_ns.namespace("epaper_spi") EPaperBase = epaper_spi_ns.class_( @@ -71,6 +72,9 @@ TRANSFORM_OPTIONS = {CONF_MIRROR_X, CONF_MIRROR_Y, CONF_SWAP_XY} def model_schema(config): model = MODELS[config[CONF_MODEL]] class_name = epaper_spi_ns.class_(model.class_name, EPaperBase) + minimum_update_interval = update_interval( + model.get_default(CONF_MINIMUM_UPDATE_INTERVAL, "1s") + ) cv_dimensions = cv.Optional if model.get_default(CONF_WIDTH) else cv.Required return ( display.FULL_DISPLAY_SCHEMA.extend( @@ -90,9 +94,9 @@ def model_schema(config): { 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_UPDATE_INTERVAL, default=cv.UNDEFINED): cv.All( + update_interval, cv.Range(min=minimum_update_interval) + ), cv.Optional(CONF_TRANSFORM): cv.Schema( { cv.Required(CONF_MIRROR_X): cv.boolean, @@ -153,9 +157,8 @@ def _final_validate(config): 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 + elif CONF_UPDATE_INTERVAL not in config: + config[CONF_UPDATE_INTERVAL] = update_interval("1min") return config diff --git a/esphome/components/epaper_spi/epaper_spi.cpp b/esphome/components/epaper_spi/epaper_spi.cpp index f6313d33ef..b2e58694c8 100644 --- a/esphome/components/epaper_spi/epaper_spi.cpp +++ b/esphome/components/epaper_spi/epaper_spi.cpp @@ -286,7 +286,7 @@ void EPaperBase::initialise_() { * @param y * @return false if the coordinates are out of bounds */ -bool EPaperBase::rotate_coordinates_(int &x, int &y) const { +bool EPaperBase::rotate_coordinates_(int &x, int &y) { if (!this->get_clipping().inside(x, y)) return false; if (this->transform_ & SWAP_XY) @@ -297,6 +297,10 @@ bool EPaperBase::rotate_coordinates_(int &x, int &y) const { y = this->height_ - y - 1; if (x >= this->width_ || y >= this->height_ || x < 0 || y < 0) return false; + 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); return true; } @@ -319,10 +323,6 @@ void HOT EPaperBase::draw_pixel_at(int x, int y, Color color) { } 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() { diff --git a/esphome/components/epaper_spi/epaper_spi.h b/esphome/components/epaper_spi/epaper_spi.h index 544ea3e9ba..6852416cac 100644 --- a/esphome/components/epaper_spi/epaper_spi.h +++ b/esphome/components/epaper_spi/epaper_spi.h @@ -106,7 +106,7 @@ class EPaperBase : public Display, void initialise_(); void wait_for_idle_(bool should_wait); bool init_buffer_(size_t buffer_length); - bool rotate_coordinates_(int &x, int &y) const; + bool rotate_coordinates_(int &x, int &y); /** * Methods that must be implemented by concrete classes to control the display diff --git a/esphome/components/epaper_spi/models/spectra_e6.py b/esphome/components/epaper_spi/models/spectra_e6.py index 42a5a7da72..58015f486e 100644 --- a/esphome/components/epaper_spi/models/spectra_e6.py +++ b/esphome/components/epaper_spi/models/spectra_e6.py @@ -4,8 +4,8 @@ from . import EpaperModel class SpectraE6(EpaperModel): - def __init__(self, name, class_name="EPaperSpectraE6", **kwargs): - super().__init__(name, class_name, **kwargs) + def __init__(self, name, class_name="EPaperSpectraE6", **defaults): + super().__init__(name, class_name, **defaults) # fmt: off def get_init_sequence(self, config: dict): @@ -30,7 +30,7 @@ class SpectraE6(EpaperModel): return self.defaults.get(key, fallback) -spectra_e6 = SpectraE6("spectra-e6") +spectra_e6 = SpectraE6("spectra-e6", minimum_update_interval="30s") spectra_e6_7p3 = spectra_e6.extend( "7.3in-Spectra-E6", diff --git a/esphome/components/es8311/audio_dac.py b/esphome/components/es8311/audio_dac.py index 7d80cfd5fb..5941a81935 100644 --- a/esphome/components/es8311/audio_dac.py +++ b/esphome/components/es8311/audio_dac.py @@ -22,7 +22,6 @@ ES8311_BITS_PER_SAMPLE_ENUM = { es8311_mic_gain = es8311_ns.enum("ES8311MicGain") ES8311_MIC_GAIN_ENUM = { - "MIN": es8311_mic_gain.ES8311_MIC_GAIN_MIN, "0DB": es8311_mic_gain.ES8311_MIC_GAIN_0DB, "6DB": es8311_mic_gain.ES8311_MIC_GAIN_6DB, "12DB": es8311_mic_gain.ES8311_MIC_GAIN_12DB, @@ -31,7 +30,6 @@ ES8311_MIC_GAIN_ENUM = { "30DB": es8311_mic_gain.ES8311_MIC_GAIN_30DB, "36DB": es8311_mic_gain.ES8311_MIC_GAIN_36DB, "42DB": es8311_mic_gain.ES8311_MIC_GAIN_42DB, - "MAX": es8311_mic_gain.ES8311_MIC_GAIN_MAX, } diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index 14db25fd46..0142fd4841 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -4,6 +4,7 @@ import itertools import logging import os from pathlib import Path +import re from esphome import yaml_util import esphome.codegen as cg @@ -59,6 +60,7 @@ from .const import ( # noqa VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -122,14 +124,15 @@ def get_cpu_frequencies(*frequencies): CPU_FREQUENCIES = { VARIANT_ESP32: get_cpu_frequencies(80, 160, 240), - VARIANT_ESP32S2: get_cpu_frequencies(80, 160, 240), - VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32C2: get_cpu_frequencies(80, 120), VARIANT_ESP32C3: get_cpu_frequencies(80, 160), VARIANT_ESP32C5: get_cpu_frequencies(80, 160, 240), VARIANT_ESP32C6: get_cpu_frequencies(80, 120, 160), + VARIANT_ESP32C61: get_cpu_frequencies(80, 120, 160), VARIANT_ESP32H2: get_cpu_frequencies(16, 32, 48, 64, 96), VARIANT_ESP32P4: get_cpu_frequencies(40, 360, 400), + VARIANT_ESP32S2: get_cpu_frequencies(80, 160, 240), + VARIANT_ESP32S3: get_cpu_frequencies(80, 160, 240), } # Make sure not missed here if a new variant added. @@ -614,10 +617,13 @@ def require_vfs_dir() -> None: 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} + # Match operator followed by version-like string (digit or *) + if match := re.search(r"(~=|>=|<=|==|!=|>|<|\^|~)(\d|\*)", value): + return {CONF_NAME: value[: match.start()], CONF_REF: value[match.start() :]} + raise cv.Invalid( + f"Invalid IDF component shorthand '{value}'. " + f"Expected format: 'owner/componentversion' where is one of: ^, ~, ~=, ==, !=, >=, >, <=, <" + ) def _validate_idf_component(config: ConfigType) -> ConfigType: @@ -762,7 +768,7 @@ def _show_framework_migration_message(name: str, variant: str) -> None: + "Need help? Check out the migration guide:\n" + color( AnsiFore.BLUE, - "https://esphome.io/guides/esp32_arduino_to_idf.html", + "https://esphome.io/guides/esp32_arduino_to_idf/", ) ) _LOGGER.warning(message) @@ -907,6 +913,7 @@ async def to_code(config): ) cg.set_cpp_standard("gnu++20") cg.add_build_flag("-DUSE_ESP32") + cg.add_build_flag("-Wl,-z,noexecstack") cg.add_define("ESPHOME_BOARD", config[CONF_BOARD]) variant = config[CONF_VARIANT] cg.add_build_flag(f"-DUSE_ESP32_VARIANT_{variant}") diff --git a/esphome/components/esp32/boards.py b/esphome/components/esp32/boards.py index cbb314650a..514d674b55 100644 --- a/esphome/components/esp32/boards.py +++ b/esphome/components/esp32/boards.py @@ -4,6 +4,7 @@ from .const import ( VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -17,6 +18,7 @@ STANDARD_BOARDS = { VARIANT_ESP32C3: "esp32-c3-devkitm-1", VARIANT_ESP32C5: "esp32-c5-devkitc-1", VARIANT_ESP32C6: "esp32-c6-devkitm-1", + VARIANT_ESP32C61: "esp32-c61-devkitc1-n8r2", VARIANT_ESP32H2: "esp32-h2-devkitm-1", VARIANT_ESP32P4: "esp32-p4-evboard", VARIANT_ESP32S2: "esp32-s2-kaluga-1", @@ -1216,6 +1218,28 @@ ESP32_BOARD_PINS = { "LED_BUILTINB": 4, }, "sensesiot_weizen": {}, + "seeed_xiao_esp32c6": { + "D0": 0, + "D1": 1, + "D2": 2, + "D3": 21, + "D4": 22, + "D5": 23, + "D6": 16, + "D7": 17, + "D8": 19, + "D9": 20, + "D10": 18, + "MTDO": 7, + "MTCK": 6, + "MTDI": 5, + "MTMS": 4, + "BOOT": 9, + "LED": 8, + "LED_BUILTIN": 8, + "RF_SWITCH_EN": 3, + "RF_ANT_SELECT": 14, + }, "sg-o_airMon": {}, "sparkfun_lora_gateway_1-channel": {"MISO": 12, "MOSI": 13, "SCK": 14, "SS": 16}, "tinypico": {}, diff --git a/esphome/components/esp32/const.py b/esphome/components/esp32/const.py index 9bef18847f..dfb736f615 100644 --- a/esphome/components/esp32/const.py +++ b/esphome/components/esp32/const.py @@ -13,36 +13,39 @@ KEY_SUBMODULES = "submodules" KEY_EXTRA_BUILD_FILES = "extra_build_files" VARIANT_ESP32 = "ESP32" -VARIANT_ESP32S2 = "ESP32S2" -VARIANT_ESP32S3 = "ESP32S3" VARIANT_ESP32C2 = "ESP32C2" VARIANT_ESP32C3 = "ESP32C3" VARIANT_ESP32C5 = "ESP32C5" VARIANT_ESP32C6 = "ESP32C6" +VARIANT_ESP32C61 = "ESP32C61" VARIANT_ESP32H2 = "ESP32H2" VARIANT_ESP32P4 = "ESP32P4" +VARIANT_ESP32S2 = "ESP32S2" +VARIANT_ESP32S3 = "ESP32S3" VARIANTS = [ VARIANT_ESP32, - VARIANT_ESP32S2, - VARIANT_ESP32S3, VARIANT_ESP32C2, VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, ] VARIANT_FRIENDLY = { VARIANT_ESP32: "ESP32", - VARIANT_ESP32S2: "ESP32-S2", - VARIANT_ESP32S3: "ESP32-S3", VARIANT_ESP32C2: "ESP32-C2", VARIANT_ESP32C3: "ESP32-C3", VARIANT_ESP32C5: "ESP32-C5", VARIANT_ESP32C6: "ESP32-C6", + VARIANT_ESP32C61: "ESP32-C61", VARIANT_ESP32H2: "ESP32-H2", VARIANT_ESP32P4: "ESP32-P4", + VARIANT_ESP32S2: "ESP32-S2", + VARIANT_ESP32S3: "ESP32-S3", } esp32_ns = cg.esphome_ns.namespace("esp32") diff --git a/esphome/components/esp32/gpio.py b/esphome/components/esp32/gpio.py index 954891ea8d..c0803f40a8 100644 --- a/esphome/components/esp32/gpio.py +++ b/esphome/components/esp32/gpio.py @@ -29,6 +29,7 @@ from .const import ( VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, @@ -40,6 +41,7 @@ from .gpio_esp32_c2 import esp32_c2_validate_gpio_pin, esp32_c2_validate_support from .gpio_esp32_c3 import esp32_c3_validate_gpio_pin, esp32_c3_validate_supports from .gpio_esp32_c5 import esp32_c5_validate_gpio_pin, esp32_c5_validate_supports from .gpio_esp32_c6 import esp32_c6_validate_gpio_pin, esp32_c6_validate_supports +from .gpio_esp32_c61 import esp32_c61_validate_gpio_pin, esp32_c61_validate_supports from .gpio_esp32_h2 import esp32_h2_validate_gpio_pin, esp32_h2_validate_supports from .gpio_esp32_p4 import esp32_p4_validate_gpio_pin, esp32_p4_validate_supports from .gpio_esp32_s2 import esp32_s2_validate_gpio_pin, esp32_s2_validate_supports @@ -110,6 +112,10 @@ _esp32_validations = { pin_validation=esp32_c6_validate_gpio_pin, usage_validation=esp32_c6_validate_supports, ), + VARIANT_ESP32C61: ESP32ValidationFunctions( + pin_validation=esp32_c61_validate_gpio_pin, + usage_validation=esp32_c61_validate_supports, + ), VARIANT_ESP32H2: ESP32ValidationFunctions( pin_validation=esp32_h2_validate_gpio_pin, usage_validation=esp32_h2_validate_supports, diff --git a/esphome/components/esp32/gpio_esp32_c5.py b/esphome/components/esp32/gpio_esp32_c5.py index ada426771c..fa2ce1a689 100644 --- a/esphome/components/esp32/gpio_esp32_c5.py +++ b/esphome/components/esp32/gpio_esp32_c5.py @@ -1,9 +1,12 @@ import logging import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +# https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c5/include/hal/i2c_ll.h +_ESP32C5_I2C_LP_PINS = {"SDA": 2, "SCL": 3} + _ESP32C5_SPI_PSRAM_PINS = { 16: "SPICS0", 17: "SPIQ", @@ -43,3 +46,13 @@ def esp32_c5_validate_supports(value): check_strapping_pin(value, _ESP32C5_STRAPPING_PINS, _LOGGER) return value + + +def esp32_c5_validate_lp_i2c(value): + lp_sda_pin = _ESP32C5_I2C_LP_PINS["SDA"] + lp_scl_pin = _ESP32C5_I2C_LP_PINS["SCL"] + if int(value[CONF_SDA]) != lp_sda_pin or int(value[CONF_SCL]) != lp_scl_pin: + raise cv.Invalid( + f"Low power i2c interface is only supported on GPIO{lp_sda_pin} SDA and GPIO{lp_scl_pin} SCL for ESP32-C5" + ) + return value diff --git a/esphome/components/esp32/gpio_esp32_c6.py b/esphome/components/esp32/gpio_esp32_c6.py index d466adb994..5d679dede2 100644 --- a/esphome/components/esp32/gpio_esp32_c6.py +++ b/esphome/components/esp32/gpio_esp32_c6.py @@ -1,9 +1,12 @@ import logging import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +# https://github.com/espressif/esp-idf/blob/master/components/esp_hal_i2c/esp32c6/include/hal/i2c_ll.h +_ESP32C6_I2C_LP_PINS = {"SDA": 6, "SCL": 7} + _ESP32C6_SPI_PSRAM_PINS = { 24: "SPICS0", 25: "SPIQ", @@ -43,3 +46,13 @@ def esp32_c6_validate_supports(value): check_strapping_pin(value, _ESP32C6_STRAPPING_PINS, _LOGGER) return value + + +def esp32_c6_validate_lp_i2c(value): + lp_sda_pin = _ESP32C6_I2C_LP_PINS["SDA"] + lp_scl_pin = _ESP32C6_I2C_LP_PINS["SCL"] + if int(value[CONF_SDA]) != lp_sda_pin or int(value[CONF_SCL]) != lp_scl_pin: + raise cv.Invalid( + f"Low power i2c interface is only supported on GPIO{lp_sda_pin} SDA and GPIO{lp_scl_pin} SCL for ESP32-C6" + ) + return value diff --git a/esphome/components/esp32/gpio_esp32_c61.py b/esphome/components/esp32/gpio_esp32_c61.py new file mode 100644 index 0000000000..77be42db3e --- /dev/null +++ b/esphome/components/esp32/gpio_esp32_c61.py @@ -0,0 +1,46 @@ +import logging + +import esphome.config_validation as cv +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.pins import check_strapping_pin + +# GPIO14-17, GPIO19-21 are used for SPI flash/PSRAM +_ESP32C61_SPI_PSRAM_PINS = { + 14: "SPICS0", + 15: "SPICLK", + 16: "SPID", + 17: "SPIQ", + 19: "SPIWP", + 20: "SPIHD", + 21: "VDD_SPI", +} + +_ESP32C61_STRAPPING_PINS = {8, 9} + +_LOGGER = logging.getLogger(__name__) + + +def esp32_c61_validate_gpio_pin(value): + if value < 0 or value > 29: + raise cv.Invalid(f"Invalid pin number: {value} (must be 0-29)") + if value in _ESP32C61_SPI_PSRAM_PINS: + raise cv.Invalid( + f"This pin cannot be used on ESP32-C61s and is already used by the SPI/PSRAM interface (function: {_ESP32C61_SPI_PSRAM_PINS[value]})" + ) + + return value + + +def esp32_c61_validate_supports(value): + num = value[CONF_NUMBER] + mode = value[CONF_MODE] + is_input = mode[CONF_INPUT] + + if num < 0 or num > 29: + raise cv.Invalid(f"Invalid pin number: {num} (must be 0-29)") + if is_input: + # All ESP32-C61 pins support input mode + pass + + check_strapping_pin(value, _ESP32C61_STRAPPING_PINS, _LOGGER) + return value diff --git a/esphome/components/esp32/gpio_esp32_p4.py b/esphome/components/esp32/gpio_esp32_p4.py index 34d1b3139d..b98b567da2 100644 --- a/esphome/components/esp32/gpio_esp32_p4.py +++ b/esphome/components/esp32/gpio_esp32_p4.py @@ -1,9 +1,12 @@ import logging import esphome.config_validation as cv -from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER +from esphome.const import CONF_INPUT, CONF_MODE, CONF_NUMBER, CONF_SCL, CONF_SDA from esphome.pins import check_strapping_pin +# https://documentation.espressif.com/esp32-p4-chip-revision-v1.3_datasheet_en.pdf +_ESP32P4_LP_PINS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + _ESP32P4_USB_JTAG_PINS = {24, 25} _ESP32P4_STRAPPING_PINS = {34, 35, 36, 37, 38} @@ -36,3 +39,14 @@ def esp32_p4_validate_supports(value): pass check_strapping_pin(value, _ESP32P4_STRAPPING_PINS, _LOGGER) return value + + +def esp32_p4_validate_lp_i2c(value): + if ( + int(value[CONF_SDA]) not in _ESP32P4_LP_PINS + or int(value[CONF_SCL]) not in _ESP32P4_LP_PINS + ): + raise cv.Invalid( + f"Low power i2c interface for ESP32-P4 is only supported on low power interface GPIO{min(_ESP32P4_LP_PINS)} - GPIO{max(_ESP32P4_LP_PINS)}" + ) + return value diff --git a/esphome/components/esp32_ble_client/ble_client_base.cpp b/esphome/components/esp32_ble_client/ble_client_base.cpp index 07e88c7528..a09390c747 100644 --- a/esphome/components/esp32_ble_client/ble_client_base.cpp +++ b/esphome/components/esp32_ble_client/ble_client_base.cpp @@ -524,10 +524,9 @@ void BLEClientBase::gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_ case ESP_GAP_BLE_AUTH_CMPL_EVT: if (!this->check_addr(param->ble_security.auth_cmpl.bd_addr)) 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_, - format_hex(bd_addr, 6).c_str()); + char addr_str[MAC_ADDR_STR_LEN]; + format_mac_addr_upper(param->ble_security.auth_cmpl.bd_addr, addr_str); + ESP_LOGI(TAG, "[%d] [%s] auth complete addr: %s", this->connection_index_, this->address_str_, addr_str); if (!param->ble_security.auth_cmpl.success) { this->log_error_("auth fail reason", param->ble_security.auth_cmpl.fail_reason); } else { diff --git a/esphome/components/esp32_can/canbus.py b/esphome/components/esp32_can/canbus.py index acc3785f22..0899a0dc2b 100644 --- a/esphome/components/esp32_can/canbus.py +++ b/esphome/components/esp32_can/canbus.py @@ -4,15 +4,17 @@ from esphome import pins import esphome.codegen as cg from esphome.components import canbus from esphome.components.canbus import CONF_BIT_RATE, CanbusComponent, CanSpeed -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import ( @@ -58,18 +60,22 @@ CAN_SPEEDS_ESP32_S2 = { CAN_SPEEDS_ESP32_S3 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_C3 = {**CAN_SPEEDS_ESP32_S2} +CAN_SPEEDS_ESP32_C5 = {**CAN_SPEEDS_ESP32_S2} CAN_SPEEDS_ESP32_C6 = {**CAN_SPEEDS_ESP32_S2} +CAN_SPEEDS_ESP32_C61 = {**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, - VARIANT_ESP32S2: CAN_SPEEDS_ESP32_S2, - VARIANT_ESP32S3: CAN_SPEEDS_ESP32_S3, VARIANT_ESP32C3: CAN_SPEEDS_ESP32_C3, + VARIANT_ESP32C5: CAN_SPEEDS_ESP32_C5, VARIANT_ESP32C6: CAN_SPEEDS_ESP32_C6, + VARIANT_ESP32C61: CAN_SPEEDS_ESP32_C61, VARIANT_ESP32H2: CAN_SPEEDS_ESP32_H2, VARIANT_ESP32P4: CAN_SPEEDS_ESP32_P4, + VARIANT_ESP32S2: CAN_SPEEDS_ESP32_S2, + VARIANT_ESP32S3: CAN_SPEEDS_ESP32_S3, } diff --git a/esphome/components/esp32_can/esp32_can.cpp b/esphome/components/esp32_can/esp32_can.cpp index f9b63b8ebc..d50964187d 100644 --- a/esphome/components/esp32_can/esp32_can.cpp +++ b/esphome/components/esp32_can/esp32_can.cpp @@ -16,8 +16,9 @@ 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_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || \ + defined(USE_ESP32_VARIANT_ESP32S3) case canbus::CAN_1KBPS: *t_config = (twai_timing_config_t) TWAI_TIMING_CONFIG_1KBITS(); return true; diff --git a/esphome/components/esp32_dac/output.py b/esphome/components/esp32_dac/output.py index cf4f12c46d..daace596d3 100644 --- a/esphome/components/esp32_dac/output.py +++ b/esphome/components/esp32_dac/output.py @@ -1,8 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import output -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_ESP32S2 +from esphome.components.esp32 import VARIANT_ESP32, VARIANT_ESP32S2, get_esp32_variant import esphome.config_validation as cv from esphome.const import CONF_ID, CONF_NUMBER, CONF_PIN diff --git a/esphome/components/esp32_hosted/__init__.py b/esphome/components/esp32_hosted/__init__.py index fde75517eb..9c9d1d4bb4 100644 --- a/esphome/components/esp32_hosted/__init__.py +++ b/esphome/components/esp32_hosted/__init__.py @@ -93,9 +93,9 @@ async def to_code(config): framework_ver: cv.Version = CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] os.environ["ESP_IDF_VERSION"] = f"{framework_ver.major}.{framework_ver.minor}" if framework_ver >= cv.Version(5, 5, 0): - esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.1.5") + esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="1.2.2") esp32.add_idf_component(name="espressif/eppp_link", ref="1.1.3") - esp32.add_idf_component(name="espressif/esp_hosted", ref="2.6.1") + esp32.add_idf_component(name="espressif/esp_hosted", ref="2.7.0") else: esp32.add_idf_component(name="espressif/esp_wifi_remote", ref="0.13.0") esp32.add_idf_component(name="espressif/eppp_link", ref="0.2.0") diff --git a/esphome/components/esp32_hosted/update/__init__.py b/esphome/components/esp32_hosted/update/__init__.py index 040f989a64..fff0d3623a 100644 --- a/esphome/components/esp32_hosted/update/__init__.py +++ b/esphome/components/esp32_hosted/update/__init__.py @@ -40,8 +40,8 @@ CONFIG_SCHEMA = cv.All( ), esp32.only_on_variant( supported=[ - esp32.const.VARIANT_ESP32H2, - esp32.const.VARIANT_ESP32P4, + esp32.VARIANT_ESP32H2, + esp32.VARIANT_ESP32P4, ] ), ) diff --git a/esphome/components/esp32_rmt/__init__.py b/esphome/components/esp32_rmt/__init__.py index 1e72185e3e..272c7c81ba 100644 --- a/esphome/components/esp32_rmt/__init__.py +++ b/esphome/components/esp32_rmt/__init__.py @@ -9,7 +9,7 @@ def validate_clock_resolution(): cv.only_on_esp32(value) value = cv.int_(value) variant = esp32.get_esp32_variant() - if variant == esp32.const.VARIANT_ESP32H2 and value > 32000000: + if variant == esp32.VARIANT_ESP32H2 and value > 32000000: raise cv.Invalid( f"ESP32 variant {variant} has a max clock_resolution of 32000000." ) diff --git a/esphome/components/esp32_rmt_led_strip/light.py b/esphome/components/esp32_rmt_led_strip/light.py index ac4f0b2e92..f020d02e86 100644 --- a/esphome/components/esp32_rmt_led_strip/light.py +++ b/esphome/components/esp32_rmt_led_strip/light.py @@ -77,13 +77,13 @@ CONFIG_SCHEMA = cv.All( cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, - esp32_s2=192, - esp32_s3=192, - esp32_p4=192, esp32_c3=96, esp32_c5=96, esp32_c6=96, esp32_h2=96, + esp32_p4=192, + esp32_s2=192, + esp32_s3=192, ): cv.int_range(min=2), cv.Optional(CONF_MAX_REFRESH_RATE): cv.positive_time_period_microseconds, cv.Optional(CONF_CHIPSET): cv.one_of(*CHIPSETS, upper=True), @@ -91,7 +91,7 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_IS_WRGB, default=False): cv.boolean, cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( - supported=[esp32.const.VARIANT_ESP32S3, esp32.const.VARIANT_ESP32P4] + supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] ), cv.boolean, ), diff --git a/esphome/components/esp32_touch/__init__.py b/esphome/components/esp32_touch/__init__.py index b6cb19ebb1..c54ed8b9ea 100644 --- a/esphome/components/esp32_touch/__init__.py +++ b/esphome/components/esp32_touch/__init__.py @@ -1,10 +1,11 @@ import esphome.codegen as cg from esphome.components import esp32 -from esphome.components.esp32 import get_esp32_variant, gpio -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32S2, VARIANT_ESP32S3, + get_esp32_variant, + gpio, ) import esphome.config_validation as cv from esphome.const import ( @@ -255,9 +256,9 @@ CONFIG_SCHEMA = cv.All( cv.has_none_or_all_keys(CONF_WATERPROOF_GUARD_RING, CONF_WATERPROOF_SHIELD_DRIVER), esp32.only_on_variant( supported=[ - esp32.const.VARIANT_ESP32, - esp32.const.VARIANT_ESP32S2, - esp32.const.VARIANT_ESP32S3, + esp32.VARIANT_ESP32, + esp32.VARIANT_ESP32S2, + esp32.VARIANT_ESP32S3, ] ), validate_variant_vars, diff --git a/esphome/components/espnow/packet_transport/espnow_transport.cpp b/esphome/components/espnow/packet_transport/espnow_transport.cpp index d30e9447a0..c1252acc9d 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.cpp +++ b/esphome/components/espnow/packet_transport/espnow_transport.cpp @@ -13,7 +13,7 @@ static const char *const TAG = "espnow.transport"; bool ESPNowTransport::should_send() { return this->parent_ != nullptr && !this->parent_->is_failed(); } void ESPNowTransport::setup() { - packet_transport::PacketTransport::setup(); + PacketTransport::setup(); if (this->parent_ == nullptr) { ESP_LOGE(TAG, "ESPNow component not set"); @@ -26,15 +26,10 @@ void ESPNowTransport::setup() { this->peer_address_[2], this->peer_address_[3], this->peer_address_[4], this->peer_address_[5]); // Register received handler - this->parent_->register_received_handler(static_cast(this)); + this->parent_->register_received_handler(this); // Register broadcasted handler - this->parent_->register_broadcasted_handler(static_cast(this)); -} - -void ESPNowTransport::update() { - packet_transport::PacketTransport::update(); - this->updated_ = true; + this->parent_->register_broadcasted_handler(this); } void ESPNowTransport::send_packet(const std::vector &buf) const { diff --git a/esphome/components/espnow/packet_transport/espnow_transport.h b/esphome/components/espnow/packet_transport/espnow_transport.h index 3629fad2cd..d85119db7d 100644 --- a/esphome/components/espnow/packet_transport/espnow_transport.h +++ b/esphome/components/espnow/packet_transport/espnow_transport.h @@ -18,7 +18,6 @@ class ESPNowTransport : public packet_transport::PacketTransport, public ESPNowBroadcastedHandler { public: void setup() override; - void update() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } void set_peer_address(peer_address_t address) { diff --git a/esphome/components/ethernet/__init__.py b/esphome/components/ethernet/__init__.py index b4d67635c1..e1ed327fb9 100644 --- a/esphome/components/ethernet/__init__.py +++ b/esphome/components/ethernet/__init__.py @@ -3,16 +3,17 @@ import logging from esphome import pins import esphome.codegen as cg from esphome.components.esp32 import ( - add_idf_component, - add_idf_sdkconfig_option, - get_esp32_variant, -) -from esphome.components.esp32.const import ( VARIANT_ESP32, VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + add_idf_component, + add_idf_sdkconfig_option, + get_esp32_variant, ) from esphome.components.network import ip_address_literal from esphome.components.spi import CONF_INTERFACE_INDEX, get_spi_interface @@ -303,7 +304,14 @@ def _final_validate_spi(config): return if spi_configs := fv.full_config.get().get(CONF_SPI): variant = get_esp32_variant() - if variant in (VARIANT_ESP32C3, VARIANT_ESP32S2, VARIANT_ESP32S3): + if variant in ( + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + ): spi_host = "SPI2_HOST" else: spi_host = "SPI3_HOST" @@ -426,10 +434,13 @@ def _final_validate_rmii_pins(config: ConfigType) -> None: # Check all used pins against RMII reserved pins for pin_list in pins.PIN_SCHEMA_REGISTRY.pins_used.values(): - for pin_path, _, pin_config in pin_list: + for pin_path, pin_device, pin_config in pin_list: pin_num = pin_config.get(CONF_NUMBER) if pin_num not in rmii_pins: continue + # Skip if pin is not directly on ESP, but at some expander (device set to something else than 'None') + if pin_device is not None: + continue # Found a conflict - show helpful error message pin_function = rmii_pins[pin_num] component_path = ".".join(str(p) for p in pin_path) diff --git a/esphome/components/ethernet/ethernet_component.cpp b/esphome/components/ethernet/ethernet_component.cpp index 9a46aa2687..793ebdec42 100644 --- a/esphome/components/ethernet/ethernet_component.cpp +++ b/esphome/components/ethernet/ethernet_component.cpp @@ -87,8 +87,8 @@ void EthernetComponent::setup() { .intr_flags = 0, }; -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || \ - defined(USE_ESP32_VARIANT_ESP32C6) +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) auto host = SPI2_HOST; #else auto host = SPI3_HOST; diff --git a/esphome/components/gree/__init__.py b/esphome/components/gree/__init__.py index e69de29bb2..2dd9ac0f1c 100644 --- a/esphome/components/gree/__init__.py +++ b/esphome/components/gree/__init__.py @@ -0,0 +1,3 @@ +import esphome.codegen as cg + +gree_ns = cg.esphome_ns.namespace("gree") diff --git a/esphome/components/gree/climate.py b/esphome/components/gree/climate.py index 057ba67b94..0892155fd2 100644 --- a/esphome/components/gree/climate.py +++ b/esphome/components/gree/climate.py @@ -3,11 +3,11 @@ from esphome.components import climate_ir import esphome.config_validation as cv from esphome.const import CONF_MODEL +from . import gree_ns + CODEOWNERS = ["@orestismers"] AUTO_LOAD = ["climate_ir"] - -gree_ns = cg.esphome_ns.namespace("gree") GreeClimate = gree_ns.class_("GreeClimate", climate_ir.ClimateIR) Model = gree_ns.enum("Model") @@ -23,7 +23,7 @@ MODELS = { CONFIG_SCHEMA = climate_ir.climate_ir_with_receiver_schema(GreeClimate).extend( { - cv.Required(CONF_MODEL): cv.enum(MODELS), + cv.Required(CONF_MODEL): cv.enum(MODELS, lower=True), } ) diff --git a/esphome/components/gree/gree.cpp b/esphome/components/gree/gree.cpp index e0cacb4f1e..b8cf8a39a8 100644 --- a/esphome/components/gree/gree.cpp +++ b/esphome/components/gree/gree.cpp @@ -16,13 +16,28 @@ void GreeClimate::set_model(Model model) { this->model_ = model; } +void GreeClimate::set_mode_bit(uint8_t bit_mask, bool enabled) { + if (enabled) { + this->mode_bits_ |= bit_mask; + } else { + this->mode_bits_ &= ~bit_mask; + } + this->transmit_state(); +} + void GreeClimate::transmit_state() { uint8_t remote_state[8] = {0x00, 0x00, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00}; remote_state[0] = this->fan_speed_() | this->operation_mode_(); remote_state[1] = this->temperature_(); - if (this->model_ == GREE_YAN || this->model_ == GREE_YX1FF || this->model_ == GREE_YAG) { + if (this->model_ == GREE_YAN) { + remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN + remote_state[3] = 0x50; // bits 4..7 always 0101 + remote_state[4] = this->vertical_swing_(); + } + + if (this->model_ == GREE_YX1FF || this->model_ == GREE_YAG) { remote_state[2] = 0x60; remote_state[3] = 0x50; remote_state[4] = this->vertical_swing_(); @@ -41,7 +56,7 @@ void GreeClimate::transmit_state() { } if (this->model_ == GREE_YAA || this->model_ == GREE_YAC || this->model_ == GREE_YAC1FB9) { - remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO,LIGHT,HEALTH,X-FAN + remote_state[2] = 0x20; // bits 0..3 always 0000, bits 4..7 TURBO, LIGHT, HEALTH, X-FAN remote_state[3] = 0x50; // bits 4..7 always 0101 remote_state[6] = 0x20; // YAA1FB, FAA1FB1, YB1F2 bits 4..7 always 0010 @@ -52,6 +67,13 @@ void GreeClimate::transmit_state() { } } + if (this->model_ == GREE_YAN || this->model_ == GREE_YAA || this->model_ == GREE_YAC || + this->model_ == GREE_YAC1FB9) { + // Merge the mode bits into remote_state[2] + // Clear the mode bits (bits 4-7) and OR in the current mode_bits_ + remote_state[2] = (remote_state[2] & 0x0F) | this->mode_bits_; + } + if (this->model_ == GREE_YX1FF) { if (this->fan_speed_() == GREE_FAN_TURBO) { remote_state[2] |= GREE_FAN_TURBO_BIT; diff --git a/esphome/components/gree/gree.h b/esphome/components/gree/gree.h index f91d78cabd..24453750ae 100644 --- a/esphome/components/gree/gree.h +++ b/esphome/components/gree/gree.h @@ -2,80 +2,79 @@ #include "esphome/components/climate_ir/climate_ir.h" -namespace esphome { -namespace gree { +namespace esphome::gree { // Values for GREE IR Controllers // Temperature -const uint8_t GREE_TEMP_MIN = 16; // Celsius -const uint8_t GREE_TEMP_MAX = 30; // Celsius +static constexpr uint8_t GREE_TEMP_MIN = 16; // Celsius +static constexpr uint8_t GREE_TEMP_MAX = 30; // Celsius // Modes -const uint8_t GREE_MODE_AUTO = 0x00; -const uint8_t GREE_MODE_COOL = 0x01; -const uint8_t GREE_MODE_HEAT = 0x04; -const uint8_t GREE_MODE_DRY = 0x02; -const uint8_t GREE_MODE_FAN = 0x03; +static constexpr uint8_t GREE_MODE_AUTO = 0x00; +static constexpr uint8_t GREE_MODE_COOL = 0x01; +static constexpr uint8_t GREE_MODE_HEAT = 0x04; +static constexpr uint8_t GREE_MODE_DRY = 0x02; +static constexpr uint8_t GREE_MODE_FAN = 0x03; -const uint8_t GREE_MODE_OFF = 0x00; -const uint8_t GREE_MODE_ON = 0x08; +static constexpr uint8_t GREE_MODE_OFF = 0x00; +static constexpr uint8_t GREE_MODE_ON = 0x08; // Fan Speed -const uint8_t GREE_FAN_AUTO = 0x00; -const uint8_t GREE_FAN_1 = 0x10; -const uint8_t GREE_FAN_2 = 0x20; -const uint8_t GREE_FAN_3 = 0x30; +static constexpr uint8_t GREE_FAN_AUTO = 0x00; +static constexpr uint8_t GREE_FAN_1 = 0x10; +static constexpr uint8_t GREE_FAN_2 = 0x20; +static constexpr uint8_t GREE_FAN_3 = 0x30; // IR Transmission -const uint32_t GREE_IR_FREQUENCY = 38000; -const uint32_t GREE_HEADER_MARK = 9000; -const uint32_t GREE_HEADER_SPACE = 4000; -const uint32_t GREE_BIT_MARK = 620; -const uint32_t GREE_ONE_SPACE = 1600; -const uint32_t GREE_ZERO_SPACE = 540; -const uint32_t GREE_MESSAGE_SPACE = 19000; +static constexpr uint32_t GREE_IR_FREQUENCY = 38000; +static constexpr uint32_t GREE_HEADER_MARK = 9000; +static constexpr uint32_t GREE_HEADER_SPACE = 4000; +static constexpr uint32_t GREE_BIT_MARK = 620; +static constexpr uint32_t GREE_ONE_SPACE = 1600; +static constexpr uint32_t GREE_ZERO_SPACE = 540; +static constexpr uint32_t GREE_MESSAGE_SPACE = 19000; // Timing specific for YAC features (I-Feel mode) -const uint32_t GREE_YAC_HEADER_MARK = 6000; -const uint32_t GREE_YAC_HEADER_SPACE = 3000; -const uint32_t GREE_YAC_BIT_MARK = 650; +static constexpr uint32_t GREE_YAC_HEADER_MARK = 6000; +static constexpr uint32_t GREE_YAC_HEADER_SPACE = 3000; +static constexpr uint32_t GREE_YAC_BIT_MARK = 650; // Timing specific to YAC1FB9 -const uint32_t GREE_YAC1FB9_HEADER_SPACE = 4500; -const uint32_t GREE_YAC1FB9_MESSAGE_SPACE = 19980; +static constexpr uint32_t GREE_YAC1FB9_HEADER_SPACE = 4500; +static constexpr uint32_t GREE_YAC1FB9_MESSAGE_SPACE = 19980; // State Frame size -const uint8_t GREE_STATE_FRAME_SIZE = 8; +static constexpr uint8_t GREE_STATE_FRAME_SIZE = 8; // Only available on YAN // Vertical air directions. Note that these cannot be set on all heat pumps -const uint8_t GREE_VDIR_AUTO = 0x00; -const uint8_t GREE_VDIR_MANUAL = 0x00; -const uint8_t GREE_VDIR_SWING = 0x01; -const uint8_t GREE_VDIR_UP = 0x02; -const uint8_t GREE_VDIR_MUP = 0x03; -const uint8_t GREE_VDIR_MIDDLE = 0x04; -const uint8_t GREE_VDIR_MDOWN = 0x05; -const uint8_t GREE_VDIR_DOWN = 0x06; +static constexpr uint8_t GREE_VDIR_AUTO = 0x00; +static constexpr uint8_t GREE_VDIR_MANUAL = 0x00; +static constexpr uint8_t GREE_VDIR_SWING = 0x01; +static constexpr uint8_t GREE_VDIR_UP = 0x02; +static constexpr uint8_t GREE_VDIR_MUP = 0x03; +static constexpr uint8_t GREE_VDIR_MIDDLE = 0x04; +static constexpr uint8_t GREE_VDIR_MDOWN = 0x05; +static constexpr uint8_t GREE_VDIR_DOWN = 0x06; // Only available on YAC/YAG // Horizontal air directions. Note that these cannot be set on all heat pumps -const uint8_t GREE_HDIR_AUTO = 0x00; -const uint8_t GREE_HDIR_MANUAL = 0x00; -const uint8_t GREE_HDIR_SWING = 0x01; -const uint8_t GREE_HDIR_LEFT = 0x02; -const uint8_t GREE_HDIR_MLEFT = 0x03; -const uint8_t GREE_HDIR_MIDDLE = 0x04; -const uint8_t GREE_HDIR_MRIGHT = 0x05; -const uint8_t GREE_HDIR_RIGHT = 0x06; +static constexpr uint8_t GREE_HDIR_AUTO = 0x00; +static constexpr uint8_t GREE_HDIR_MANUAL = 0x00; +static constexpr uint8_t GREE_HDIR_SWING = 0x01; +static constexpr uint8_t GREE_HDIR_LEFT = 0x02; +static constexpr uint8_t GREE_HDIR_MLEFT = 0x03; +static constexpr uint8_t GREE_HDIR_MIDDLE = 0x04; +static constexpr uint8_t GREE_HDIR_MRIGHT = 0x05; +static constexpr uint8_t GREE_HDIR_RIGHT = 0x06; // Only available on YX1FF // Turbo (high) fan mode + sleep preset mode -const uint8_t GREE_FAN_TURBO = 0x80; -const uint8_t GREE_FAN_TURBO_BIT = 0x10; -const uint8_t GREE_PRESET_NONE = 0x00; -const uint8_t GREE_PRESET_SLEEP = 0x01; -const uint8_t GREE_PRESET_SLEEP_BIT = 0x80; +static constexpr uint8_t GREE_FAN_TURBO = 0x80; +static constexpr uint8_t GREE_FAN_TURBO_BIT = 0x10; +static constexpr uint8_t GREE_PRESET_NONE = 0x00; +static constexpr uint8_t GREE_PRESET_SLEEP = 0x01; +static constexpr uint8_t GREE_PRESET_SLEEP_BIT = 0x80; // Model codes enum Model { GREE_GENERIC, GREE_YAN, GREE_YAA, GREE_YAC, GREE_YAC1FB9, GREE_YX1FF, GREE_YAG }; @@ -90,6 +89,7 @@ class GreeClimate : public climate_ir::ClimateIR { climate::CLIMATE_SWING_HORIZONTAL, climate::CLIMATE_SWING_BOTH}) {} void set_model(Model model); + void set_mode_bit(uint8_t bit_mask, bool enabled); protected: // Transmit via IR the state of this climate controller. @@ -103,7 +103,7 @@ class GreeClimate : public climate_ir::ClimateIR { uint8_t preset_(); Model model_{}; + uint8_t mode_bits_{0}; // Combined mode bits for remote_state[2] }; -} // namespace gree -} // namespace esphome +} // namespace esphome::gree diff --git a/esphome/components/gree/switch/__init__.py b/esphome/components/gree/switch/__init__.py new file mode 100644 index 0000000000..111fea65d2 --- /dev/null +++ b/esphome/components/gree/switch/__init__.py @@ -0,0 +1,74 @@ +import esphome.codegen as cg +from esphome.components import switch +import esphome.config_validation as cv +from esphome.const import CONF_LIGHT, DEVICE_CLASS_SWITCH, ENTITY_CATEGORY_CONFIG +import esphome.final_validate as fv + +from .. import gree_ns +from ..climate import CONF_MODEL, GreeClimate + +CODEOWNERS = ["@nagyrobi"] + +GreeModeBitSwitch = gree_ns.class_("GreeModeBitSwitch", switch.Switch, cg.Component) + +CONF_TURBO = "turbo" +CONF_HEALTH = "health" +CONF_XFAN = "xfan" +CONF_GREE_ID = "gree_id" + +# Switch configurations: (config_key, display_name, bit_mask, icon) +SWITCH_CONFIGS = ( + (CONF_TURBO, "Gree Turbo Switch", 0x10, "mdi:car-turbocharger"), + (CONF_LIGHT, "Gree Light Switch", 0x20, "mdi:led-outline"), + (CONF_HEALTH, "Gree Health Switch", 0x40, "mdi:pine-tree"), + (CONF_XFAN, "Gree X-FAN Switch", 0x80, "mdi:wall-sconce-flat"), +) + +SUPPORTED_MODELS = { + "yan", + "yaa", + "yac", + "yac1fb9", +} + +CONFIG_SCHEMA = cv.Schema( + { + cv.Required(CONF_GREE_ID): cv.use_id(GreeClimate), + **{ + cv.Optional(key): switch.switch_schema( + GreeModeBitSwitch, + icon=icon, + default_restore_mode="RESTORE_DEFAULT_OFF", + device_class=DEVICE_CLASS_SWITCH, + entity_category=ENTITY_CATEGORY_CONFIG, + ) + for key, _, _, icon in SWITCH_CONFIGS + }, + } +) + + +def _validate_model(config): + full_config = fv.full_config.get() + climate_path = full_config.get_path_for_id(config[CONF_GREE_ID])[:-1] + climate_conf = full_config.get_config_for_path(climate_path) + if climate_conf[CONF_MODEL] not in SUPPORTED_MODELS: + raise cv.Invalid( + "Gree switches are only supported for the " + + ", ".join(SUPPORTED_MODELS) + + " models" + ) + + +FINAL_VALIDATE_SCHEMA = _validate_model + + +async def to_code(config): + parent = await cg.get_variable(config[CONF_GREE_ID]) + + for conf_key, name, bit_mask, _ in SWITCH_CONFIGS: + if switch_conf := config.get(conf_key): + sw = cg.new_Pvariable(switch_conf[cv.CONF_ID], name, bit_mask) + await switch.register_switch(sw, switch_conf) + await cg.register_component(sw, switch_conf) + await cg.register_parented(sw, parent) diff --git a/esphome/components/gree/switch/gree_switch.cpp b/esphome/components/gree/switch/gree_switch.cpp new file mode 100644 index 0000000000..13f14e5453 --- /dev/null +++ b/esphome/components/gree/switch/gree_switch.cpp @@ -0,0 +1,24 @@ +#include "gree_switch.h" +#include "esphome/core/log.h" + +namespace esphome { +namespace gree { + +static const char *const TAG = "gree.switch"; + +void GreeModeBitSwitch::setup() { + auto initial = this->get_initial_state_with_restore_mode(); + if (initial.has_value()) { + this->write_state(*initial); + } +} + +void GreeModeBitSwitch::dump_config() { log_switch(TAG, " ", this->name_, this); } + +void GreeModeBitSwitch::write_state(bool state) { + this->parent_->set_mode_bit(this->bit_mask_, state); + this->publish_state(state); +} + +} // namespace gree +} // namespace esphome diff --git a/esphome/components/gree/switch/gree_switch.h b/esphome/components/gree/switch/gree_switch.h new file mode 100644 index 0000000000..239ac4bf17 --- /dev/null +++ b/esphome/components/gree/switch/gree_switch.h @@ -0,0 +1,24 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/switch/switch.h" +#include "esphome/components/gree/gree.h" + +namespace esphome { +namespace gree { + +class GreeModeBitSwitch : public switch_::Switch, public Component, public Parented { + public: + GreeModeBitSwitch(const char *name, uint8_t bit_mask) : name_(name), bit_mask_(bit_mask) {} + + void setup() override; + void dump_config() override; + void write_state(bool state) override; + + protected: + const char *name_; + uint8_t bit_mask_; +}; + +} // namespace gree +} // namespace esphome diff --git a/esphome/components/hlw8032/__init__.py b/esphome/components/hlw8032/__init__.py new file mode 100644 index 0000000000..4908e10037 --- /dev/null +++ b/esphome/components/hlw8032/__init__.py @@ -0,0 +1 @@ +CODEOWNERS = ["@rici4kubicek"] diff --git a/esphome/components/hlw8032/hlw8032.cpp b/esphome/components/hlw8032/hlw8032.cpp new file mode 100644 index 0000000000..55e6664a8b --- /dev/null +++ b/esphome/components/hlw8032/hlw8032.cpp @@ -0,0 +1,194 @@ +#include "hlw8032.h" +#include "esphome/core/log.h" +#include + +namespace esphome::hlw8032 { + +static const char *const TAG = "hlw8032"; + +static constexpr uint8_t STATE_REG_OFFSET = 0; +static constexpr uint8_t VOLTAGE_PARAM_OFFSET = 2; +static constexpr uint8_t VOLTAGE_REG_OFFSET = 5; +static constexpr uint8_t CURRENT_PARAM_OFFSET = 8; +static constexpr uint8_t CURRENT_REG_OFFSET = 11; +static constexpr uint8_t POWER_PARAM_OFFSET = 14; +static constexpr uint8_t POWER_REG_OFFSET = 17; +static constexpr uint8_t DATA_UPDATE_REG_OFFSET = 20; +static constexpr uint8_t CHECKSUM_REG_OFFSET = 23; +static constexpr uint8_t PARAM_REG_USABLE_BIT = (1 << 0); +static constexpr uint8_t POWER_OVERFLOW_BIT = (1 << 1); +static constexpr uint8_t CURRENT_OVERFLOW_BIT = (1 << 2); +static constexpr uint8_t VOLTAGE_OVERFLOW_BIT = (1 << 3); +static constexpr uint8_t HAVE_POWER_BIT = (1 << 4); +static constexpr uint8_t HAVE_CURRENT_BIT = (1 << 5); +static constexpr uint8_t HAVE_VOLTAGE_BIT = (1 << 6); +static constexpr uint8_t CHECK_REG = 0x5A; +static constexpr uint8_t STATE_REG_CORRECTION_FUNC_NORMAL = 0x55; +static constexpr uint8_t STATE_REG_CORRECTION_FUNC_FAIL = 0xAA; +static constexpr uint8_t STATE_REG_CORRECTION_MASK = 0xF0; +static constexpr uint8_t STATE_REG_OVERFLOW_MASK = 0xF; +static constexpr uint8_t PACKET_LENGTH = 24; + +void HLW8032Component::loop() { + while (this->available()) { + uint8_t data = this->read(); + if (!this->header_found_) { + if ((data == STATE_REG_CORRECTION_FUNC_NORMAL) || (data == STATE_REG_CORRECTION_FUNC_FAIL) || + (data & STATE_REG_CORRECTION_MASK) == STATE_REG_CORRECTION_MASK) { + this->header_found_ = true; + this->raw_data_[0] = data; + } + } else if (data == CHECK_REG) { + this->raw_data_[1] = data; + this->raw_data_index_ = 2; + this->check_ = 0; + } else if (this->raw_data_index_ >= 2 && this->raw_data_index_ < PACKET_LENGTH) { + this->raw_data_[this->raw_data_index_++] = data; + if (this->raw_data_index_ < PACKET_LENGTH) { + this->check_ += data; + } else if (this->raw_data_index_ == PACKET_LENGTH) { + if (this->check_ == this->raw_data_[CHECKSUM_REG_OFFSET]) { + this->parse_data_(); + } else { + ESP_LOGW(TAG, "Invalid checksum: 0x%02X != 0x%02X", this->check_, this->raw_data_[CHECKSUM_REG_OFFSET]); + } + this->raw_data_index_ = 0; + this->header_found_ = false; + memset(this->raw_data_, 0, PACKET_LENGTH); + } + } + } +} + +uint32_t HLW8032Component::read_uint24_(uint8_t offset) { + return encode_uint24(this->raw_data_[offset], this->raw_data_[offset + 1], this->raw_data_[offset + 2]); +} + +void HLW8032Component::parse_data_() { + // Parse header + uint8_t state_reg = this->raw_data_[STATE_REG_OFFSET]; + + if (state_reg == STATE_REG_CORRECTION_FUNC_FAIL) { + ESP_LOGE(TAG, "The chip's function of error correction fails."); + return; + } + + // Parse data frame + uint32_t voltage_parameter = this->read_uint24_(VOLTAGE_PARAM_OFFSET); + uint32_t voltage_reg = this->read_uint24_(VOLTAGE_REG_OFFSET); + uint32_t current_parameter = this->read_uint24_(CURRENT_PARAM_OFFSET); + uint32_t current_reg = this->read_uint24_(CURRENT_REG_OFFSET); + uint32_t power_parameter = this->read_uint24_(POWER_PARAM_OFFSET); + uint32_t power_reg = this->read_uint24_(POWER_REG_OFFSET); + uint8_t data_update_register = this->raw_data_[DATA_UPDATE_REG_OFFSET]; + + bool have_power = data_update_register & HAVE_POWER_BIT; + bool have_current = data_update_register & HAVE_CURRENT_BIT; + bool have_voltage = data_update_register & HAVE_VOLTAGE_BIT; + + bool power_cycle_exceeds_range = false; + bool parameter_regs_usable = true; + + if ((state_reg & STATE_REG_CORRECTION_MASK) == STATE_REG_CORRECTION_MASK) { + if (state_reg & STATE_REG_OVERFLOW_MASK) { + if (state_reg & VOLTAGE_OVERFLOW_BIT) { + have_voltage = false; + } + if (state_reg & CURRENT_OVERFLOW_BIT) { + have_current = false; + } + if (state_reg & POWER_OVERFLOW_BIT) { + have_power = false; + } + if (state_reg & PARAM_REG_USABLE_BIT) { + parameter_regs_usable = false; + } + + ESP_LOGW(TAG, + "Reports: (0x%02X)\n" + " Voltage REG overflows: %s\n" + " Current REG overflows: %s\n" + " Power REG overflows: %s\n" + " Voltage/Current/Power Parameter REGs not usable: %s\n", + state_reg, YESNO(!have_voltage), YESNO(!have_current), YESNO(!have_power), + YESNO(!parameter_regs_usable)); + + if (!parameter_regs_usable) { + return; + } + } + power_cycle_exceeds_range = have_power; + } + + ESP_LOGVV(TAG, + "Parsed data:\n" + " Voltage: Parameter REG 0x%06" PRIX32 ", REG 0x%06" PRIX32 "\n" + " Current: Parameter REG 0x%06" PRIX32 ", REG 0x%06" PRIX32 "\n" + " Power: Parameter REG 0x%06" PRIX32 ", REG 0x%06" PRIX32 "\n" + " Data Update: REG 0x%02" PRIX8 "\n", + voltage_parameter, voltage_reg, current_parameter, current_reg, power_parameter, power_reg, + data_update_register); + + const float current_multiplier = 1 / (this->current_resistor_ * 1000); + + float voltage = 0.0f; + if (have_voltage && voltage_reg) { + voltage = float(voltage_parameter) * this->voltage_divider_ / float(voltage_reg); + } + if (this->voltage_sensor_ != nullptr) { + this->voltage_sensor_->publish_state(voltage); + } + + float power = 0.0f; + if (have_power && power_reg && !power_cycle_exceeds_range) { + power = (float(power_parameter) / float(power_reg)) * this->voltage_divider_ * current_multiplier; + } + if (this->power_sensor_ != nullptr) { + this->power_sensor_->publish_state(power); + } + + float current = 0.0f; + if (have_current && current_reg) { + current = float(current_parameter) * current_multiplier / float(current_reg); + } + if (this->current_sensor_ != nullptr) { + this->current_sensor_->publish_state(current); + } + + float pf = NAN; + const float apparent_power = voltage * current; + if (have_voltage && have_current) { + if (have_power || power_cycle_exceeds_range) { + if (apparent_power > 0) { + pf = power / apparent_power; + if (pf < 0 || pf > 1) { + ESP_LOGD(TAG, "Impossible power factor: %.4f not in interval [0, 1]", pf); + pf = NAN; + } + } else if (apparent_power == 0 && power == 0) { + // No load, report ideal power factor + pf = 1.0f; + } + } + } + if (this->apparent_power_sensor_ != nullptr) { + this->apparent_power_sensor_->publish_state(apparent_power); + } + if (this->power_factor_sensor_ != nullptr) { + this->power_factor_sensor_->publish_state(pf); + } +} + +void HLW8032Component::dump_config() { + ESP_LOGCONFIG(TAG, + "Configuration:\n" + " Current resistor: %.1f mΩ\n" + " Voltage Divider: %.3f", + this->current_resistor_ * 1000.0f, this->voltage_divider_); + LOG_SENSOR(" ", "Voltage", this->voltage_sensor_); + LOG_SENSOR(" ", "Current", this->current_sensor_); + LOG_SENSOR(" ", "Power", this->power_sensor_); + LOG_SENSOR(" ", "Apparent Power", this->apparent_power_sensor_); + LOG_SENSOR(" ", "Power Factor", this->power_factor_sensor_); +} +} // namespace esphome::hlw8032 diff --git a/esphome/components/hlw8032/hlw8032.h b/esphome/components/hlw8032/hlw8032.h new file mode 100644 index 0000000000..d4c7dbd26c --- /dev/null +++ b/esphome/components/hlw8032/hlw8032.h @@ -0,0 +1,44 @@ +#pragma once + +#include "esphome/core/component.h" +#include "esphome/components/sensor/sensor.h" +#include "esphome/components/uart/uart.h" + +namespace esphome::hlw8032 { + +class HLW8032Component : public Component, public uart::UARTDevice { + public: + void loop() override; + void dump_config() override; + + void set_current_resistor(float current_resistor) { this->current_resistor_ = current_resistor; } + void set_voltage_divider(float voltage_divider) { this->voltage_divider_ = voltage_divider; } + void set_voltage_sensor(sensor::Sensor *voltage_sensor) { this->voltage_sensor_ = voltage_sensor; } + void set_current_sensor(sensor::Sensor *current_sensor) { this->current_sensor_ = current_sensor; } + void set_power_sensor(sensor::Sensor *power_sensor) { this->power_sensor_ = power_sensor; } + void set_apparent_power_sensor(sensor::Sensor *apparent_power_sensor) { + this->apparent_power_sensor_ = apparent_power_sensor; + } + void set_power_factor_sensor(sensor::Sensor *power_factor_sensor) { + this->power_factor_sensor_ = power_factor_sensor; + } + + protected: + void parse_data_(); + uint32_t read_uint24_(uint8_t offset); + + sensor::Sensor *voltage_sensor_{nullptr}; + sensor::Sensor *current_sensor_{nullptr}; + sensor::Sensor *power_sensor_{nullptr}; + sensor::Sensor *apparent_power_sensor_{nullptr}; + sensor::Sensor *power_factor_sensor_{nullptr}; + + float current_resistor_{0.001f}; + float voltage_divider_{1.720f}; + uint8_t raw_data_[24]{}; + uint8_t check_{0}; + uint8_t raw_data_index_{0}; + bool header_found_{false}; +}; + +} // namespace esphome::hlw8032 diff --git a/esphome/components/hlw8032/sensor.py b/esphome/components/hlw8032/sensor.py new file mode 100644 index 0000000000..96800e46f4 --- /dev/null +++ b/esphome/components/hlw8032/sensor.py @@ -0,0 +1,93 @@ +import esphome.codegen as cg +from esphome.components import sensor, uart +import esphome.config_validation as cv +from esphome.const import ( + CONF_APPARENT_POWER, + CONF_CURRENT, + CONF_CURRENT_RESISTOR, + CONF_ID, + CONF_POWER, + CONF_POWER_FACTOR, + CONF_VOLTAGE, + CONF_VOLTAGE_DIVIDER, + DEVICE_CLASS_APPARENT_POWER, + DEVICE_CLASS_CURRENT, + DEVICE_CLASS_POWER, + DEVICE_CLASS_POWER_FACTOR, + DEVICE_CLASS_VOLTAGE, + STATE_CLASS_MEASUREMENT, + UNIT_AMPERE, + UNIT_VOLT, + UNIT_VOLT_AMPS, + UNIT_WATT, +) + +DEPENDENCIES = ["uart"] + +hlw8032_ns = cg.esphome_ns.namespace("hlw8032") +HLW8032Component = hlw8032_ns.class_("HLW8032Component", cg.Component, uart.UARTDevice) + +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(HLW8032Component), + cv.Optional(CONF_VOLTAGE): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_VOLTAGE, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CURRENT): sensor.sensor_schema( + unit_of_measurement=UNIT_AMPERE, + accuracy_decimals=2, + device_class=DEVICE_CLASS_CURRENT, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_WATT, + accuracy_decimals=1, + device_class=DEVICE_CLASS_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_APPARENT_POWER): sensor.sensor_schema( + unit_of_measurement=UNIT_VOLT_AMPS, + accuracy_decimals=1, + device_class=DEVICE_CLASS_APPARENT_POWER, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_POWER_FACTOR): sensor.sensor_schema( + accuracy_decimals=2, + device_class=DEVICE_CLASS_POWER_FACTOR, + state_class=STATE_CLASS_MEASUREMENT, + ), + cv.Optional(CONF_CURRENT_RESISTOR, default=0.001): cv.resistance, + cv.Optional(CONF_VOLTAGE_DIVIDER, default=1.720): cv.positive_float, + } +).extend(uart.UART_DEVICE_SCHEMA) + +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + "hlw8032", baud_rate=4800, require_rx=True, data_bits=8, parity="EVEN" +) + + +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 voltage_config := config.get(CONF_VOLTAGE): + sens = await sensor.new_sensor(voltage_config) + cg.add(var.set_voltage_sensor(sens)) + if current_config := config.get(CONF_CURRENT): + sens = await sensor.new_sensor(current_config) + cg.add(var.set_current_sensor(sens)) + if power_config := config.get(CONF_POWER): + sens = await sensor.new_sensor(power_config) + cg.add(var.set_power_sensor(sens)) + if apparent_power_config := config.get(CONF_APPARENT_POWER): + sens = await sensor.new_sensor(apparent_power_config) + cg.add(var.set_apparent_power_sensor(sens)) + if power_factor_config := config.get(CONF_POWER_FACTOR): + sens = await sensor.new_sensor(power_factor_config) + cg.add(var.set_power_factor_sensor(sens)) + cg.add(var.set_current_resistor(config[CONF_CURRENT_RESISTOR])) + cg.add(var.set_voltage_divider(config[CONF_VOLTAGE_DIVIDER])) diff --git a/esphome/components/hmac_md5/__init__.py b/esphome/components/hmac_md5/__init__.py index fe245c0cfd..e37eb9b116 100644 --- a/esphome/components/hmac_md5/__init__.py +++ b/esphome/components/hmac_md5/__init__.py @@ -1,2 +1,6 @@ +import esphome.config_validation as cv + AUTO_LOAD = ["md5"] CODEOWNERS = ["@dwmw2"] + +CONFIG_SCHEMA = cv.Schema({}) diff --git a/esphome/components/hmac_sha256/__init__.py b/esphome/components/hmac_sha256/__init__.py new file mode 100644 index 0000000000..158d740dc5 --- /dev/null +++ b/esphome/components/hmac_sha256/__init__.py @@ -0,0 +1,6 @@ +import esphome.config_validation as cv + +AUTO_LOAD = ["sha256"] +CODEOWNERS = ["@dwmw2"] + +CONFIG_SCHEMA = cv.Schema({}) diff --git a/esphome/components/hmac_sha256/hmac_sha256.cpp b/esphome/components/hmac_sha256/hmac_sha256.cpp new file mode 100644 index 0000000000..cf5daf63af --- /dev/null +++ b/esphome/components/hmac_sha256/hmac_sha256.cpp @@ -0,0 +1,102 @@ +#include +#include +#include "hmac_sha256.h" +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) +#include "esphome/core/helpers.h" + +namespace esphome::hmac_sha256 { + +constexpr size_t SHA256_DIGEST_SIZE = 32; + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + +HmacSHA256::~HmacSHA256() { mbedtls_md_free(&this->ctx_); } + +void HmacSHA256::init(const uint8_t *key, size_t len) { + mbedtls_md_init(&this->ctx_); + const mbedtls_md_info_t *md_info = mbedtls_md_info_from_type(MBEDTLS_MD_SHA256); + mbedtls_md_setup(&this->ctx_, md_info, 1); // 1 = HMAC mode + mbedtls_md_hmac_starts(&this->ctx_, key, len); +} + +void HmacSHA256::add(const uint8_t *data, size_t len) { mbedtls_md_hmac_update(&this->ctx_, data, len); } + +void HmacSHA256::calculate() { mbedtls_md_hmac_finish(&this->ctx_, this->digest_); } + +void HmacSHA256::get_bytes(uint8_t *output) { memcpy(output, this->digest_, SHA256_DIGEST_SIZE); } + +void HmacSHA256::get_hex(char *output) { + for (size_t i = 0; i < SHA256_DIGEST_SIZE; i++) { + sprintf(output + (i * 2), "%02x", this->digest_[i]); + } +} + +bool HmacSHA256::equals_bytes(const uint8_t *expected) { + return memcmp(this->digest_, expected, SHA256_DIGEST_SIZE) == 0; +} + +bool HmacSHA256::equals_hex(const char *expected) { + char hex_output[SHA256_DIGEST_SIZE * 2 + 1]; + this->get_hex(hex_output); + hex_output[SHA256_DIGEST_SIZE * 2] = '\0'; + return strncmp(hex_output, expected, SHA256_DIGEST_SIZE * 2) == 0; +} + +#else + +HmacSHA256::~HmacSHA256() = default; + +// HMAC block size for SHA256 (RFC 2104) +constexpr size_t HMAC_BLOCK_SIZE = 64; + +void HmacSHA256::init(const uint8_t *key, size_t len) { + uint8_t ipad[HMAC_BLOCK_SIZE], opad[HMAC_BLOCK_SIZE]; + + memset(ipad, 0, sizeof(ipad)); + if (len > HMAC_BLOCK_SIZE) { + sha256::SHA256 keysha256; + keysha256.init(); + keysha256.add(key, len); + keysha256.calculate(); + keysha256.get_bytes(ipad); + } else { + memcpy(ipad, key, len); + } + memcpy(opad, ipad, sizeof(opad)); + + for (size_t i = 0; i < HMAC_BLOCK_SIZE; i++) { + ipad[i] ^= 0x36; + opad[i] ^= 0x5c; + } + + this->ihash_.init(); + this->ihash_.add(ipad, sizeof(ipad)); + + this->ohash_.init(); + this->ohash_.add(opad, sizeof(opad)); +} + +void HmacSHA256::add(const uint8_t *data, size_t len) { this->ihash_.add(data, len); } + +void HmacSHA256::calculate() { + uint8_t ibytes[32]; + + this->ihash_.calculate(); + this->ihash_.get_bytes(ibytes); + + this->ohash_.add(ibytes, sizeof(ibytes)); + this->ohash_.calculate(); +} + +void HmacSHA256::get_bytes(uint8_t *output) { this->ohash_.get_bytes(output); } + +void HmacSHA256::get_hex(char *output) { this->ohash_.get_hex(output); } + +bool HmacSHA256::equals_bytes(const uint8_t *expected) { return this->ohash_.equals_bytes(expected); } + +bool HmacSHA256::equals_hex(const char *expected) { return this->ohash_.equals_hex(expected); } + +#endif // USE_ESP32 || USE_LIBRETINY + +} // namespace esphome::hmac_sha256 +#endif diff --git a/esphome/components/hmac_sha256/hmac_sha256.h b/esphome/components/hmac_sha256/hmac_sha256.h new file mode 100644 index 0000000000..fa6b64aa94 --- /dev/null +++ b/esphome/components/hmac_sha256/hmac_sha256.h @@ -0,0 +1,59 @@ +#pragma once + +#include "esphome/core/defines.h" +#if defined(USE_ESP32) || defined(USE_ESP8266) || defined(USE_RP2040) || defined(USE_LIBRETINY) || defined(USE_HOST) + +#include + +#if defined(USE_ESP32) || defined(USE_LIBRETINY) +#include "mbedtls/md.h" +#else +#include "esphome/components/sha256/sha256.h" +#endif + +namespace esphome::hmac_sha256 { + +class HmacSHA256 { + public: + HmacSHA256() = default; + ~HmacSHA256(); + + /// Initialize a new HMAC-SHA256 digest computation. + void init(const uint8_t *key, size_t len); + void init(const char *key, size_t len) { this->init((const uint8_t *) key, len); } + void init(const std::string &key) { this->init(key.c_str(), key.length()); } + + /// Add bytes of data for the digest. + void add(const uint8_t *data, size_t len); + void add(const char *data, size_t len) { this->add((const uint8_t *) data, len); } + + /// Compute the digest, based on the provided data. + void calculate(); + + /// Retrieve the HMAC-SHA256 digest as bytes. + /// The output must be able to hold 32 bytes or more. + void get_bytes(uint8_t *output); + + /// Retrieve the HMAC-SHA256 digest as hex characters. + /// The output must be able to hold 64 bytes or more. + void get_hex(char *output); + + /// Compare the digest against a provided byte-encoded digest (32 bytes). + bool equals_bytes(const uint8_t *expected); + + /// Compare the digest against a provided hex-encoded digest (64 bytes). + bool equals_hex(const char *expected); + + protected: +#if defined(USE_ESP32) || defined(USE_LIBRETINY) + static constexpr size_t SHA256_DIGEST_SIZE = 32; + mbedtls_md_context_t ctx_{}; + uint8_t digest_[SHA256_DIGEST_SIZE]{}; +#else + sha256::SHA256 ihash_; + sha256::SHA256 ohash_; +#endif +}; + +} // namespace esphome::hmac_sha256 +#endif diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp index a36fcb204a..5652e7d603 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.cpp @@ -19,11 +19,10 @@ void HomeassistantBinarySensor::setup() { case PARSE_ON: case PARSE_OFF: bool new_state = val == PARSE_ON; - if (this->attribute_.has_value()) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state %s", this->entity_id_.c_str(), - this->attribute_.value().c_str(), ONOFF(new_state)); + if (this->attribute_ != nullptr) { + ESP_LOGD(TAG, "'%s::%s': Got attribute state %s", this->entity_id_, this->attribute_, ONOFF(new_state)); } else { - ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_.c_str(), ONOFF(new_state)); + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, ONOFF(new_state)); } if (this->initial_) { this->publish_initial_state(new_state); @@ -37,9 +36,9 @@ void HomeassistantBinarySensor::setup() { } void HomeassistantBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Homeassistant Binary Sensor", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); - if (this->attribute_.has_value()) { - ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_.value().c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); + if (this->attribute_ != nullptr) { + ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_); } } float HomeassistantBinarySensor::get_setup_priority() const { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h index 7026496295..9aec61a370 100644 --- a/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h +++ b/esphome/components/homeassistant/binary_sensor/homeassistant_binary_sensor.h @@ -8,15 +8,15 @@ namespace homeassistant { class HomeassistantBinarySensor : public binary_sensor::BinarySensor, public Component { public: - void set_entity_id(const std::string &entity_id) { entity_id_ = entity_id; } - void set_attribute(const std::string &attribute) { attribute_ = attribute; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + void set_attribute(const char *attribute) { this->attribute_ = attribute; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: - std::string entity_id_; - optional attribute_; + const char *entity_id_{nullptr}; + const char *attribute_{nullptr}; bool initial_{true}; }; diff --git a/esphome/components/homeassistant/number/homeassistant_number.cpp b/esphome/components/homeassistant/number/homeassistant_number.cpp index 9963f3431d..1ca90180eb 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.cpp +++ b/esphome/components/homeassistant/number/homeassistant_number.cpp @@ -12,21 +12,21 @@ static const char *const TAG = "homeassistant.number"; void HomeassistantNumber::state_changed_(const std::string &state) { auto number_value = parse_number(state); if (!number_value.has_value()) { - ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_.c_str(), state.c_str()); + ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_, state.c_str()); this->publish_state(NAN); return; } if (this->state == number_value.value()) { return; } - ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_.c_str(), state.c_str()); + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, state.c_str()); this->publish_state(number_value.value()); } void HomeassistantNumber::min_retrieved_(const std::string &min) { auto min_value = parse_number(min); if (!min_value.has_value()) { - ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_.c_str(), min.c_str()); + ESP_LOGE(TAG, "'%s': Can't convert 'min' value '%s' to number!", this->entity_id_, min.c_str()); return; } ESP_LOGD(TAG, "'%s': Min retrieved: %s", get_name().c_str(), min.c_str()); @@ -36,7 +36,7 @@ void HomeassistantNumber::min_retrieved_(const std::string &min) { void HomeassistantNumber::max_retrieved_(const std::string &max) { auto max_value = parse_number(max); if (!max_value.has_value()) { - ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_.c_str(), max.c_str()); + ESP_LOGE(TAG, "'%s': Can't convert 'max' value '%s' to number!", this->entity_id_, max.c_str()); return; } ESP_LOGD(TAG, "'%s': Max retrieved: %s", get_name().c_str(), max.c_str()); @@ -46,7 +46,7 @@ void HomeassistantNumber::max_retrieved_(const std::string &max) { void HomeassistantNumber::step_retrieved_(const std::string &step) { auto step_value = parse_number(step); if (!step_value.has_value()) { - ESP_LOGE(TAG, "'%s': Can't convert 'step' value '%s' to number!", this->entity_id_.c_str(), step.c_str()); + ESP_LOGE(TAG, "'%s': Can't convert 'step' value '%s' to number!", this->entity_id_, step.c_str()); return; } ESP_LOGD(TAG, "'%s': Step Retrieved %s", get_name().c_str(), step.c_str()); @@ -55,22 +55,19 @@ void HomeassistantNumber::step_retrieved_(const std::string &step) { void HomeassistantNumber::setup() { api::global_api_server->subscribe_home_assistant_state( - this->entity_id_, nullopt, std::bind(&HomeassistantNumber::state_changed_, this, std::placeholders::_1)); + this->entity_id_, nullptr, std::bind(&HomeassistantNumber::state_changed_, this, std::placeholders::_1)); api::global_api_server->get_home_assistant_state( - this->entity_id_, optional("min"), - std::bind(&HomeassistantNumber::min_retrieved_, this, std::placeholders::_1)); + this->entity_id_, "min", std::bind(&HomeassistantNumber::min_retrieved_, this, std::placeholders::_1)); api::global_api_server->get_home_assistant_state( - this->entity_id_, optional("max"), - std::bind(&HomeassistantNumber::max_retrieved_, this, std::placeholders::_1)); + this->entity_id_, "max", std::bind(&HomeassistantNumber::max_retrieved_, this, std::placeholders::_1)); api::global_api_server->get_home_assistant_state( - this->entity_id_, optional("step"), - std::bind(&HomeassistantNumber::step_retrieved_, this, std::placeholders::_1)); + this->entity_id_, "step", std::bind(&HomeassistantNumber::step_retrieved_, this, std::placeholders::_1)); } void HomeassistantNumber::dump_config() { LOG_NUMBER("", "Homeassistant Number", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); } float HomeassistantNumber::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/number/homeassistant_number.h b/esphome/components/homeassistant/number/homeassistant_number.h index 0860b4e91c..0dffc108cb 100644 --- a/esphome/components/homeassistant/number/homeassistant_number.h +++ b/esphome/components/homeassistant/number/homeassistant_number.h @@ -11,7 +11,7 @@ namespace homeassistant { class HomeassistantNumber : public number::Number, public Component { public: - void set_entity_id(const std::string &entity_id) { this->entity_id_ = entity_id; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void setup() override; void dump_config() override; @@ -25,7 +25,7 @@ class HomeassistantNumber : public number::Number, public Component { void control(float value) override; - std::string entity_id_; + const char *entity_id_{nullptr}; }; } // namespace homeassistant } // namespace esphome diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp index 35e660f7c1..78da47f9a1 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.cpp @@ -12,25 +12,24 @@ void HomeassistantSensor::setup() { this->entity_id_, this->attribute_, [this](const std::string &state) { auto val = parse_number(state); if (!val.has_value()) { - ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_.c_str(), state.c_str()); + ESP_LOGW(TAG, "'%s': Can't convert '%s' to number!", this->entity_id_, state.c_str()); this->publish_state(NAN); return; } - if (this->attribute_.has_value()) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_.c_str(), - this->attribute_.value().c_str(), *val); + if (this->attribute_ != nullptr) { + ESP_LOGD(TAG, "'%s::%s': Got attribute state %.2f", this->entity_id_, this->attribute_, *val); } else { - ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_.c_str(), *val); + ESP_LOGD(TAG, "'%s': Got state %.2f", this->entity_id_, *val); } this->publish_state(*val); }); } void HomeassistantSensor::dump_config() { LOG_SENSOR("", "Homeassistant Sensor", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); - if (this->attribute_.has_value()) { - ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_.value().c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); + if (this->attribute_ != nullptr) { + ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_); } } float HomeassistantSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/sensor/homeassistant_sensor.h b/esphome/components/homeassistant/sensor/homeassistant_sensor.h index 53b288d7d4..d89fc069ff 100644 --- a/esphome/components/homeassistant/sensor/homeassistant_sensor.h +++ b/esphome/components/homeassistant/sensor/homeassistant_sensor.h @@ -8,15 +8,15 @@ namespace homeassistant { class HomeassistantSensor : public sensor::Sensor, public Component { public: - void set_entity_id(const std::string &entity_id) { entity_id_ = entity_id; } - void set_attribute(const std::string &attribute) { attribute_ = attribute; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + void set_attribute(const char *attribute) { this->attribute_ = attribute; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: - std::string entity_id_; - optional attribute_; + const char *entity_id_{nullptr}; + const char *attribute_{nullptr}; }; } // namespace homeassistant diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.cpp b/esphome/components/homeassistant/switch/homeassistant_switch.cpp index 27d3705fc2..c4abf2295d 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.cpp +++ b/esphome/components/homeassistant/switch/homeassistant_switch.cpp @@ -10,7 +10,7 @@ static const char *const TAG = "homeassistant.switch"; using namespace esphome::switch_; void HomeassistantSwitch::setup() { - api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullopt, [this](const std::string &state) { + api::global_api_server->subscribe_home_assistant_state(this->entity_id_, nullptr, [this](const std::string &state) { auto val = parse_on_off(state.c_str()); switch (val) { case PARSE_NONE: @@ -20,7 +20,7 @@ void HomeassistantSwitch::setup() { case PARSE_ON: case PARSE_OFF: bool new_state = val == PARSE_ON; - ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_.c_str(), ONOFF(new_state)); + ESP_LOGD(TAG, "'%s': Got state %s", this->entity_id_, ONOFF(new_state)); this->publish_state(new_state); break; } @@ -29,7 +29,7 @@ void HomeassistantSwitch::setup() { void HomeassistantSwitch::dump_config() { LOG_SWITCH("", "Homeassistant Switch", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); } float HomeassistantSwitch::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/switch/homeassistant_switch.h b/esphome/components/homeassistant/switch/homeassistant_switch.h index a4da257960..c180b7f98a 100644 --- a/esphome/components/homeassistant/switch/homeassistant_switch.h +++ b/esphome/components/homeassistant/switch/homeassistant_switch.h @@ -8,14 +8,14 @@ namespace homeassistant { class HomeassistantSwitch : public switch_::Switch, public Component { public: - void set_entity_id(const std::string &entity_id) { this->entity_id_ = entity_id; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: void write_state(bool state) override; - std::string entity_id_; + const char *entity_id_{nullptr}; }; } // namespace homeassistant diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp index 9b933fbbbe..6154330a4e 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.cpp @@ -10,20 +10,19 @@ static const char *const TAG = "homeassistant.text_sensor"; void HomeassistantTextSensor::setup() { api::global_api_server->subscribe_home_assistant_state( this->entity_id_, this->attribute_, [this](const std::string &state) { - if (this->attribute_.has_value()) { - ESP_LOGD(TAG, "'%s::%s': Got attribute state '%s'", this->entity_id_.c_str(), - this->attribute_.value().c_str(), state.c_str()); + if (this->attribute_ != nullptr) { + ESP_LOGD(TAG, "'%s::%s': Got attribute state '%s'", this->entity_id_, this->attribute_, state.c_str()); } else { - ESP_LOGD(TAG, "'%s': Got state '%s'", this->entity_id_.c_str(), state.c_str()); + ESP_LOGD(TAG, "'%s': Got state '%s'", this->entity_id_, state.c_str()); } this->publish_state(state); }); } void HomeassistantTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Homeassistant Text Sensor", this); - ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_.c_str()); - if (this->attribute_.has_value()) { - ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_.value().c_str()); + ESP_LOGCONFIG(TAG, " Entity ID: '%s'", this->entity_id_); + if (this->attribute_ != nullptr) { + ESP_LOGCONFIG(TAG, " Attribute: '%s'", this->attribute_); } } float HomeassistantTextSensor::get_setup_priority() const { return setup_priority::AFTER_CONNECTION; } diff --git a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h index ce6b2c2c3f..4d66c65a17 100644 --- a/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h +++ b/esphome/components/homeassistant/text_sensor/homeassistant_text_sensor.h @@ -8,15 +8,15 @@ namespace homeassistant { class HomeassistantTextSensor : public text_sensor::TextSensor, public Component { public: - void set_entity_id(const std::string &entity_id) { entity_id_ = entity_id; } - void set_attribute(const std::string &attribute) { attribute_ = attribute; } + void set_entity_id(const char *entity_id) { this->entity_id_ = entity_id; } + void set_attribute(const char *attribute) { this->attribute_ = attribute; } void setup() override; void dump_config() override; float get_setup_priority() const override; protected: - std::string entity_id_; - optional attribute_; + const char *entity_id_{nullptr}; + const char *attribute_{nullptr}; }; } // namespace homeassistant diff --git a/esphome/components/http_request/http_request.h b/esphome/components/http_request/http_request.h index 8a82a44d7d..8adf13b954 100644 --- a/esphome/components/http_request/http_request.h +++ b/esphome/components/http_request/http_request.h @@ -255,6 +255,9 @@ template class HttpRequestSendAction : public Action { size_t read_index = 0; while (container->get_bytes_read() < max_length) { int read = container->read(buf + read_index, std::min(max_length - read_index, 512)); + if (read <= 0) { + break; + } App.feed_wdt(); yield(); read_index += read; diff --git a/esphome/components/http_request/ota/ota_http_request.cpp b/esphome/components/http_request/ota/ota_http_request.cpp index 4552fcc9df..b257518e06 100644 --- a/esphome/components/http_request/ota/ota_http_request.cpp +++ b/esphome/components/http_request/ota/ota_http_request.cpp @@ -132,11 +132,18 @@ uint8_t OtaHttpRequestComponent::do_ota_() { App.feed_wdt(); yield(); - if (bufsize < 0) { - ESP_LOGE(TAG, "Stream closed"); - this->cleanup_(std::move(backend), container); - return OTA_CONNECTION_ERROR; - } else if (bufsize > 0 && bufsize <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) { + // Exit loop if no data available (stream closed or end of data) + if (bufsize <= 0) { + if (bufsize < 0) { + ESP_LOGE(TAG, "Stream closed with error"); + this->cleanup_(std::move(backend), container); + return OTA_CONNECTION_ERROR; + } + // bufsize == 0: no more data available, exit loop + break; + } + + if (bufsize <= OtaHttpRequestComponent::HTTP_RECV_BUFFER) { // add read bytes to MD5 md5_receive.add(buf, bufsize); @@ -247,6 +254,9 @@ bool OtaHttpRequestComponent::http_get_md5_() { int read_len = 0; while (container->get_bytes_read() < MD5_SIZE) { read_len = container->read((uint8_t *) this->md5_expected_.data(), MD5_SIZE); + if (read_len <= 0) { + break; + } App.feed_wdt(); yield(); } diff --git a/esphome/components/http_request/update/http_request_update.cpp b/esphome/components/http_request/update/http_request_update.cpp index c91b0eba73..22cad625d1 100644 --- a/esphome/components/http_request/update/http_request_update.cpp +++ b/esphome/components/http_request/update/http_request_update.cpp @@ -36,6 +36,10 @@ void HttpRequestUpdate::setup() { } void HttpRequestUpdate::update() { + if (!network::is_connected()) { + ESP_LOGD(TAG, "Network not connected, skipping update check"); + return; + } #ifdef USE_ESP32 xTaskCreate(HttpRequestUpdate::update_task, "update_task", 8192, (void *) this, 1, &this->update_task_handle_); #else @@ -72,6 +76,11 @@ void HttpRequestUpdate::update_task(void *params) { yield(); + if (read_bytes <= 0) { + // Network error or connection closed - break to avoid infinite loop + break; + } + read_index += read_bytes; } diff --git a/esphome/components/hub75/__init__.py b/esphome/components/hub75/__init__.py new file mode 100644 index 0000000000..cd5441f749 --- /dev/null +++ b/esphome/components/hub75/__init__.py @@ -0,0 +1,6 @@ +from esphome.cpp_generator import MockObj + +CODEOWNERS = ["@stuartparmenter"] + +# Use fully-qualified namespace to avoid collision with external hub75 library's global ::hub75 namespace +hub75_ns = MockObj("::esphome::hub75", "::") diff --git a/esphome/components/hub75/boards/__init__.py b/esphome/components/hub75/boards/__init__.py new file mode 100644 index 0000000000..52f8864c60 --- /dev/null +++ b/esphome/components/hub75/boards/__init__.py @@ -0,0 +1,80 @@ +"""Board presets for HUB75 displays. + +Each board preset defines standard pin mappings for HUB75 controller boards. +""" + +from dataclasses import dataclass, field +import importlib +import pkgutil +from typing import ClassVar + + +class BoardRegistry: + """Global registry for board configurations.""" + + _boards: ClassVar[dict[str, "BoardConfig"]] = {} + + @classmethod + def register(cls, board: "BoardConfig") -> None: + """Register a board configuration.""" + cls._boards[board.name] = board + + @classmethod + def get_boards(cls) -> dict[str, "BoardConfig"]: + """Return all registered boards.""" + return cls._boards + + +@dataclass +class BoardConfig: + """Board configuration storing HUB75 pin mappings.""" + + name: str + r1_pin: int + g1_pin: int + b1_pin: int + r2_pin: int + g2_pin: int + b2_pin: int + a_pin: int + b_pin: int + c_pin: int + d_pin: int + e_pin: int | None + lat_pin: int + oe_pin: int + clk_pin: int + ignore_strapping_pins: tuple[str, ...] = () # e.g., ("a_pin", "clk_pin") + + # Derived field for pin lookup + pins: dict[str, int | None] = field(default_factory=dict, init=False, repr=False) + + def __post_init__(self): + """Initialize derived fields and register board.""" + self.name = self.name.lower() + self.pins = { + "r1": self.r1_pin, + "g1": self.g1_pin, + "b1": self.b1_pin, + "r2": self.r2_pin, + "g2": self.g2_pin, + "b2": self.b2_pin, + "a": self.a_pin, + "b": self.b_pin, + "c": self.c_pin, + "d": self.d_pin, + "e": self.e_pin, + "lat": self.lat_pin, + "oe": self.oe_pin, + "clk": self.clk_pin, + } + BoardRegistry.register(self) + + def get_pin(self, pin_name: str) -> int | None: + """Get pin number for a given pin name.""" + return self.pins.get(pin_name) + + +# Dynamically import all board definition modules +for module_info in pkgutil.iter_modules(__path__): + importlib.import_module(f".{module_info.name}", package=__package__) diff --git a/esphome/components/hub75/boards/adafruit.py b/esphome/components/hub75/boards/adafruit.py new file mode 100644 index 0000000000..e27eeb9379 --- /dev/null +++ b/esphome/components/hub75/boards/adafruit.py @@ -0,0 +1,23 @@ +"""Adafruit Matrix Portal board definitions.""" + +from . import BoardConfig + +# Adafruit Matrix Portal S3 +BoardConfig( + "adafruit-matrix-portal-s3", + r1_pin=42, + g1_pin=41, + b1_pin=40, + r2_pin=38, + g2_pin=39, + b2_pin=37, + a_pin=45, + b_pin=36, + c_pin=48, + d_pin=35, + e_pin=21, + lat_pin=47, + oe_pin=14, + clk_pin=2, + ignore_strapping_pins=("a_pin",), # GPIO45 is a strapping pin +) diff --git a/esphome/components/hub75/boards/apollo.py b/esphome/components/hub75/boards/apollo.py new file mode 100644 index 0000000000..4b8b2c1f0a --- /dev/null +++ b/esphome/components/hub75/boards/apollo.py @@ -0,0 +1,41 @@ +"""Apollo Automation M1 board definitions.""" + +from . import BoardConfig + +# Apollo Automation M1 Rev4 +BoardConfig( + "apollo-automation-m1-rev4", + r1_pin=42, + g1_pin=41, + b1_pin=40, + r2_pin=38, + g2_pin=39, + b2_pin=37, + a_pin=45, + b_pin=36, + c_pin=48, + d_pin=35, + e_pin=21, + lat_pin=47, + oe_pin=14, + clk_pin=2, +) + +# Apollo Automation M1 Rev6 +BoardConfig( + "apollo-automation-m1-rev6", + r1_pin=1, + g1_pin=5, + b1_pin=6, + r2_pin=7, + g2_pin=13, + b2_pin=9, + a_pin=16, + b_pin=48, + c_pin=47, + d_pin=21, + e_pin=38, + lat_pin=8, + oe_pin=4, + clk_pin=18, +) diff --git a/esphome/components/hub75/boards/huidu.py b/esphome/components/hub75/boards/huidu.py new file mode 100644 index 0000000000..52744d397e --- /dev/null +++ b/esphome/components/hub75/boards/huidu.py @@ -0,0 +1,22 @@ +"""Huidu board definitions.""" + +from . import BoardConfig + +# Huidu HD-WF2 +BoardConfig( + "huidu-hd-wf2", + r1_pin=2, + g1_pin=6, + b1_pin=10, + r2_pin=3, + g2_pin=7, + b2_pin=11, + a_pin=39, + b_pin=38, + c_pin=37, + d_pin=36, + e_pin=21, + lat_pin=33, + oe_pin=35, + clk_pin=34, +) diff --git a/esphome/components/hub75/boards/trinity.py b/esphome/components/hub75/boards/trinity.py new file mode 100644 index 0000000000..bfad779ad0 --- /dev/null +++ b/esphome/components/hub75/boards/trinity.py @@ -0,0 +1,24 @@ +"""ESP32 Trinity board definitions.""" + +from . import BoardConfig + +# ESP32 Trinity +# https://esp32trinity.com/ +# Pin assignments from: https://github.com/witnessmenow/ESP32-Trinity/blob/master/FAQ.md +BoardConfig( + "esp32-trinity", + r1_pin=25, + g1_pin=26, + b1_pin=27, + r2_pin=14, + g2_pin=12, + b2_pin=13, + a_pin=23, + b_pin=19, + c_pin=5, + d_pin=17, + e_pin=18, + lat_pin=4, + oe_pin=15, + clk_pin=16, +) diff --git a/esphome/components/hub75/display.py b/esphome/components/hub75/display.py new file mode 100644 index 0000000000..81dd4ffc1c --- /dev/null +++ b/esphome/components/hub75/display.py @@ -0,0 +1,578 @@ +from typing import Any + +from esphome import pins +import esphome.codegen as cg +from esphome.components import display +from esphome.components.esp32 import add_idf_component +import esphome.config_validation as cv +from esphome.const import ( + CONF_AUTO_CLEAR_ENABLED, + CONF_BIT_DEPTH, + CONF_BOARD, + CONF_BRIGHTNESS, + CONF_CLK_PIN, + CONF_GAMMA_CORRECT, + CONF_ID, + CONF_LAMBDA, + CONF_OE_PIN, + CONF_UPDATE_INTERVAL, +) +import esphome.final_validate as fv +from esphome.types import ConfigType + +from . import boards, hub75_ns + +DEPENDENCIES = ["esp32"] +CODEOWNERS = ["@stuartparmenter"] + +# Load all board presets +BOARDS = boards.BoardRegistry.get_boards() + +# Constants +CONF_HUB75_ID = "hub75_id" + +# Panel dimensions +CONF_PANEL_WIDTH = "panel_width" +CONF_PANEL_HEIGHT = "panel_height" + +# Multi-panel layout +CONF_LAYOUT_ROWS = "layout_rows" +CONF_LAYOUT_COLS = "layout_cols" +CONF_LAYOUT = "layout" + +# Panel hardware +CONF_SCAN_WIRING = "scan_wiring" +CONF_SHIFT_DRIVER = "shift_driver" + +# RGB pins +CONF_R1_PIN = "r1_pin" +CONF_G1_PIN = "g1_pin" +CONF_B1_PIN = "b1_pin" +CONF_R2_PIN = "r2_pin" +CONF_G2_PIN = "g2_pin" +CONF_B2_PIN = "b2_pin" + +# Address pins +CONF_A_PIN = "a_pin" +CONF_B_PIN = "b_pin" +CONF_C_PIN = "c_pin" +CONF_D_PIN = "d_pin" +CONF_E_PIN = "e_pin" + +# Control pins +CONF_LAT_PIN = "lat_pin" + +NEVER = 4294967295 # uint32_t max - value used when update_interval is "never" + +# Pin mapping from config keys to board keys +PIN_MAPPING = { + CONF_R1_PIN: "r1", + CONF_G1_PIN: "g1", + CONF_B1_PIN: "b1", + CONF_R2_PIN: "r2", + CONF_G2_PIN: "g2", + CONF_B2_PIN: "b2", + CONF_A_PIN: "a", + CONF_B_PIN: "b", + CONF_C_PIN: "c", + CONF_D_PIN: "d", + CONF_E_PIN: "e", + CONF_LAT_PIN: "lat", + CONF_OE_PIN: "oe", + CONF_CLK_PIN: "clk", +} + +# Required pins (E pin is optional) +REQUIRED_PINS = [key for key in PIN_MAPPING if key != CONF_E_PIN] + +# Configuration +CONF_CLOCK_SPEED = "clock_speed" +CONF_LATCH_BLANKING = "latch_blanking" +CONF_CLOCK_PHASE = "clock_phase" +CONF_DOUBLE_BUFFER = "double_buffer" +CONF_MIN_REFRESH_RATE = "min_refresh_rate" + +# Map to hub75 library enums (in global namespace) +ShiftDriver = cg.global_ns.enum("ShiftDriver", is_class=True) +SHIFT_DRIVERS = { + "GENERIC": ShiftDriver.GENERIC, + "FM6126A": ShiftDriver.FM6126A, + "ICN2038S": ShiftDriver.ICN2038S, + "FM6124": ShiftDriver.FM6124, + "MBI5124": ShiftDriver.MBI5124, + "DP3246": ShiftDriver.DP3246, +} + +PanelLayout = cg.global_ns.enum("PanelLayout", is_class=True) +PANEL_LAYOUTS = { + "HORIZONTAL": PanelLayout.HORIZONTAL, + "TOP_LEFT_DOWN": PanelLayout.TOP_LEFT_DOWN, + "TOP_RIGHT_DOWN": PanelLayout.TOP_RIGHT_DOWN, + "BOTTOM_LEFT_UP": PanelLayout.BOTTOM_LEFT_UP, + "BOTTOM_RIGHT_UP": PanelLayout.BOTTOM_RIGHT_UP, + "TOP_LEFT_DOWN_ZIGZAG": PanelLayout.TOP_LEFT_DOWN_ZIGZAG, + "TOP_RIGHT_DOWN_ZIGZAG": PanelLayout.TOP_RIGHT_DOWN_ZIGZAG, + "BOTTOM_LEFT_UP_ZIGZAG": PanelLayout.BOTTOM_LEFT_UP_ZIGZAG, + "BOTTOM_RIGHT_UP_ZIGZAG": PanelLayout.BOTTOM_RIGHT_UP_ZIGZAG, +} + +ScanPattern = cg.global_ns.enum("ScanPattern", is_class=True) +SCAN_PATTERNS = { + "STANDARD_TWO_SCAN": ScanPattern.STANDARD_TWO_SCAN, + "FOUR_SCAN_16PX_HIGH": ScanPattern.FOUR_SCAN_16PX_HIGH, + "FOUR_SCAN_32PX_HIGH": ScanPattern.FOUR_SCAN_32PX_HIGH, + "FOUR_SCAN_64PX_HIGH": ScanPattern.FOUR_SCAN_64PX_HIGH, +} + +Hub75ClockSpeed = cg.global_ns.enum("Hub75ClockSpeed", is_class=True) +CLOCK_SPEEDS = { + "8MHZ": Hub75ClockSpeed.HZ_8M, + "10MHZ": Hub75ClockSpeed.HZ_10M, + "16MHZ": Hub75ClockSpeed.HZ_16M, + "20MHZ": Hub75ClockSpeed.HZ_20M, +} + +HUB75Display = hub75_ns.class_("HUB75Display", cg.PollingComponent, display.Display) +Hub75Config = cg.global_ns.struct("Hub75Config") +Hub75Pins = cg.global_ns.struct("Hub75Pins") + + +def _merge_board_pins(config: ConfigType) -> ConfigType: + """Merge board preset pins with explicit pin overrides.""" + board_name = config.get(CONF_BOARD) + + if board_name is None: + # No board specified - validate that all required pins are present + errs = [ + cv.Invalid( + f"Required pin '{pin_name}' is missing. " + f"Either specify a board preset or provide all pin mappings manually.", + path=[pin_name], + ) + for pin_name in REQUIRED_PINS + if pin_name not in config + ] + + if errs: + raise cv.MultipleInvalid(errs) + + # E_PIN is optional + return config + + # Get board configuration + if board_name not in BOARDS: + raise cv.Invalid( + f"Unknown board '{board_name}'. Available boards: {', '.join(sorted(BOARDS.keys()))}" + ) + + board = BOARDS[board_name] + + # Merge board pins with explicit overrides + # Explicit pins in config take precedence over board defaults + for conf_key, board_key in PIN_MAPPING.items(): + if conf_key in config or (board_pin := board.get_pin(board_key)) is None: + continue + # Create pin config + pin_config = {"number": board_pin} + if conf_key in board.ignore_strapping_pins: + pin_config["ignore_strapping_warning"] = True + + # Validate through pin schema to add required fields (id, etc.) + config[conf_key] = pins.gpio_output_pin_schema(pin_config) + + return config + + +def _validate_config(config: ConfigType) -> ConfigType: + """Validate driver and layout requirements.""" + errs: list[cv.Invalid] = [] + + # MBI5124 requires inverted clock phase + driver = config.get(CONF_SHIFT_DRIVER, "GENERIC") + if driver == "MBI5124" and not config.get(CONF_CLOCK_PHASE, False): + errs.append( + cv.Invalid( + "MBI5124 shift driver requires 'clock_phase: true' to be set", + path=[CONF_CLOCK_PHASE], + ) + ) + + # Prevent conflicting min_refresh_rate + update_interval configuration + # min_refresh_rate is auto-calculated from update_interval unless using LVGL mode + update_interval = config.get(CONF_UPDATE_INTERVAL) + if CONF_MIN_REFRESH_RATE in config and update_interval is not None: + # Handle both integer (NEVER) and time object cases + interval_ms = ( + update_interval + if isinstance(update_interval, int) + else update_interval.total_milliseconds + ) + if interval_ms != NEVER: + errs.append( + cv.Invalid( + "Cannot set both 'min_refresh_rate' and 'update_interval' (except 'never'). " + "Refresh rate is auto-calculated from update_interval. " + "Remove 'min_refresh_rate' or use 'update_interval: never' for LVGL mode.", + path=[CONF_MIN_REFRESH_RATE], + ) + ) + + # Validate layout configuration (validate effective config including C++ defaults) + layout = config.get(CONF_LAYOUT, "HORIZONTAL") + layout_rows = config.get(CONF_LAYOUT_ROWS, 1) + layout_cols = config.get(CONF_LAYOUT_COLS, 1) + is_zigzag = "ZIGZAG" in layout + + # Single panel (1x1) should use HORIZONTAL + if layout_rows == 1 and layout_cols == 1 and layout != "HORIZONTAL": + errs.append( + cv.Invalid( + f"Single panel (layout_rows=1, layout_cols=1) should use 'layout: HORIZONTAL' (got {layout})", + path=[CONF_LAYOUT], + ) + ) + + # HORIZONTAL layout requires single row + if layout == "HORIZONTAL" and layout_rows != 1: + errs.append( + cv.Invalid( + f"HORIZONTAL layout requires 'layout_rows: 1' (got {layout_rows}). " + "For multi-row grids, use TOP_LEFT_DOWN or other grid layouts.", + path=[CONF_LAYOUT_ROWS], + ) + ) + + # Grid layouts (non-HORIZONTAL) require more than one panel + if layout != "HORIZONTAL" and layout_rows == 1 and layout_cols == 1: + errs.append( + cv.Invalid( + f"Grid layout '{layout}' requires multiple panels (layout_rows > 1 or layout_cols > 1)", + path=[CONF_LAYOUT], + ) + ) + + # Serpentine layouts (non-ZIGZAG) require multiple rows + # Serpentine physically rotates alternate rows upside down (Y-coordinate inversion) + # Single-row chains should use HORIZONTAL or ZIGZAG variants + if not is_zigzag and layout != "HORIZONTAL" and layout_rows == 1: + errs.append( + cv.Invalid( + f"Serpentine layout '{layout}' requires layout_rows > 1 " + f"(got layout_rows={layout_rows}). " + "Serpentine wiring physically rotates alternate rows upside down. " + "For single-row chains, use 'layout: HORIZONTAL' or add '_ZIGZAG' suffix.", + path=[CONF_LAYOUT_ROWS], + ) + ) + + # ZIGZAG layouts require actual grid (both rows AND cols > 1) + if is_zigzag and (layout_rows == 1 or layout_cols == 1): + errs.append( + cv.Invalid( + f"ZIGZAG layout '{layout}' requires both layout_rows > 1 AND layout_cols > 1 " + f"(got rows={layout_rows}, cols={layout_cols}). " + "For single row/column chains, use non-zigzag layouts or HORIZONTAL.", + path=[CONF_LAYOUT], + ) + ) + + if errs: + raise cv.MultipleInvalid(errs) + + return config + + +def _final_validate(config: ConfigType) -> ConfigType: + """Validate requirements when using HUB75 display.""" + # Local imports to avoid circular dependencies + from esphome.components.esp32 import get_esp32_variant + from esphome.components.esp32.const import VARIANT_ESP32P4 + from esphome.components.lvgl import DOMAIN as LVGL_DOMAIN + from esphome.components.psram import DOMAIN as PSRAM_DOMAIN + + full_config = fv.full_config.get() + errs: list[cv.Invalid] = [] + + # ESP32-P4 requires PSRAM + variant = get_esp32_variant() + if variant == VARIANT_ESP32P4 and PSRAM_DOMAIN not in full_config: + errs.append( + cv.Invalid( + "HUB75 display on ESP32-P4 requires PSRAM. Add 'psram:' to your configuration.", + path=[CONF_ID], + ) + ) + + # LVGL-specific validation + if LVGL_DOMAIN in full_config: + # Check update_interval (converted from "never" to NEVER constant) + update_interval = config.get(CONF_UPDATE_INTERVAL) + if update_interval is not None: + # Handle both integer (NEVER) and time object cases + interval_ms = ( + update_interval + if isinstance(update_interval, int) + else update_interval.total_milliseconds + ) + if interval_ms != NEVER: + errs.append( + cv.Invalid( + "HUB75 display with LVGL must have 'update_interval: never'. " + "LVGL manages its own refresh timing.", + path=[CONF_UPDATE_INTERVAL], + ) + ) + + # Check auto_clear_enabled + auto_clear = config[CONF_AUTO_CLEAR_ENABLED] + if auto_clear is not False: + errs.append( + cv.Invalid( + f"HUB75 display with LVGL must have 'auto_clear_enabled: false' (got '{auto_clear}'). " + "LVGL manages screen clearing.", + path=[CONF_AUTO_CLEAR_ENABLED], + ) + ) + + # Check double_buffer (C++ default: false) + double_buffer = config.get(CONF_DOUBLE_BUFFER, False) + if double_buffer is not False: + errs.append( + cv.Invalid( + f"HUB75 display with LVGL must have 'double_buffer: false' (got '{double_buffer}'). " + "LVGL uses its own buffering strategy.", + path=[CONF_DOUBLE_BUFFER], + ) + ) + + if errs: + raise cv.MultipleInvalid(errs) + + return config + + +FINAL_VALIDATE_SCHEMA = cv.Schema(_final_validate) + + +CONFIG_SCHEMA = cv.All( + display.FULL_DISPLAY_SCHEMA.extend( + { + cv.GenerateID(): cv.declare_id(HUB75Display), + # Board preset (optional - provides default pin mappings) + cv.Optional(CONF_BOARD): cv.one_of(*BOARDS.keys(), lower=True), + # Panel dimensions + cv.Required(CONF_PANEL_WIDTH): cv.positive_int, + cv.Required(CONF_PANEL_HEIGHT): cv.positive_int, + # Multi-panel layout + cv.Optional(CONF_LAYOUT_ROWS): cv.positive_int, + cv.Optional(CONF_LAYOUT_COLS): cv.positive_int, + cv.Optional(CONF_LAYOUT): cv.enum(PANEL_LAYOUTS, upper=True, space="_"), + # Panel hardware configuration + cv.Optional(CONF_SCAN_WIRING): cv.enum( + SCAN_PATTERNS, upper=True, space="_" + ), + cv.Optional(CONF_SHIFT_DRIVER): cv.enum(SHIFT_DRIVERS, upper=True), + # Display configuration + cv.Optional(CONF_DOUBLE_BUFFER): cv.boolean, + cv.Optional(CONF_BRIGHTNESS): cv.int_range(min=0, max=255), + cv.Optional(CONF_BIT_DEPTH): cv.int_range(min=6, max=12), + cv.Optional(CONF_GAMMA_CORRECT): cv.enum( + {"LINEAR": 0, "CIE1931": 1, "GAMMA_2_2": 2}, upper=True + ), + cv.Optional(CONF_MIN_REFRESH_RATE): cv.int_range(min=40, max=200), + # RGB data pins + cv.Optional(CONF_R1_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_G1_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_B1_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_R2_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_G2_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_B2_PIN): pins.gpio_output_pin_schema, + # Address pins + cv.Optional(CONF_A_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_B_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_C_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_D_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_E_PIN): pins.gpio_output_pin_schema, + # Control pins + cv.Optional(CONF_LAT_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_OE_PIN): pins.gpio_output_pin_schema, + cv.Optional(CONF_CLK_PIN): pins.gpio_output_pin_schema, + # Timing configuration + cv.Optional(CONF_CLOCK_SPEED): cv.enum(CLOCK_SPEEDS, upper=True), + cv.Optional(CONF_LATCH_BLANKING): cv.positive_int, + cv.Optional(CONF_CLOCK_PHASE): cv.boolean, + } + ), + _merge_board_pins, + _validate_config, +) + + +DEFAULT_REFRESH_RATE = 60 # Hz + + +def _calculate_min_refresh_rate(config: ConfigType) -> int: + """Calculate minimum refresh rate for the display. + + Priority: + 1. Explicit min_refresh_rate setting (user override) + 2. Derived from update_interval (ms to Hz conversion) + 3. Default 60 Hz (for LVGL or unspecified interval) + """ + if CONF_MIN_REFRESH_RATE in config: + return config[CONF_MIN_REFRESH_RATE] + + update_interval = config.get(CONF_UPDATE_INTERVAL) + if update_interval is None: + return DEFAULT_REFRESH_RATE + + # update_interval can be TimePeriod object or NEVER constant (int) + interval_ms = ( + update_interval + if isinstance(update_interval, int) + else update_interval.total_milliseconds + ) + + # "never" or zero means external refresh (e.g., LVGL) + if interval_ms in (NEVER, 0): + return DEFAULT_REFRESH_RATE + + # Convert ms interval to Hz, clamped to valid range [40, 200] + return max(40, min(200, int(round(1000 / interval_ms)))) + + +def _build_pins_struct( + pin_expressions: dict[str, Any], e_pin_num: int | cg.RawExpression +) -> cg.StructInitializer: + """Build Hub75Pins struct from pin expressions.""" + + def pin_cast(pin): + return cg.RawExpression(f"static_cast({pin.get_pin()})") + + return cg.StructInitializer( + Hub75Pins, + ("r1", pin_cast(pin_expressions["r1"])), + ("g1", pin_cast(pin_expressions["g1"])), + ("b1", pin_cast(pin_expressions["b1"])), + ("r2", pin_cast(pin_expressions["r2"])), + ("g2", pin_cast(pin_expressions["g2"])), + ("b2", pin_cast(pin_expressions["b2"])), + ("a", pin_cast(pin_expressions["a"])), + ("b", pin_cast(pin_expressions["b"])), + ("c", pin_cast(pin_expressions["c"])), + ("d", pin_cast(pin_expressions["d"])), + ("e", e_pin_num), + ("lat", pin_cast(pin_expressions["lat"])), + ("oe", pin_cast(pin_expressions["oe"])), + ("clk", pin_cast(pin_expressions["clk"])), + ) + + +def _append_config_fields( + config: ConfigType, + field_mapping: list[tuple[str, str]], + config_fields: list[tuple[str, Any]], +) -> None: + """Append config fields from mapping if present in config.""" + for conf_key, struct_field in field_mapping: + if conf_key in config: + config_fields.append((struct_field, config[conf_key])) + + +def _build_config_struct( + config: ConfigType, pins_struct: cg.StructInitializer, min_refresh: int +) -> cg.StructInitializer: + """Build Hub75Config struct from config. + + Fields must be added in declaration order (see hub75_types.h) to satisfy + C++ designated initializer requirements. The order is: + 1. fields_before_pins (panel_width through layout) + 2. pins + 3. output_clock_speed + 4. min_refresh_rate + 5. fields_after_min_refresh (latch_blanking through brightness) + """ + fields_before_pins = [ + (CONF_PANEL_WIDTH, "panel_width"), + (CONF_PANEL_HEIGHT, "panel_height"), + # scan_pattern - auto-calculated, not set + (CONF_SCAN_WIRING, "scan_wiring"), + (CONF_SHIFT_DRIVER, "shift_driver"), + (CONF_LAYOUT_ROWS, "layout_rows"), + (CONF_LAYOUT_COLS, "layout_cols"), + (CONF_LAYOUT, "layout"), + ] + fields_after_min_refresh = [ + (CONF_LATCH_BLANKING, "latch_blanking"), + (CONF_DOUBLE_BUFFER, "double_buffer"), + (CONF_CLOCK_PHASE, "clk_phase_inverted"), + (CONF_BRIGHTNESS, "brightness"), + ] + + config_fields: list[tuple[str, Any]] = [] + + _append_config_fields(config, fields_before_pins, config_fields) + + config_fields.append(("pins", pins_struct)) + + if CONF_CLOCK_SPEED in config: + config_fields.append(("output_clock_speed", config[CONF_CLOCK_SPEED])) + + config_fields.append(("min_refresh_rate", min_refresh)) + + _append_config_fields(config, fields_after_min_refresh, config_fields) + + return cg.StructInitializer(Hub75Config, *config_fields) + + +async def to_code(config: ConfigType) -> None: + add_idf_component( + name="esphome/esp-hub75", + ref="0.1.6", + ) + + # Set compile-time configuration via defines + if CONF_BIT_DEPTH in config: + cg.add_define("HUB75_BIT_DEPTH", config[CONF_BIT_DEPTH]) + + if CONF_GAMMA_CORRECT in config: + cg.add_define("HUB75_GAMMA_MODE", config[CONF_GAMMA_CORRECT]) + + # Await all pin expressions + pin_expressions = { + "r1": await cg.gpio_pin_expression(config[CONF_R1_PIN]), + "g1": await cg.gpio_pin_expression(config[CONF_G1_PIN]), + "b1": await cg.gpio_pin_expression(config[CONF_B1_PIN]), + "r2": await cg.gpio_pin_expression(config[CONF_R2_PIN]), + "g2": await cg.gpio_pin_expression(config[CONF_G2_PIN]), + "b2": await cg.gpio_pin_expression(config[CONF_B2_PIN]), + "a": await cg.gpio_pin_expression(config[CONF_A_PIN]), + "b": await cg.gpio_pin_expression(config[CONF_B_PIN]), + "c": await cg.gpio_pin_expression(config[CONF_C_PIN]), + "d": await cg.gpio_pin_expression(config[CONF_D_PIN]), + "lat": await cg.gpio_pin_expression(config[CONF_LAT_PIN]), + "oe": await cg.gpio_pin_expression(config[CONF_OE_PIN]), + "clk": await cg.gpio_pin_expression(config[CONF_CLK_PIN]), + } + + # E pin is optional + if CONF_E_PIN in config: + e_pin = await cg.gpio_pin_expression(config[CONF_E_PIN]) + e_pin_num = cg.RawExpression(f"static_cast({e_pin.get_pin()})") + else: + e_pin_num = -1 + + # Build structs + min_refresh = _calculate_min_refresh_rate(config) + pins_struct = _build_pins_struct(pin_expressions, e_pin_num) + hub75_config = _build_config_struct(config, pins_struct, min_refresh) + + # Create display and register + var = cg.new_Pvariable(config[CONF_ID], hub75_config) + await display.register_display(var, config) + + if CONF_LAMBDA in config: + lambda_ = await cg.process_lambda( + config[CONF_LAMBDA], [(display.DisplayRef, "it")], return_type=cg.void + ) + cg.add(var.set_writer(lambda_)) diff --git a/esphome/components/hub75/hub75.cpp b/esphome/components/hub75/hub75.cpp new file mode 100644 index 0000000000..e023e446c4 --- /dev/null +++ b/esphome/components/hub75/hub75.cpp @@ -0,0 +1,192 @@ +#include "hub75_component.h" +#include "esphome/core/application.h" + +#ifdef USE_ESP32 + +namespace esphome::hub75 { + +static const char *const TAG = "hub75"; + +// ======================================== +// Constructor +// ======================================== + +HUB75Display::HUB75Display(const Hub75Config &config) : config_(config) { + // Initialize runtime state from config + this->brightness_ = config.brightness; + this->enabled_ = (config.brightness > 0); +} + +// ======================================== +// Core Component methods +// ======================================== + +void HUB75Display::setup() { + ESP_LOGCONFIG(TAG, "Setting up HUB75Display..."); + + // Create driver with pre-configured config + driver_ = new Hub75Driver(config_); + if (!driver_->begin()) { + ESP_LOGE(TAG, "Failed to initialize HUB75 driver!"); + return; + } + + this->enabled_ = true; +} + +void HUB75Display::dump_config() { + LOG_DISPLAY("", "HUB75", this); + + ESP_LOGCONFIG(TAG, + " Panel: %dx%d pixels\n" + " Layout: %dx%d panels\n" + " Virtual Display: %dx%d pixels", + config_.panel_width, config_.panel_height, config_.layout_cols, config_.layout_rows, + config_.panel_width * config_.layout_cols, config_.panel_height * config_.layout_rows); + + ESP_LOGCONFIG(TAG, + " Scan Wiring: %d\n" + " Shift Driver: %d", + static_cast(config_.scan_wiring), static_cast(config_.shift_driver)); + + ESP_LOGCONFIG(TAG, + " Pins: R1:%i, G1:%i, B1:%i, R2:%i, G2:%i, B2:%i\n" + " Pins: A:%i, B:%i, C:%i, D:%i, E:%i\n" + " Pins: LAT:%i, OE:%i, CLK:%i", + config_.pins.r1, config_.pins.g1, config_.pins.b1, config_.pins.r2, config_.pins.g2, config_.pins.b2, + config_.pins.a, config_.pins.b, config_.pins.c, config_.pins.d, config_.pins.e, config_.pins.lat, + config_.pins.oe, config_.pins.clk); + + ESP_LOGCONFIG(TAG, + " Clock Speed: %u MHz\n" + " Latch Blanking: %i\n" + " Clock Phase: %s\n" + " Min Refresh Rate: %i Hz\n" + " Bit Depth: %i\n" + " Double Buffer: %s", + static_cast(config_.output_clock_speed) / 1000000, config_.latch_blanking, + TRUEFALSE(config_.clk_phase_inverted), config_.min_refresh_rate, HUB75_BIT_DEPTH, + YESNO(config_.double_buffer)); +} + +// ======================================== +// Display/PollingComponent methods +// ======================================== + +void HUB75Display::update() { + if (!driver_) [[unlikely]] + return; + if (!this->enabled_) [[unlikely]] + return; + + this->do_update_(); + + if (config_.double_buffer) { + driver_->flip_buffer(); + } +} + +void HUB75Display::fill(Color color) { + if (!driver_) [[unlikely]] + return; + if (!this->enabled_) [[unlikely]] + return; + + // Special case: black (off) - use fast hardware clear + if (!color.is_on()) { + driver_->clear(); + return; + } + + // For non-black colors, fall back to base class (pixel-by-pixel) + Display::fill(color); +} + +void HOT HUB75Display::draw_pixel_at(int x, int y, Color color) { + if (!driver_) [[unlikely]] + return; + if (!this->enabled_) [[unlikely]] + return; + + if (x >= this->get_width_internal() || x < 0 || y >= this->get_height_internal() || y < 0) [[unlikely]] + return; + + driver_->set_pixel(x, y, color.r, color.g, color.b); + App.feed_wdt(); +} + +void HOT HUB75Display::draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, ColorOrder order, + ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) { + if (!driver_) [[unlikely]] + return; + if (!this->enabled_) [[unlikely]] + return; + + // Map ESPHome enums to hub75 enums + Hub75PixelFormat format; + Hub75ColorOrder color_order = Hub75ColorOrder::RGB; + int bytes_per_pixel; + + // Determine format based on bitness + if (bitness == ColorBitness::COLOR_BITNESS_565) { + format = Hub75PixelFormat::RGB565; + bytes_per_pixel = 2; + } else if (bitness == ColorBitness::COLOR_BITNESS_888) { +#ifdef USE_LVGL +#if LV_COLOR_DEPTH == 32 + // 32-bit: 4 bytes per pixel with padding byte (LVGL mode) + format = Hub75PixelFormat::RGB888_32; + bytes_per_pixel = 4; + + // Map ESPHome ColorOrder to Hub75ColorOrder + // ESPHome ColorOrder is typically BGR for little-endian 32-bit + color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::RGB : Hub75ColorOrder::BGR; +#elif LV_COLOR_DEPTH == 24 + // 24-bit: 3 bytes per pixel, tightly packed + format = Hub75PixelFormat::RGB888; + bytes_per_pixel = 3; + // Note: 24-bit is always RGB order in LVGL +#else + ESP_LOGE(TAG, "Unsupported LV_COLOR_DEPTH: %d", LV_COLOR_DEPTH); + return; +#endif +#else + // Non-LVGL mode: standard 24-bit RGB888 + format = Hub75PixelFormat::RGB888; + bytes_per_pixel = 3; + color_order = (order == ColorOrder::COLOR_ORDER_RGB) ? Hub75ColorOrder::RGB : Hub75ColorOrder::BGR; +#endif + } else { + ESP_LOGE(TAG, "Unsupported bitness: %d", static_cast(bitness)); + return; + } + + // Check if buffer is tightly packed (no stride) + const int stride_px = x_offset + w + x_pad; + const bool is_packed = (x_offset == 0 && x_pad == 0 && y_offset == 0); + + if (is_packed) { + // Tightly packed buffer - single bulk call for best performance + driver_->draw_pixels(x_start, y_start, w, h, ptr, format, color_order, big_endian); + } else { + // Buffer has stride (padding between rows) - draw row by row + for (int yy = 0; yy < h; ++yy) { + const size_t row_offset = ((y_offset + yy) * stride_px + x_offset) * bytes_per_pixel; + const uint8_t *row_ptr = ptr + row_offset; + + driver_->draw_pixels(x_start, y_start + yy, w, 1, row_ptr, format, color_order, big_endian); + } + } +} + +void HUB75Display::set_brightness(int brightness) { + this->brightness_ = brightness; + this->enabled_ = (brightness > 0); + if (this->driver_ != nullptr) { + this->driver_->set_brightness(brightness); + } +} + +} // namespace esphome::hub75 + +#endif diff --git a/esphome/components/hub75/hub75_component.h b/esphome/components/hub75/hub75_component.h new file mode 100644 index 0000000000..49d4274483 --- /dev/null +++ b/esphome/components/hub75/hub75_component.h @@ -0,0 +1,55 @@ +#pragma once + +#ifdef USE_ESP32 + +#include + +#include "esphome/components/display/display_buffer.h" +#include "esphome/core/component.h" +#include "esphome/core/hal.h" +#include "esphome/core/log.h" +#include "hub75.h" // hub75 library + +namespace esphome::hub75 { + +using esphome::display::ColorBitness; +using esphome::display::ColorOrder; + +class HUB75Display : public display::Display { + public: + // Constructor accepting config + explicit HUB75Display(const Hub75Config &config); + + // Core Component methods + void setup() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::PROCESSOR; } + + // Display/PollingComponent methods + void update() override; + display::DisplayType get_display_type() override { return display::DisplayType::DISPLAY_TYPE_COLOR; } + void fill(Color color) override; + void draw_pixel_at(int x, int y, Color color) override; + void draw_pixels_at(int x_start, int y_start, int w, int h, const uint8_t *ptr, display::ColorOrder order, + display::ColorBitness bitness, bool big_endian, int x_offset, int y_offset, int x_pad) override; + + // Brightness control (runtime mutable) + void set_brightness(int brightness); + + protected: + // Display internal methods + int get_width_internal() override { return config_.panel_width * config_.layout_cols; } + int get_height_internal() override { return config_.panel_height * config_.layout_rows; } + + // Member variables + Hub75Driver *driver_{nullptr}; + Hub75Config config_; // Immutable configuration + + // Runtime state (mutable) + int brightness_{128}; + bool enabled_{false}; +}; + +} // namespace esphome::hub75 + +#endif diff --git a/esphome/components/i2c/__init__.py b/esphome/components/i2c/__init__.py index 738568cd3c..9e7c9d702c 100644 --- a/esphome/components/i2c/__init__.py +++ b/esphome/components/i2c/__init__.py @@ -2,6 +2,23 @@ import logging from esphome import pins import esphome.codegen as cg +from esphome.components import esp32 +from esphome.components.esp32 import ( + VARIANT_ESP32, + VARIANT_ESP32C2, + VARIANT_ESP32C3, + VARIANT_ESP32C5, + VARIANT_ESP32C6, + VARIANT_ESP32C61, + VARIANT_ESP32H2, + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + get_esp32_variant, +) +from esphome.components.esp32.gpio_esp32_c5 import esp32_c5_validate_lp_i2c +from esphome.components.esp32.gpio_esp32_c6 import esp32_c6_validate_lp_i2c +from esphome.components.esp32.gpio_esp32_p4 import esp32_p4_validate_lp_i2c from esphome.components.zephyr import ( zephyr_add_overlay, zephyr_add_prj_conf, @@ -16,6 +33,7 @@ from esphome.const import ( CONF_I2C, CONF_I2C_ID, CONF_ID, + CONF_LOW_POWER_MODE, CONF_SCAN, CONF_SCL, CONF_SDA, @@ -40,6 +58,25 @@ IDFI2CBus = i2c_ns.class_("IDFI2CBus", InternalI2CBus, cg.Component) ZephyrI2CBus = i2c_ns.class_("ZephyrI2CBus", I2CBus, cg.Component) I2CDevice = i2c_ns.class_("I2CDevice") +ESP32_I2C_CAPABILITIES = { + # https://github.com/espressif/esp-idf/blob/master/components/soc/esp32/include/soc/soc_caps.h + VARIANT_ESP32: {"NUM": 2, "HP": 2}, + VARIANT_ESP32C2: {"NUM": 1, "HP": 1}, + VARIANT_ESP32C3: {"NUM": 1, "HP": 1}, + VARIANT_ESP32C5: {"NUM": 2, "HP": 1, "LP": 1}, + VARIANT_ESP32C6: {"NUM": 2, "HP": 1, "LP": 1}, + VARIANT_ESP32C61: {"NUM": 1, "HP": 1}, + VARIANT_ESP32H2: {"NUM": 2, "HP": 2}, + VARIANT_ESP32P4: {"NUM": 3, "HP": 2, "LP": 1}, + VARIANT_ESP32S2: {"NUM": 2, "HP": 2}, + VARIANT_ESP32S3: {"NUM": 2, "HP": 2}, +} +VALIDATE_LP_I2C = { + VARIANT_ESP32C5: esp32_c5_validate_lp_i2c, + VARIANT_ESP32C6: esp32_c6_validate_lp_i2c, + VARIANT_ESP32P4: esp32_p4_validate_lp_i2c, +} +LP_I2C_VARIANT = list(VALIDATE_LP_I2C.keys()) CONF_SDA_PULLUP_ENABLED = "sda_pullup_enabled" CONF_SCL_PULLUP_ENABLED = "scl_pullup_enabled" @@ -91,6 +128,13 @@ CONFIG_SCHEMA = cv.All( cv.positive_time_period, ), cv.Optional(CONF_SCAN, default=True): cv.boolean, + cv.Optional(CONF_LOW_POWER_MODE): cv.All( + cv.only_on_esp32, + esp32.only_on_variant( + supported=LP_I2C_VARIANT, msg_prefix="Low power i2c" + ), + cv.boolean, + ), } ).extend(cv.COMPONENT_SCHEMA), cv.only_on([PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, PLATFORM_NRF52]), @@ -102,6 +146,31 @@ def _final_validate(config): full_config = fv.full_config.get()[CONF_I2C] if CORE.using_zephyr and len(full_config) > 1: raise cv.Invalid("Second i2c is not implemented on Zephyr yet") + if CORE.using_esp_idf and get_esp32_variant() in ESP32_I2C_CAPABILITIES: + variant = get_esp32_variant() + max_num = ESP32_I2C_CAPABILITIES[variant]["NUM"] + if len(full_config) > max_num: + raise cv.Invalid( + f"The maximum number of i2c interfaces for {variant} is {max_num}" + ) + if variant in LP_I2C_VARIANT: + max_lp_num = ESP32_I2C_CAPABILITIES[variant]["LP"] + max_hp_num = ESP32_I2C_CAPABILITIES[variant]["HP"] + lp_num = sum( + CONF_LOW_POWER_MODE in conf and conf[CONF_LOW_POWER_MODE] + for conf in full_config + ) + hp_num = len(full_config) - lp_num + if CONF_LOW_POWER_MODE in config and config[CONF_LOW_POWER_MODE]: + VALIDATE_LP_I2C[variant](config) + if lp_num > max_lp_num: + raise cv.Invalid( + f"The maximum number of low power i2c interfaces for {variant} is {max_lp_num}" + ) + if hp_num > max_hp_num: + raise cv.Invalid( + f"The maximum number of high power i2c interfaces for {variant} is {max_hp_num}" + ) FINAL_VALIDATE_SCHEMA = _final_validate @@ -155,6 +224,8 @@ async def to_code(config): cg.add(var.set_timeout(int(config[CONF_TIMEOUT].total_microseconds))) if CORE.using_arduino and not CORE.is_esp32: cg.add_library("Wire", None) + if CONF_LOW_POWER_MODE in config: + cg.add(var.set_lp_mode(bool(config[CONF_LOW_POWER_MODE]))) def i2c_device_schema(default_address): diff --git a/esphome/components/i2c/i2c_bus_esp_idf.cpp b/esphome/components/i2c/i2c_bus_esp_idf.cpp index c22db51c68..486dc0b7d8 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.cpp +++ b/esphome/components/i2c/i2c_bus_esp_idf.cpp @@ -16,13 +16,10 @@ namespace i2c { static const char *const TAG = "i2c.idf"; void IDFI2CBus::setup() { - static i2c_port_t next_port = I2C_NUM_0; - this->port_ = next_port; - if (this->port_ == I2C_NUM_MAX) { - ESP_LOGE(TAG, "No more than %u buses supported", I2C_NUM_MAX); - this->mark_failed(); - return; - } + static i2c_port_t next_hp_port = I2C_NUM_0; +#if SOC_LP_I2C_SUPPORTED + static i2c_port_t next_lp_port = LP_I2C_NUM_0; +#endif if (this->timeout_ > 13000) { ESP_LOGW(TAG, "Using max allowed timeout: 13 ms"); @@ -31,23 +28,35 @@ void IDFI2CBus::setup() { this->recover_(); - next_port = (i2c_port_t) (next_port + 1); - i2c_master_bus_config_t bus_conf{}; memset(&bus_conf, 0, sizeof(bus_conf)); bus_conf.sda_io_num = gpio_num_t(sda_pin_); bus_conf.scl_io_num = gpio_num_t(scl_pin_); - bus_conf.i2c_port = this->port_; bus_conf.glitch_ignore_cnt = 7; #if SOC_LP_I2C_SUPPORTED - if (this->port_ < SOC_HP_I2C_NUM) { - bus_conf.clk_source = I2C_CLK_SRC_DEFAULT; - } else { + if (this->lp_mode_) { + if ((next_lp_port - LP_I2C_NUM_0) == SOC_LP_I2C_NUM) { + ESP_LOGE(TAG, "No more than %u LP buses supported", SOC_LP_I2C_NUM); + this->mark_failed(); + return; + } + this->port_ = next_lp_port; + next_lp_port = (i2c_port_t) (next_lp_port + 1); bus_conf.lp_source_clk = LP_I2C_SCLK_DEFAULT; - } -#else - bus_conf.clk_source = I2C_CLK_SRC_DEFAULT; + } else { #endif + if (next_hp_port == SOC_HP_I2C_NUM) { + ESP_LOGE(TAG, "No more than %u HP buses supported", SOC_HP_I2C_NUM); + this->mark_failed(); + return; + } + this->port_ = next_hp_port; + next_hp_port = (i2c_port_t) (next_hp_port + 1); + bus_conf.clk_source = I2C_CLK_SRC_DEFAULT; +#if SOC_LP_I2C_SUPPORTED + } +#endif + bus_conf.i2c_port = this->port_; bus_conf.flags.enable_internal_pullup = sda_pullup_enabled_ || scl_pullup_enabled_; esp_err_t err = i2c_new_master_bus(&bus_conf, &this->bus_); if (err != ESP_OK) { diff --git a/esphome/components/i2c/i2c_bus_esp_idf.h b/esphome/components/i2c/i2c_bus_esp_idf.h index 63fe8b701c..84f4616967 100644 --- a/esphome/components/i2c/i2c_bus_esp_idf.h +++ b/esphome/components/i2c/i2c_bus_esp_idf.h @@ -30,6 +30,9 @@ class IDFI2CBus : public InternalI2CBus, public Component { void set_scl_pullup_enabled(bool scl_pullup_enabled) { this->scl_pullup_enabled_ = scl_pullup_enabled; } void set_frequency(uint32_t frequency) { this->frequency_ = frequency; } void set_timeout(uint32_t timeout) { this->timeout_ = timeout; } +#if SOC_LP_I2C_SUPPORTED + void set_lp_mode(bool lp_mode) { this->lp_mode_ = lp_mode; } +#endif int get_port() const override { return this->port_; } @@ -48,6 +51,9 @@ class IDFI2CBus : public InternalI2CBus, public Component { uint32_t frequency_{}; uint32_t timeout_ = 0; bool initialized_ = false; +#if SOC_LP_I2C_SUPPORTED + bool lp_mode_ = false; +#endif }; } // namespace i2c diff --git a/esphome/components/i2s_audio/__init__.py b/esphome/components/i2s_audio/__init__.py index 907429ee0e..61c5ca4ec1 100644 --- a/esphome/components/i2s_audio/__init__.py +++ b/esphome/components/i2s_audio/__init__.py @@ -1,15 +1,17 @@ from esphome import pins import esphome.codegen as cg -from esphome.components.esp32 import add_idf_sdkconfig_option, get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + add_idf_sdkconfig_option, + get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import CONF_BITS_PER_SAMPLE, CONF_CHANNEL, CONF_ID, CONF_SAMPLE_RATE @@ -68,13 +70,14 @@ I2S_ROLE_OPTIONS = { # https://github.com/espressif/esp-idf/blob/master/components/soc/{variant}/include/soc/soc_caps.h (SOC_I2S_NUM) I2S_PORTS = { VARIANT_ESP32: 2, - VARIANT_ESP32S2: 1, - VARIANT_ESP32S3: 2, VARIANT_ESP32C3: 1, VARIANT_ESP32C5: 1, VARIANT_ESP32C6: 1, + VARIANT_ESP32C61: 1, VARIANT_ESP32H2: 1, VARIANT_ESP32P4: 3, + VARIANT_ESP32S2: 1, + VARIANT_ESP32S3: 2, } i2s_channel_fmt_t = cg.global_ns.enum("i2s_channel_fmt_t") diff --git a/esphome/components/i2s_audio/media_player/__init__.py b/esphome/components/i2s_audio/media_player/__init__.py index 316ce7c48b..35c42e1b06 100644 --- a/esphome/components/i2s_audio/media_player/__init__.py +++ b/esphome/components/i2s_audio/media_player/__init__.py @@ -40,7 +40,7 @@ INTERNAL_DAC_OPTIONS = { EXTERNAL_DAC_OPTIONS = [CONF_MONO, CONF_STEREO] -NO_INTERNAL_DAC_VARIANTS = [esp32.const.VARIANT_ESP32S2] +NO_INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32S2] I2C_COMM_FMT_OPTIONS = ["lsb", "msb"] diff --git a/esphome/components/i2s_audio/microphone/__init__.py b/esphome/components/i2s_audio/microphone/__init__.py index f919199c60..dd23673db5 100644 --- a/esphome/components/i2s_audio/microphone/__init__.py +++ b/esphome/components/i2s_audio/microphone/__init__.py @@ -37,8 +37,8 @@ I2SAudioMicrophone = i2s_audio_ns.class_( "I2SAudioMicrophone", I2SAudioIn, microphone.Microphone, cg.Component ) -INTERNAL_ADC_VARIANTS = [esp32.const.VARIANT_ESP32] -PDM_VARIANTS = [esp32.const.VARIANT_ESP32, esp32.const.VARIANT_ESP32S3] +INTERNAL_ADC_VARIANTS = [esp32.VARIANT_ESP32] +PDM_VARIANTS = [esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S3] def _validate_esp32_variant(config): diff --git a/esphome/components/i2s_audio/speaker/__init__.py b/esphome/components/i2s_audio/speaker/__init__.py index 98322d3a18..2e009a1de1 100644 --- a/esphome/components/i2s_audio/speaker/__init__.py +++ b/esphome/components/i2s_audio/speaker/__init__.py @@ -62,7 +62,7 @@ I2C_COMM_FMT_OPTIONS = { "pcm_long": i2s_comm_format_t.I2S_COMM_FORMAT_PCM_LONG, } -INTERNAL_DAC_VARIANTS = [esp32.const.VARIANT_ESP32] +INTERNAL_DAC_VARIANTS = [esp32.VARIANT_ESP32] def _set_num_channels_from_config(config): diff --git a/esphome/components/improv_serial/__init__.py b/esphome/components/improv_serial/__init__.py index fb2b541707..7f88b17e11 100644 --- a/esphome/components/improv_serial/__init__.py +++ b/esphome/components/improv_serial/__init__.py @@ -1,7 +1,6 @@ import esphome.codegen as cg from esphome.components import improv_base -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import VARIANT_ESP32S3 +from esphome.components.esp32 import VARIANT_ESP32S3, get_esp32_variant from esphome.components.logger import USB_CDC import esphome.config_validation as cv from esphome.const import CONF_BAUD_RATE, CONF_HARDWARE_UART, CONF_ID, CONF_LOGGER diff --git a/esphome/components/improv_serial/improv_serial_component.cpp b/esphome/components/improv_serial/improv_serial_component.cpp index 70260eeab3..281e95d12b 100644 --- a/esphome/components/improv_serial/improv_serial_component.cpp +++ b/esphome/components/improv_serial/improv_serial_component.cpp @@ -70,9 +70,10 @@ optional ImprovSerialComponent::read_byte_() { case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: #if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) case logger::UART_SELECTION_UART2: -#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3 +#endif // !USE_ESP32_VARIANT_ESP32C3 && !USE_ESP32_VARIANT_ESP32C6 && !USE_ESP32_VARIANT_ESP32C61 && + // !USE_ESP32_VARIANT_ESP32S2 && !USE_ESP32_VARIANT_ESP32S3 if (this->uart_num_ >= 0) { size_t available; uart_get_buffered_data_len(this->uart_num_, &available); @@ -137,7 +138,7 @@ void ImprovSerialComponent::write_data_(const uint8_t *data, const size_t size) case logger::UART_SELECTION_UART0: case logger::UART_SELECTION_UART1: #if !defined(USE_ESP32_VARIANT_ESP32C3) && !defined(USE_ESP32_VARIANT_ESP32C6) && \ - !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) + !defined(USE_ESP32_VARIANT_ESP32C61) && !defined(USE_ESP32_VARIANT_ESP32S2) && !defined(USE_ESP32_VARIANT_ESP32S3) case logger::UART_SELECTION_UART2: #endif uart_write_bytes(this->uart_num_, this->tx_header_, header_tx_len); diff --git a/esphome/components/improv_serial/improv_serial_component.h b/esphome/components/improv_serial/improv_serial_component.h index 057247f376..dd8f5e4719 100644 --- a/esphome/components/improv_serial/improv_serial_component.h +++ b/esphome/components/improv_serial/improv_serial_component.h @@ -11,8 +11,8 @@ #ifdef USE_ESP32 #include -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S3) || \ - defined(USE_ESP32_VARIANT_ESP32H2) +#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || \ + defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32S3) #include #include #endif diff --git a/esphome/components/internal_temperature/internal_temperature.cpp b/esphome/components/internal_temperature/internal_temperature.cpp index 28ac55d6de..2ef8cf2649 100644 --- a/esphome/components/internal_temperature/internal_temperature.cpp +++ b/esphome/components/internal_temperature/internal_temperature.cpp @@ -7,9 +7,9 @@ extern "C" { uint8_t temprature_sens_read(); } -#elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32P4) +#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ + defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "driver/temperature_sensor.h" #endif // USE_ESP32_VARIANT #endif // USE_ESP32 @@ -27,9 +27,9 @@ namespace internal_temperature { static const char *const TAG = "internal_temperature"; #ifdef USE_ESP32 -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32C2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) static temperature_sensor_handle_t tsensNew = NULL; #endif // USE_ESP32_VARIANT #endif // USE_ESP32 @@ -43,9 +43,9 @@ void InternalTemperatureSensor::update() { ESP_LOGV(TAG, "Raw temperature value: %d", raw); temperature = (raw - 32) / 1.8f; success = (raw != 128); -#elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ - defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32P4) +#elif defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ + defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) esp_err_t result = temperature_sensor_get_celsius(tsensNew, &temperature); success = (result == ESP_OK); if (!success) { @@ -81,9 +81,9 @@ void InternalTemperatureSensor::update() { void InternalTemperatureSensor::setup() { #ifdef USE_ESP32 -#if defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32S2) || \ - defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32C2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32C2) || defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C6) || \ + defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || defined(USE_ESP32_VARIANT_ESP32P4) || \ + defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) temperature_sensor_config_t tsens_config = TEMPERATURE_SENSOR_CONFIG_DEFAULT(-10, 80); esp_err_t result = temperature_sensor_install(&tsens_config, &tsensNew); diff --git a/esphome/components/kalman_combinator/sensor.py b/esphome/components/kalman_combinator/sensor.py index c19a17462d..d30a41d6bf 100644 --- a/esphome/components/kalman_combinator/sensor.py +++ b/esphome/components/kalman_combinator/sensor.py @@ -2,5 +2,5 @@ import esphome.config_validation as cv CONFIG_SCHEMA = cv.invalid( "The kalman_combinator sensor has moved.\nPlease use the combination platform instead with type: kalman.\n" - "See https://esphome.io/components/sensor/combination.html" + "See https://esphome.io/components/sensor/combination/" ) diff --git a/esphome/components/ld2420/ld2420.cpp b/esphome/components/ld2420/ld2420.cpp index 4fca9494aa..10c623bce0 100644 --- a/esphome/components/ld2420/ld2420.cpp +++ b/esphome/components/ld2420/ld2420.cpp @@ -204,8 +204,10 @@ void LD2420Component::dump_config() { LOG_BUTTON(" ", "Factory Reset:", this->factory_reset_button_); LOG_BUTTON(" ", "Restart Module:", this->restart_module_button_); #endif +#ifdef USE_SELECT ESP_LOGCONFIG(TAG, "Select:"); LOG_SELECT(" ", "Operating Mode", this->operating_selector_); +#endif if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) { ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_); } @@ -237,12 +239,20 @@ void LD2420Component::setup() { memcpy(&this->new_config, &this->current_config, sizeof(this->current_config)); if (ld2420::get_firmware_int(this->firmware_ver_) < CALIBRATE_VERSION_MIN) { this->set_operating_mode(OP_SIMPLE_MODE_STRING); - this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); + } +#endif this->set_mode_(CMD_SYSTEM_MODE_SIMPLE); ESP_LOGW(TAG, "Firmware version %s and older supports Simple Mode only", this->firmware_ver_); } else { this->set_mode_(CMD_SYSTEM_MODE_ENERGY); - this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING); +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(OP_NORMAL_MODE_STRING); + } +#endif } #ifdef USE_NUMBER this->init_gate_config_numbers(); @@ -382,8 +392,12 @@ void LD2420Component::set_operating_mode(const char *state) { // If unsupported firmware ignore mode select if (ld2420::get_firmware_int(firmware_ver_) >= CALIBRATE_VERSION_MIN) { this->current_operating_mode = find_uint8(OP_MODE_BY_STR, state); - // Entering Auto Calibrate we need to clear the privoiuos data collection - this->operating_selector_->publish_state(state); + // Entering Auto Calibrate we need to clear the previous data collection +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(state); + } +#endif if (current_operating_mode == OP_CALIBRATE_MODE) { this->set_calibration_(true); for (uint8_t gate = 0; gate < TOTAL_GATES; gate++) { @@ -403,7 +417,11 @@ void LD2420Component::set_operating_mode(const char *state) { } } else { this->current_operating_mode = OP_SIMPLE_MODE; - this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); +#ifdef USE_SELECT + if (this->operating_selector_ != nullptr) { + this->operating_selector_->publish_state(OP_SIMPLE_MODE_STRING); + } +#endif } } diff --git a/esphome/components/light/addressable_light.h b/esphome/components/light/addressable_light.h index 2e4b984ce4..fcaf07f578 100644 --- a/esphome/components/light/addressable_light.h +++ b/esphome/components/light/addressable_light.h @@ -70,7 +70,7 @@ class AddressableLight : public LightOutput, public Component { this->state_parent_ = state; } void update_state(LightState *state) override; - void schedule_show() { this->state_parent_->next_write_ = true; } + void schedule_show() { this->state_parent_->schedule_write_(); } #ifdef USE_POWER_SUPPLY void set_power_supply(power_supply::PowerSupply *power_supply) { this->power_.set_parent(power_supply); } diff --git a/esphome/components/light/light_call.cpp b/esphome/components/light/light_call.cpp index dca5861734..8161e8b814 100644 --- a/esphome/components/light/light_call.cpp +++ b/esphome/components/light/light_call.cpp @@ -504,8 +504,8 @@ color_mode_bitmask_t LightCall::get_suitable_color_modes_mask_() { #undef KEY } -LightCall &LightCall::set_effect(const std::string &effect) { - if (strcasecmp(effect.c_str(), "none") == 0) { +LightCall &LightCall::set_effect(const char *effect, size_t len) { + if (len == 4 && strncasecmp(effect, "none", 4) == 0) { this->set_effect(0); return *this; } @@ -513,15 +513,16 @@ LightCall &LightCall::set_effect(const std::string &effect) { bool found = false; for (uint32_t i = 0; i < this->parent_->effects_.size(); i++) { LightEffect *e = this->parent_->effects_[i]; + const char *name = e->get_name(); - if (strcasecmp(effect.c_str(), e->get_name()) == 0) { + if (strncasecmp(effect, name, len) == 0 && name[len] == '\0') { this->set_effect(i + 1); found = true; break; } } if (!found) { - ESP_LOGW(TAG, "'%s': no such effect '%s'", this->parent_->get_name().c_str(), effect.c_str()); + ESP_LOGW(TAG, "'%s': no such effect '%.*s'", this->parent_->get_name().c_str(), (int) len, effect); } return *this; } diff --git a/esphome/components/light/light_call.h b/esphome/components/light/light_call.h index 6931b58b9d..0926ab6108 100644 --- a/esphome/components/light/light_call.h +++ b/esphome/components/light/light_call.h @@ -129,7 +129,9 @@ class LightCall { /// Set the effect of the light by its name. LightCall &set_effect(optional effect); /// Set the effect of the light by its name. - LightCall &set_effect(const std::string &effect); + LightCall &set_effect(const std::string &effect) { return this->set_effect(effect.data(), effect.size()); } + /// Set the effect of the light by its name and length (zero-copy from API). + LightCall &set_effect(const char *effect, size_t len); /// Set the effect of the light by its internal index number (only for internal use). LightCall &set_effect(uint32_t effect_number); LightCall &set_effect(optional effect_number); diff --git a/esphome/components/light/light_state.cpp b/esphome/components/light/light_state.cpp index af619a426a..5a50bae50b 100644 --- a/esphome/components/light/light_state.cpp +++ b/esphome/components/light/light_state.cpp @@ -305,8 +305,7 @@ void LightState::set_immediately_(const LightColorValues &target, bool set_remot this->remote_values = target; } this->output_->update_state(this); - this->next_write_ = true; - this->enable_loop(); + this->schedule_write_(); } void LightState::disable_loop_if_idle_() { diff --git a/esphome/components/light/light_state.h b/esphome/components/light/light_state.h index 7ea72306f9..a21c2c7693 100644 --- a/esphome/components/light/light_state.h +++ b/esphome/components/light/light_state.h @@ -277,6 +277,12 @@ class LightState : public EntityBase, public Component { /// Disable loop if neither transformer nor effect is active void disable_loop_if_idle_(); + /// Schedule a write to the light output and enable the loop to process it + void schedule_write_() { + this->next_write_ = true; + this->enable_loop(); + } + /// Store the output to allow effects to have more access. LightOutput *output_; /// The currently active transformer for this light (transition/flash). diff --git a/esphome/components/logger/__init__.py b/esphome/components/logger/__init__.py index d9ca44d3c9..fb0ce92cc9 100644 --- a/esphome/components/logger/__init__.py +++ b/esphome/components/logger/__init__.py @@ -3,17 +3,19 @@ import re from esphome import automation from esphome.automation import LambdaAction, StatelessLambdaAction import esphome.codegen as cg -from esphome.components.esp32 import add_idf_sdkconfig_option, get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + add_idf_sdkconfig_option, + get_esp32_variant, ) from esphome.components.libretiny import get_libretiny_component, get_libretiny_family from esphome.components.libretiny.const import ( @@ -100,14 +102,15 @@ CONF_TASK_LOG_BUFFER_SIZE = "task_log_buffer_size" UART_SELECTION_ESP32 = { VARIANT_ESP32: [UART0, UART1, UART2], - VARIANT_ESP32S2: [UART0, UART1, USB_CDC], - VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], - VARIANT_ESP32C3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C2: [UART0, UART1], + VARIANT_ESP32C3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C5: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32C6: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32C61: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32H2: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], VARIANT_ESP32P4: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], + VARIANT_ESP32S2: [UART0, UART1, USB_CDC], + VARIANT_ESP32S3: [UART0, UART1, USB_CDC, USB_SERIAL_JTAG], } UART_SELECTION_ESP8266 = [UART0, UART0_SWAP, UART1] @@ -238,12 +241,12 @@ CONFIG_SCHEMA = cv.All( CONF_HARDWARE_UART, esp8266=UART0, esp32=UART0, - esp32_s2=USB_CDC, - esp32_s3=USB_SERIAL_JTAG, esp32_c3=USB_SERIAL_JTAG, esp32_c5=USB_SERIAL_JTAG, esp32_c6=USB_SERIAL_JTAG, esp32_p4=USB_SERIAL_JTAG, + esp32_s2=USB_CDC, + esp32_s3=USB_SERIAL_JTAG, rp2040=USB_CDC, bk72xx=DEFAULT, ln882x=DEFAULT, diff --git a/esphome/components/lvgl/__init__.py b/esphome/components/lvgl/__init__.py index 040661495c..19c258fcd5 100644 --- a/esphome/components/lvgl/__init__.py +++ b/esphome/components/lvgl/__init__.py @@ -1,5 +1,6 @@ import importlib import logging +from pathlib import Path import pkgutil from esphome.automation import build_automation, validate_automation @@ -26,6 +27,7 @@ from esphome.core import CORE, ID, Lambda from esphome.cpp_generator import MockObj from esphome.final_validate import full_config from esphome.helpers import write_file_if_changed +from esphome.yaml_util import load_yaml from . import defines as df, helpers, lv_validation as lvalid, widgets from .automation import disp_update, focused_widgets, refreshed_widgets @@ -37,7 +39,6 @@ from .encoders import ( initial_focus_to_code, ) from .gradient import GRADIENT_SCHEMA, gradients_to_code -from .hello_world import get_hello_world from .keypads import KEYPADS_CONFIG, keypads_to_code from .lv_validation import lv_bool, lv_images_used from .lvcode import LvContext, LvglComponent, lvgl_static @@ -84,6 +85,7 @@ DEPENDENCIES = ["display"] AUTO_LOAD = ["key_provider"] CODEOWNERS = ["@clydebarrow"] LOGGER = logging.getLogger(__name__) +HELLO_WORLD_FILE = "hello_world.yaml" SIMPLE_TRIGGERS = ( @@ -354,7 +356,8 @@ def display_schema(config): def add_hello_world(config): if df.CONF_WIDGETS not in config and CONF_PAGES not in config: LOGGER.info("No pages or widgets configured, creating default hello_world page") - config[df.CONF_WIDGETS] = any_widget_schema()(get_hello_world()) + hello_world_path = Path(__file__).parent / HELLO_WORLD_FILE + config[df.CONF_WIDGETS] = any_widget_schema()(load_yaml(hello_world_path)) return config diff --git a/esphome/components/lvgl/hello_world.py b/esphome/components/lvgl/hello_world.py deleted file mode 100644 index f85da9d8e4..0000000000 --- a/esphome/components/lvgl/hello_world.py +++ /dev/null @@ -1,127 +0,0 @@ -from io import StringIO - -from esphome.yaml_util import parse_yaml - -CONFIG = """ -- obj: - id: hello_world_card_ - pad_all: 12 - bg_color: white - height: 100% - width: 100% - scrollable: false - widgets: - - obj: - align: top_mid - outline_width: 0 - border_width: 0 - pad_all: 4 - scrollable: false - height: size_content - width: 100% - layout: - type: flex - flex_flow: row - flex_align_cross: center - flex_align_track: start - flex_align_main: space_between - widgets: - - button: - checkable: true - radius: 4 - text_font: montserrat_20 - on_click: - lvgl.label.update: - id: hello_world_label_ - text: "Clicked!" - widgets: - - label: - text: "Button" - - label: - id: hello_world_title_ - text: ESPHome - text_font: montserrat_20 - width: 100% - text_align: center - on_boot: - lvgl.widget.refresh: hello_world_title_ - hidden: !lambda |- - return lv_obj_get_width(lv_scr_act()) < 400; - - checkbox: - text: Checkbox - id: hello_world_checkbox_ - on_boot: - lvgl.widget.refresh: hello_world_checkbox_ - hidden: !lambda |- - return lv_obj_get_width(lv_scr_act()) < 240; - on_click: - lvgl.label.update: - id: hello_world_label_ - text: "Checked!" - - obj: - id: hello_world_container_ - align: center - y: 14 - pad_all: 0 - outline_width: 0 - border_width: 0 - width: 100% - height: size_content - scrollable: false - on_click: - lvgl.spinner.update: - id: hello_world_spinner_ - arc_color: springgreen - layout: - type: flex - flex_flow: row_wrap - flex_align_cross: center - flex_align_track: center - flex_align_main: space_evenly - widgets: - - spinner: - id: hello_world_spinner_ - indicator: - arc_color: tomato - height: 100 - width: 100 - spin_time: 2s - arc_length: 60deg - widgets: - - label: - id: hello_world_label_ - text: "Hello World!" - align: center - - obj: - id: hello_world_qrcode_ - outline_width: 0 - border_width: 0 - hidden: !lambda |- - return lv_obj_get_width(lv_scr_act()) < 300 && lv_obj_get_height(lv_scr_act()) < 400; - widgets: - - label: - text_font: montserrat_14 - text: esphome.io - align: top_mid - - qrcode: - text: "https://esphome.io" - size: 80 - align: bottom_mid - on_boot: - lvgl.widget.refresh: hello_world_qrcode_ - - - slider: - width: 80% - align: bottom_mid - on_value: - lvgl.label.update: - id: hello_world_label_ - text: - format: "%.0f%%" - args: [x] -""" - - -def get_hello_world(): - with StringIO(CONFIG) as fp: - return parse_yaml("hello_world", fp) diff --git a/esphome/components/lvgl/hello_world.yaml b/esphome/components/lvgl/hello_world.yaml new file mode 100644 index 0000000000..359e73cd52 --- /dev/null +++ b/esphome/components/lvgl/hello_world.yaml @@ -0,0 +1,118 @@ +# This file defines a placeholder LVGL "Hello World" that is shown when no +# widgets are configured. +- obj: + id: hello_world_card_ + pad_all: 12 + bg_color: white + height: 100% + width: 100% + scrollable: false + widgets: + - obj: + align: top_mid + outline_width: 0 + border_width: 0 + pad_all: 4 + scrollable: false + height: size_content + width: 100% + layout: + type: flex + flex_flow: row + flex_align_cross: center + flex_align_track: start + flex_align_main: space_between + widgets: + - button: + checkable: true + radius: 4 + text_font: montserrat_20 + on_click: + lvgl.label.update: + id: hello_world_label_ + text: "Clicked!" + widgets: + - label: + text: "Button" + - label: + id: hello_world_title_ + text: ESPHome + text_font: montserrat_20 + width: 100% + text_align: center + on_boot: + lvgl.widget.refresh: hello_world_title_ + hidden: !lambda |- + return lv_obj_get_width(lv_scr_act()) < 400; + - checkbox: + text: Checkbox + id: hello_world_checkbox_ + on_boot: + lvgl.widget.refresh: hello_world_checkbox_ + hidden: !lambda |- + return lv_obj_get_width(lv_scr_act()) < 240; + on_click: + lvgl.label.update: + id: hello_world_label_ + text: "Checked!" + - obj: + id: hello_world_container_ + align: center + y: 14 + pad_all: 0 + outline_width: 0 + border_width: 0 + width: 100% + height: size_content + scrollable: false + on_click: + lvgl.spinner.update: + id: hello_world_spinner_ + arc_color: springgreen + layout: + type: flex + flex_flow: row_wrap + flex_align_cross: center + flex_align_track: center + flex_align_main: space_evenly + widgets: + - spinner: + id: hello_world_spinner_ + indicator: + arc_color: tomato + height: 100 + width: 100 + spin_time: 2s + arc_length: 60deg + widgets: + - label: + id: hello_world_label_ + text: "Hello World!" + align: center + - obj: + id: hello_world_qrcode_ + outline_width: 0 + border_width: 0 + hidden: !lambda |- + return lv_obj_get_width(lv_scr_act()) < 300 && lv_obj_get_height(lv_scr_act()) < 400; + widgets: + - label: + text_font: montserrat_14 + text: esphome.io + align: top_mid + - qrcode: + text: "https://esphome.io" + size: 80 + align: bottom_mid + on_boot: + lvgl.widget.refresh: hello_world_qrcode_ + + - slider: + width: 80% + align: bottom_mid + on_value: + lvgl.label.update: + id: hello_world_label_ + text: + format: "%.0f%%" + args: [x] diff --git a/esphome/components/lvgl/lvgl_esphome.cpp b/esphome/components/lvgl/lvgl_esphome.cpp index 18226a9f57..50dba94a2b 100644 --- a/esphome/components/lvgl/lvgl_esphome.cpp +++ b/esphome/components/lvgl/lvgl_esphome.cpp @@ -498,12 +498,12 @@ void LvglComponent::setup() { buf_bytes /= MIN_BUFFER_FRAC; buffer = lv_custom_mem_alloc(buf_bytes); // NOLINT } + this->buffer_frac_ = frac; if (buffer == nullptr) { this->status_set_error(LOG_STR("Memory allocation failure")); this->mark_failed(); return; } - this->buffer_frac_ = frac; lv_disp_draw_buf_init(&this->draw_buf_, buffer, nullptr, buffer_pixels); this->disp_drv_.hor_res = display->get_width(); this->disp_drv_.ver_res = display->get_height(); diff --git a/esphome/components/lvgl/number/lvgl_number.h b/esphome/components/lvgl/number/lvgl_number.h index 7bc44c9e20..d9885bc7fb 100644 --- a/esphome/components/lvgl/number/lvgl_number.h +++ b/esphome/components/lvgl/number/lvgl_number.h @@ -29,15 +29,18 @@ class LVGLNumber : public number::Number, public Component { this->publish_state(value); } - void on_value() { this->publish_state(this->value_lambda_()); } + void on_value() { this->publish_(this->value_lambda_()); } protected: - void control(float value) override { - this->control_lambda_(value); + void publish_(float value) { this->publish_state(value); if (this->restore_) this->pref_.save(&value); } + void control(float value) override { + this->control_lambda_(value); + this->publish_(value); + } std::function control_lambda_; std::function value_lambda_; lv_event_code_t event_; diff --git a/esphome/components/md5/__init__.py b/esphome/components/md5/__init__.py index 1af9ee0b29..1710b00e66 100644 --- a/esphome/components/md5/__init__.py +++ b/esphome/components/md5/__init__.py @@ -1,7 +1,17 @@ import esphome.codegen as cg +from esphome.core import CORE +from esphome.helpers import IS_MACOS CODEOWNERS = ["@esphome/core"] async def to_code(config): cg.add_define("USE_MD5") + + # Add OpenSSL library for host platform + if CORE.is_host: + if IS_MACOS: + # macOS needs special handling for Homebrew OpenSSL + cg.add_build_flag("-I/opt/homebrew/opt/openssl/include") + cg.add_build_flag("-L/opt/homebrew/opt/openssl/lib") + cg.add_build_flag("-lcrypto") diff --git a/esphome/components/md5/md5.cpp b/esphome/components/md5/md5.cpp index 866f00eda4..26554e4d3c 100644 --- a/esphome/components/md5/md5.cpp +++ b/esphome/components/md5/md5.cpp @@ -39,6 +39,44 @@ void MD5Digest::add(const uint8_t *data, size_t len) { br_md5_update(&this->ctx_ void MD5Digest::calculate() { br_md5_out(&this->ctx_, this->digest_); } #endif // USE_RP2040 +#ifdef USE_HOST +MD5Digest::~MD5Digest() { + if (this->ctx_) { + EVP_MD_CTX_free(this->ctx_); + } +} + +void MD5Digest::init() { + if (this->ctx_) { + EVP_MD_CTX_free(this->ctx_); + } + this->ctx_ = EVP_MD_CTX_new(); + EVP_DigestInit_ex(this->ctx_, EVP_md5(), nullptr); + this->calculated_ = false; + memset(this->digest_, 0, 16); +} + +void MD5Digest::add(const uint8_t *data, size_t len) { + if (!this->ctx_) { + this->init(); + } + EVP_DigestUpdate(this->ctx_, data, len); +} + +void MD5Digest::calculate() { + if (!this->ctx_) { + this->init(); + } + if (!this->calculated_) { + unsigned int len = 16; + EVP_DigestFinal_ex(this->ctx_, this->digest_, &len); + this->calculated_ = true; + } +} +#else +MD5Digest::~MD5Digest() = default; +#endif // USE_HOST + } // namespace md5 } // namespace esphome #endif diff --git a/esphome/components/md5/md5.h b/esphome/components/md5/md5.h index b0da2c0a3b..6ff651b02e 100644 --- a/esphome/components/md5/md5.h +++ b/esphome/components/md5/md5.h @@ -5,6 +5,10 @@ #include "esphome/core/hash_base.h" +#ifdef USE_HOST +#include +#endif + #ifdef USE_ESP32 #include "esp_rom_md5.h" #define MD5_CTX_TYPE md5_context_t @@ -31,7 +35,7 @@ namespace md5 { class MD5Digest : public HashBase { public: MD5Digest() = default; - ~MD5Digest() override = default; + ~MD5Digest() override; /// Initialize a new MD5 digest computation. void init() override; @@ -47,7 +51,12 @@ class MD5Digest : public HashBase { size_t get_size() const override { return 16; } protected: +#ifdef USE_HOST + EVP_MD_CTX *ctx_{nullptr}; + bool calculated_{false}; +#else MD5_CTX_TYPE ctx_{}; +#endif }; } // namespace md5 diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index 1daac93a2e..99b728b249 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -184,10 +184,8 @@ async def to_code(config): # Calculate compile-time dynamic TXT value count # Dynamic values are those that cannot be stored in flash at compile time + # Note: MAC address is now stored in a fixed char[13] buffer, not dynamic storage dynamic_txt_count = 0 - if "api" in CORE.config: - # Always: get_mac_address() - dynamic_txt_count += 1 # User-provided templatable TXT values (only lambdas, not static strings) dynamic_txt_count += sum( 1 @@ -196,8 +194,10 @@ async def to_code(config): if cg.is_template(txt_value) ) - # Ensure at least 1 to avoid zero-size array - cg.add_define("MDNS_DYNAMIC_TXT_COUNT", max(1, dynamic_txt_count)) + # Only add define if we actually need dynamic storage + if dynamic_txt_count > 0: + cg.add_define("USE_MDNS_DYNAMIC_TXT") + cg.add_define("MDNS_DYNAMIC_TXT_COUNT", dynamic_txt_count) # Enable storage if verbose logging is enabled (for dump_config) if get_logger_level() in ("VERBOSE", "VERY_VERBOSE"): diff --git a/esphome/components/mdns/mdns_component.cpp b/esphome/components/mdns/mdns_component.cpp index 4655907983..47db92610a 100644 --- a/esphome/components/mdns/mdns_component.cpp +++ b/esphome/components/mdns/mdns_component.cpp @@ -35,7 +35,7 @@ MDNS_STATIC_CONST_CHAR(SERVICE_TCP, "_tcp"); // Wrap build-time defines into flash storage MDNS_STATIC_CONST_CHAR(VALUE_VERSION, ESPHOME_VERSION); -void MDNSComponent::compile_records_(StaticVector &services) { +void MDNSComponent::compile_records_(StaticVector &services, char *mac_address_buf) { // IMPORTANT: The #ifdef blocks below must match COMPONENTS_WITH_MDNS_SERVICES // in mdns/__init__.py. If you add a new service here, update both locations. @@ -86,7 +86,9 @@ void MDNSComponent::compile_records_(StaticVectoradd_dynamic_txt_value(get_mac_address()))}); + + // MAC address: passed from caller (either member buffer or stack buffer depending on USE_MDNS_STORE_SERVICES) + txt_records.push_back({MDNS_STR(TXT_MAC), MDNS_STR(mac_address_buf)}); #ifdef USE_ESP8266 MDNS_STATIC_CONST_CHAR(PLATFORM_ESP8266, "ESP8266"); diff --git a/esphome/components/mdns/mdns_component.h b/esphome/components/mdns/mdns_component.h index 691c45b7df..f696cfff1c 100644 --- a/esphome/components/mdns/mdns_component.h +++ b/esphome/components/mdns/mdns_component.h @@ -60,22 +60,58 @@ class MDNSComponent : public Component { void on_shutdown() override; +#ifdef USE_MDNS_DYNAMIC_TXT /// Add a dynamic TXT value and return pointer to it for use in MDNSTXTRecord const char *add_dynamic_txt_value(const std::string &value) { this->dynamic_txt_values_.push_back(value); return this->dynamic_txt_values_[this->dynamic_txt_values_.size() - 1].c_str(); } +#endif - /// Storage for runtime-generated TXT values (MAC address, user lambdas) + protected: + /// Helper to set up services and MAC buffers, then call platform-specific registration + using PlatformRegisterFn = void (*)(MDNSComponent *, StaticVector &); + + void setup_buffers_and_register_(PlatformRegisterFn platform_register) { +#ifdef USE_MDNS_STORE_SERVICES + auto &services = this->services_; +#else + StaticVector services_storage; + auto &services = services_storage; +#endif + +#ifdef USE_API +#ifdef USE_MDNS_STORE_SERVICES + get_mac_address_into_buffer(this->mac_address_); + char *mac_ptr = this->mac_address_; +#else + char mac_address[MAC_ADDRESS_BUFFER_SIZE]; + get_mac_address_into_buffer(mac_address); + char *mac_ptr = mac_address; +#endif +#else + char *mac_ptr = nullptr; +#endif + + this->compile_records_(services, mac_ptr); + platform_register(this, services); + } + +#ifdef USE_MDNS_DYNAMIC_TXT + /// Storage for runtime-generated TXT values from user lambdas /// Pre-sized at compile time via MDNS_DYNAMIC_TXT_COUNT to avoid heap allocations. /// Static/compile-time values (version, board, etc.) are stored directly in flash and don't use this. StaticVector dynamic_txt_values_; +#endif - protected: +#if defined(USE_API) && defined(USE_MDNS_STORE_SERVICES) + /// Fixed buffer for MAC address (only needed when services are stored) + char mac_address_[MAC_ADDRESS_BUFFER_SIZE]; +#endif #ifdef USE_MDNS_STORE_SERVICES StaticVector services_{}; #endif - void compile_records_(StaticVector &services); + void compile_records_(StaticVector &services, char *mac_address_buf); }; } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_esp32.cpp b/esphome/components/mdns/mdns_esp32.cpp index 5547a2524b..e6b43e59cb 100644 --- a/esphome/components/mdns/mdns_esp32.cpp +++ b/esphome/components/mdns/mdns_esp32.cpp @@ -11,19 +11,11 @@ namespace esphome::mdns { static const char *const TAG = "mdns"; -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_esp32(MDNSComponent *comp, StaticVector &services) { esp_err_t err = mdns_init(); if (err != ESP_OK) { ESP_LOGW(TAG, "Init failed: %s", esp_err_to_name(err)); - this->mark_failed(); + comp->mark_failed(); return; } @@ -50,6 +42,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp32); } + void MDNSComponent::on_shutdown() { mdns_free(); delay(40); // Allow the mdns packets announcing service removal to be sent diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 06f905884c..dcbe5ebd52 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -11,15 +11,7 @@ namespace esphome::mdns { -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_esp8266(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -44,6 +36,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_esp8266); } + void MDNSComponent::loop() { MDNS.update(); } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/mdns/mdns_host.cpp b/esphome/components/mdns/mdns_host.cpp index 64b8c8f54b..4d902319b8 100644 --- a/esphome/components/mdns/mdns_host.cpp +++ b/esphome/components/mdns/mdns_host.cpp @@ -9,6 +9,15 @@ namespace esphome::mdns { void MDNSComponent::setup() { +#ifdef USE_MDNS_STORE_SERVICES +#ifdef USE_API + get_mac_address_into_buffer(this->mac_address_); + char *mac_ptr = this->mac_address_; +#else + char *mac_ptr = nullptr; +#endif + this->compile_records_(this->services_, mac_ptr); +#endif // Host platform doesn't have actual mDNS implementation } diff --git a/esphome/components/mdns/mdns_libretiny.cpp b/esphome/components/mdns/mdns_libretiny.cpp index a049fe2109..986099fa1f 100644 --- a/esphome/components/mdns/mdns_libretiny.cpp +++ b/esphome/components/mdns/mdns_libretiny.cpp @@ -11,15 +11,7 @@ namespace esphome::mdns { -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_libretiny(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -43,6 +35,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_libretiny); } + void MDNSComponent::on_shutdown() {} } // namespace esphome::mdns diff --git a/esphome/components/mdns/mdns_rp2040.cpp b/esphome/components/mdns/mdns_rp2040.cpp index a102e0b6c3..e4a9b60cdb 100644 --- a/esphome/components/mdns/mdns_rp2040.cpp +++ b/esphome/components/mdns/mdns_rp2040.cpp @@ -11,15 +11,7 @@ namespace esphome::mdns { -void MDNSComponent::setup() { -#ifdef USE_MDNS_STORE_SERVICES - this->compile_records_(this->services_); - const auto &services = this->services_; -#else - StaticVector services; - this->compile_records_(services); -#endif - +static void register_rp2040(MDNSComponent *, StaticVector &services) { MDNS.begin(App.get_name().c_str()); for (const auto &service : services) { @@ -43,6 +35,8 @@ void MDNSComponent::setup() { } } +void MDNSComponent::setup() { this->setup_buffers_and_register_(register_rp2040); } + void MDNSComponent::loop() { MDNS.update(); } void MDNSComponent::on_shutdown() { diff --git a/esphome/components/micronova/__init__.py b/esphome/components/micronova/__init__.py index 31abc11abf..d6ef93cf30 100644 --- a/esphome/components/micronova/__init__.py +++ b/esphome/components/micronova/__init__.py @@ -4,59 +4,73 @@ from esphome.components import uart import esphome.config_validation as cv from esphome.const import CONF_ID -CODEOWNERS = ["@jorre05"] +CODEOWNERS = ["@jorre05", "@edenhaus"] DEPENDENCIES = ["uart"] -CONF_MICRONOVA_ID = "micronova_id" +DOMAIN = "micronova" +CONF_MICRONOVA_ID = f"{DOMAIN}_id" CONF_ENABLE_RX_PIN = "enable_rx_pin" CONF_MEMORY_LOCATION = "memory_location" CONF_MEMORY_ADDRESS = "memory_address" +DEFAULT_POLLING_INTERVAL = "60s" -micronova_ns = cg.esphome_ns.namespace("micronova") +micronova_ns = cg.esphome_ns.namespace(DOMAIN) -MicroNovaFunctions = micronova_ns.enum("MicroNovaFunctions", is_class=True) -MICRONOVA_FUNCTIONS_ENUM = { - "STOVE_FUNCTION_SWITCH": MicroNovaFunctions.STOVE_FUNCTION_SWITCH, - "STOVE_FUNCTION_ROOM_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_ROOM_TEMPERATURE, - "STOVE_FUNCTION_THERMOSTAT_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE, - "STOVE_FUNCTION_FUMES_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_FUMES_TEMPERATURE, - "STOVE_FUNCTION_STOVE_POWER": MicroNovaFunctions.STOVE_FUNCTION_STOVE_POWER, - "STOVE_FUNCTION_FAN_SPEED": MicroNovaFunctions.STOVE_FUNCTION_FAN_SPEED, - "STOVE_FUNCTION_STOVE_STATE": MicroNovaFunctions.STOVE_FUNCTION_STOVE_STATE, - "STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR": MicroNovaFunctions.STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR, - "STOVE_FUNCTION_WATER_TEMPERATURE": MicroNovaFunctions.STOVE_FUNCTION_WATER_TEMPERATURE, - "STOVE_FUNCTION_WATER_PRESSURE": MicroNovaFunctions.STOVE_FUNCTION_WATER_PRESSURE, - "STOVE_FUNCTION_POWER_LEVEL": MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL, - "STOVE_FUNCTION_CUSTOM": MicroNovaFunctions.STOVE_FUNCTION_CUSTOM, -} +MicroNova = micronova_ns.class_("MicroNova", cg.Component, uart.UARTDevice) +MicroNovaListener = micronova_ns.class_("MicroNovaListener", cg.PollingComponent) -MicroNova = micronova_ns.class_("MicroNova", cg.PollingComponent, uart.UARTDevice) +CONFIG_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(MicroNova), + cv.Required(CONF_ENABLE_RX_PIN): pins.gpio_output_pin_schema, + } +).extend(uart.UART_DEVICE_SCHEMA) -CONFIG_SCHEMA = ( - cv.Schema( - { - cv.GenerateID(): cv.declare_id(MicroNova), - cv.Required(CONF_ENABLE_RX_PIN): pins.gpio_output_pin_schema, - } - ) - .extend(uart.UART_DEVICE_SCHEMA) - .extend(cv.polling_component_schema("60s")) +FINAL_VALIDATE_SCHEMA = uart.final_validate_device_schema( + DOMAIN, + baud_rate=1200, + require_rx=True, + require_tx=True, + data_bits=8, + parity="NONE", + stop_bits=2, ) -def MICRONOVA_LISTENER_SCHEMA(default_memory_location, default_memory_address): - return cv.Schema( +def MICRONOVA_ADDRESS_SCHEMA( + *, + default_memory_location: int | None = None, + default_memory_address: int | None = None, + is_polling_component: bool, +): + location_key = ( + cv.Optional(CONF_MEMORY_LOCATION, default=default_memory_location) + if default_memory_location is not None + else cv.Required(CONF_MEMORY_LOCATION) + ) + address_key = ( + cv.Optional(CONF_MEMORY_ADDRESS, default=default_memory_address) + if default_memory_address is not None + else cv.Required(CONF_MEMORY_ADDRESS) + ) + schema = cv.Schema( { cv.GenerateID(CONF_MICRONOVA_ID): cv.use_id(MicroNova), - cv.Optional( - CONF_MEMORY_LOCATION, default=default_memory_location - ): cv.hex_int_range(), - cv.Optional( - CONF_MEMORY_ADDRESS, default=default_memory_address - ): cv.hex_int_range(), + location_key: cv.hex_int_range(min=0x00, max=0x79), + address_key: cv.hex_int_range(min=0x00, max=0xFF), } ) + if is_polling_component: + schema = schema.extend(cv.polling_component_schema(DEFAULT_POLLING_INTERVAL)) + return schema + + +async def to_code_micronova_listener(mv, var, config): + await cg.register_component(var, config) + cg.add(mv.register_micronova_listener(var)) + cg.add(var.set_memory_location(config[CONF_MEMORY_LOCATION])) + cg.add(var.set_memory_address(config[CONF_MEMORY_ADDRESS])) async def to_code(config): diff --git a/esphome/components/micronova/button/__init__.py b/esphome/components/micronova/button/__init__.py index 813d24efef..6adf8d96fe 100644 --- a/esphome/components/micronova/button/__init__.py +++ b/esphome/components/micronova/button/__init__.py @@ -6,9 +6,8 @@ from .. import ( CONF_MEMORY_ADDRESS, CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, - MICRONOVA_LISTENER_SCHEMA, + MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, micronova_ns, ) @@ -24,8 +23,8 @@ CONFIG_SCHEMA = cv.Schema( MicroNovaButton, ) .extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0xA0, default_memory_address=0x7D + MICRONOVA_ADDRESS_SCHEMA( + is_polling_component=False, ) ) .extend({cv.Required(CONF_MEMORY_DATA): cv.hex_int_range()}), @@ -38,7 +37,6 @@ async def to_code(config): if custom_button_config := config.get(CONF_CUSTOM_BUTTON): bt = await button.new_button(custom_button_config, mv) - cg.add(bt.set_memory_location(custom_button_config.get(CONF_MEMORY_LOCATION))) - cg.add(bt.set_memory_address(custom_button_config.get(CONF_MEMORY_ADDRESS))) + cg.add(bt.set_memory_location(custom_button_config[CONF_MEMORY_LOCATION])) + cg.add(bt.set_memory_address(custom_button_config[CONF_MEMORY_ADDRESS])) cg.add(bt.set_memory_data(custom_button_config[CONF_MEMORY_DATA])) - cg.add(bt.set_function(MicroNovaFunctions.STOVE_FUNCTION_CUSTOM)) diff --git a/esphome/components/micronova/button/micronova_button.cpp b/esphome/components/micronova/button/micronova_button.cpp index 147fef37bd..3f49d4b5b3 100644 --- a/esphome/components/micronova/button/micronova_button.cpp +++ b/esphome/components/micronova/button/micronova_button.cpp @@ -3,14 +3,8 @@ namespace esphome::micronova { void MicroNovaButton::press_action() { - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_CUSTOM: - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_); - break; - default: - break; - } - this->micronova_->update(); + this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_); + this->micronova_->request_update_listeners(); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/button/micronova_button.h b/esphome/components/micronova/button/micronova_button.h index 5c1d7d8455..951ae8bba3 100644 --- a/esphome/components/micronova/button/micronova_button.h +++ b/esphome/components/micronova/button/micronova_button.h @@ -9,7 +9,10 @@ namespace esphome::micronova { class MicroNovaButton : public Component, public button::Button, public MicroNovaButtonListener { public: MicroNovaButton(MicroNova *m) : MicroNovaButtonListener(m) {} - void dump_config() override { LOG_BUTTON("", "Micronova button", this); } + void dump_config() override { + LOG_BUTTON("", "Micronova button", this); + this->dump_base_config(); + } void set_memory_data(uint8_t f) { this->memory_data_ = f; } uint8_t get_memory_data() { return this->memory_data_; } diff --git a/esphome/components/micronova/micronova.cpp b/esphome/components/micronova/micronova.cpp index 52b719bff2..22daef4fe6 100644 --- a/esphome/components/micronova/micronova.cpp +++ b/esphome/components/micronova/micronova.cpp @@ -3,6 +3,21 @@ namespace esphome::micronova { +static const int STOVE_REPLY_DELAY = 60; +static const uint8_t WRITE_BIT = 1 << 7; // 0x80 + +void MicroNovaBaseListener::dump_base_config() { + ESP_LOGCONFIG(TAG, + " Memory Location: %02X\n" + " Memory Address: %02X", + this->memory_location_, this->memory_address_); +} + +void MicroNovaListener::dump_base_config() { + MicroNovaBaseListener::dump_base_config(); + LOG_UPDATE_INTERVAL(this); +} + void MicroNova::setup() { if (this->enable_rx_pin_ != nullptr) { this->enable_rx_pin_->setup(); @@ -21,16 +36,10 @@ void MicroNova::dump_config() { if (this->enable_rx_pin_ != nullptr) { LOG_PIN(" Enable RX Pin: ", this->enable_rx_pin_); } - - for (auto &mv_sensor : this->micronova_listeners_) { - mv_sensor->dump_config(); - ESP_LOGCONFIG(TAG, " sensor location:%02X, address:%02X", mv_sensor->get_memory_location(), - mv_sensor->get_memory_address()); - } } -void MicroNova::update() { - ESP_LOGD(TAG, "Schedule sensor update"); +void MicroNova::request_update_listeners() { + ESP_LOGD(TAG, "Schedule listener update"); for (auto &mv_listener : this->micronova_listeners_) { mv_listener->set_needs_update(true); } @@ -61,7 +70,7 @@ void MicroNova::loop() { } } -void MicroNova::request_address(uint8_t location, uint8_t address, MicroNovaSensorListener *listener) { +void MicroNova::request_address(uint8_t location, uint8_t address, MicroNovaListener *listener) { uint8_t write_data[2] = {0, 0}; uint8_t trash_rx; @@ -119,7 +128,8 @@ void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) { uint16_t checksum = 0; if (this->reply_pending_mutex_.try_lock()) { - write_data[0] = location; + uint8_t write_location = location | WRITE_BIT; + write_data[0] = write_location; write_data[1] = address; write_data[2] = data; @@ -134,7 +144,7 @@ void MicroNova::write_address(uint8_t location, uint8_t address, uint8_t data) { this->enable_rx_pin_->digital_write(false); this->current_transmission_.request_transmission_time = millis(); - this->current_transmission_.memory_location = location; + this->current_transmission_.memory_location = write_location; this->current_transmission_.memory_address = address; this->current_transmission_.reply_pending = true; this->current_transmission_.initiating_listener = nullptr; diff --git a/esphome/components/micronova/micronova.h b/esphome/components/micronova/micronova.h index acb85fad3c..a70f355ead 100644 --- a/esphome/components/micronova/micronova.h +++ b/esphome/components/micronova/micronova.h @@ -11,35 +11,6 @@ namespace esphome::micronova { static const char *const TAG = "micronova"; -static const int STOVE_REPLY_DELAY = 60; - -static const std::string STOVE_STATES[11] = {"Off", - "Start", - "Pellets loading", - "Ignition", - "Working", - "Brazier Cleaning", - "Final Cleaning", - "Standby", - "No pellets alarm", - "No ignition alarm", - "Undefined alarm"}; - -enum class MicroNovaFunctions { - STOVE_FUNCTION_VOID = 0, - STOVE_FUNCTION_SWITCH = 1, - STOVE_FUNCTION_ROOM_TEMPERATURE = 2, - STOVE_FUNCTION_THERMOSTAT_TEMPERATURE = 3, - STOVE_FUNCTION_FUMES_TEMPERATURE = 4, - STOVE_FUNCTION_STOVE_POWER = 5, - STOVE_FUNCTION_FAN_SPEED = 6, - STOVE_FUNCTION_STOVE_STATE = 7, - STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR = 8, - STOVE_FUNCTION_WATER_TEMPERATURE = 9, - STOVE_FUNCTION_WATER_PRESSURE = 10, - STOVE_FUNCTION_POWER_LEVEL = 11, - STOVE_FUNCTION_CUSTOM = 12 -}; class MicroNova; @@ -49,64 +20,41 @@ class MicroNovaBaseListener { public: MicroNovaBaseListener() {} MicroNovaBaseListener(MicroNova *m) { this->micronova_ = m; } - virtual void dump_config(); void set_micronova_object(MicroNova *m) { this->micronova_ = m; } - void set_function(MicroNovaFunctions f) { this->function_ = f; } - MicroNovaFunctions get_function() { return this->function_; } - void set_memory_location(uint8_t l) { this->memory_location_ = l; } uint8_t get_memory_location() { return this->memory_location_; } void set_memory_address(uint8_t a) { this->memory_address_ = a; } uint8_t get_memory_address() { return this->memory_address_; } + void dump_base_config(); + protected: MicroNova *micronova_{nullptr}; - MicroNovaFunctions function_ = MicroNovaFunctions::STOVE_FUNCTION_VOID; uint8_t memory_location_ = 0; uint8_t memory_address_ = 0; }; -class MicroNovaSensorListener : public MicroNovaBaseListener { +class MicroNovaListener : public MicroNovaBaseListener, public PollingComponent { public: - MicroNovaSensorListener() {} - MicroNovaSensorListener(MicroNova *m) : MicroNovaBaseListener(m) {} + MicroNovaListener() {} + MicroNovaListener(MicroNova *m) : MicroNovaBaseListener(m) {} virtual void request_value_from_stove() = 0; virtual void process_value_from_stove(int value_from_stove) = 0; void set_needs_update(bool u) { this->needs_update_ = u; } bool get_needs_update() { return this->needs_update_; } - protected: - bool needs_update_ = false; -}; + void update() override { this->set_needs_update(true); } -class MicroNovaNumberListener : public MicroNovaBaseListener { - public: - MicroNovaNumberListener(MicroNova *m) : MicroNovaBaseListener(m) {} - virtual void request_value_from_stove() = 0; - virtual void process_value_from_stove(int value_from_stove) = 0; - - void set_needs_update(bool u) { this->needs_update_ = u; } - bool get_needs_update() { return this->needs_update_; } + void dump_base_config(); protected: bool needs_update_ = false; }; -class MicroNovaSwitchListener : public MicroNovaBaseListener { - public: - MicroNovaSwitchListener(MicroNova *m) : MicroNovaBaseListener(m) {} - virtual void set_stove_state(bool v) = 0; - virtual bool get_stove_state() = 0; - - protected: - uint8_t memory_data_on_ = 0; - uint8_t memory_data_off_ = 0; -}; - class MicroNovaButtonListener : public MicroNovaBaseListener { public: MicroNovaButtonListener(MicroNova *m) : MicroNovaBaseListener(m) {} @@ -117,31 +65,23 @@ class MicroNovaButtonListener : public MicroNovaBaseListener { ///////////////////////////////////////////////////////////////////// // Main component class -class MicroNova : public PollingComponent, public uart::UARTDevice { +class MicroNova : public Component, public uart::UARTDevice { public: MicroNova() {} void setup() override; void loop() override; - void update() override; void dump_config() override; - void register_micronova_listener(MicroNovaSensorListener *l) { this->micronova_listeners_.push_back(l); } + void register_micronova_listener(MicroNovaListener *l) { this->micronova_listeners_.push_back(l); } + void request_update_listeners(); - void request_address(uint8_t location, uint8_t address, MicroNovaSensorListener *listener); + void request_address(uint8_t location, uint8_t address, MicroNovaListener *listener); void write_address(uint8_t location, uint8_t address, uint8_t data); int read_stove_reply(); void set_enable_rx_pin(GPIOPin *enable_rx_pin) { this->enable_rx_pin_ = enable_rx_pin; } - void set_current_stove_state(uint8_t s) { this->current_stove_state_ = s; } - uint8_t get_current_stove_state() { return this->current_stove_state_; } - - void set_stove(MicroNovaSwitchListener *s) { this->stove_switch_ = s; } - MicroNovaSwitchListener *get_stove_switch() { return this->stove_switch_; } - protected: - uint8_t current_stove_state_ = 0; - GPIOPin *enable_rx_pin_{nullptr}; struct MicroNovaSerialTransmission { @@ -149,14 +89,13 @@ class MicroNova : public PollingComponent, public uart::UARTDevice { uint8_t memory_location; uint8_t memory_address; bool reply_pending; - MicroNovaSensorListener *initiating_listener; + MicroNovaListener *initiating_listener; }; Mutex reply_pending_mutex_; MicroNovaSerialTransmission current_transmission_; - std::vector micronova_listeners_{}; - MicroNovaSwitchListener *stove_switch_{nullptr}; + std::vector micronova_listeners_{}; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/number/__init__.py b/esphome/components/micronova/number/__init__.py index b0eeaf8dd1..ef6cc0f7d7 100644 --- a/esphome/components/micronova/number/__init__.py +++ b/esphome/components/micronova/number/__init__.py @@ -4,22 +4,22 @@ import esphome.config_validation as cv from esphome.const import CONF_STEP, DEVICE_CLASS_TEMPERATURE, UNIT_CELSIUS from .. import ( - CONF_MEMORY_ADDRESS, - CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, - MICRONOVA_LISTENER_SCHEMA, + MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, + MicroNovaListener, micronova_ns, + to_code_micronova_listener, ) ICON_FLASH = "mdi:flash" CONF_THERMOSTAT_TEMPERATURE = "thermostat_temperature" CONF_POWER_LEVEL = "power_level" -CONF_MEMORY_WRITE_LOCATION = "memory_write_location" -MicroNovaNumber = micronova_ns.class_("MicroNovaNumber", number.Number, cg.Component) +MicroNovaNumber = micronova_ns.class_( + "MicroNovaNumber", number.Number, MicroNovaListener +) CONFIG_SCHEMA = cv.Schema( { @@ -30,29 +30,26 @@ CONFIG_SCHEMA = cv.Schema( device_class=DEVICE_CLASS_TEMPERATURE, ) .extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x20, default_memory_address=0x7D + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x20, + default_memory_address=0x7D, + is_polling_component=True, ) ) .extend( { - cv.Optional( - CONF_MEMORY_WRITE_LOCATION, default=0xA0 - ): cv.hex_int_range(), cv.Optional(CONF_STEP, default=1.0): cv.float_range(min=0.1, max=10.0), } ), cv.Optional(CONF_POWER_LEVEL): number.number_schema( MicroNovaNumber, icon=ICON_FLASH, - ) - .extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x20, default_memory_address=0x7F + ).extend( + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x20, + default_memory_address=0x7F, + is_polling_component=True, ) - ) - .extend( - {cv.Optional(CONF_MEMORY_WRITE_LOCATION, default=0xA0): cv.hex_int_range()} ), } ) @@ -68,24 +65,9 @@ async def to_code(config): max_value=40, step=thermostat_temperature_config.get(CONF_STEP), ) + await to_code_micronova_listener(mv, numb, thermostat_temperature_config) cg.add(numb.set_micronova_object(mv)) - cg.add(mv.register_micronova_listener(numb)) - cg.add( - numb.set_memory_location( - thermostat_temperature_config[CONF_MEMORY_LOCATION] - ) - ) - cg.add( - numb.set_memory_address(thermostat_temperature_config[CONF_MEMORY_ADDRESS]) - ) - cg.add( - numb.set_memory_write_location( - thermostat_temperature_config.get(CONF_MEMORY_WRITE_LOCATION) - ) - ) - cg.add( - numb.set_function(MicroNovaFunctions.STOVE_FUNCTION_THERMOSTAT_TEMPERATURE) - ) + cg.add(numb.set_use_step_scaling(True)) if power_level_config := config.get(CONF_POWER_LEVEL): numb = await number.new_number( @@ -94,13 +76,5 @@ async def to_code(config): max_value=5, step=1, ) + await to_code_micronova_listener(mv, numb, power_level_config) cg.add(numb.set_micronova_object(mv)) - cg.add(mv.register_micronova_listener(numb)) - cg.add(numb.set_memory_location(power_level_config[CONF_MEMORY_LOCATION])) - cg.add(numb.set_memory_address(power_level_config[CONF_MEMORY_ADDRESS])) - cg.add( - numb.set_memory_write_location( - power_level_config.get(CONF_MEMORY_WRITE_LOCATION) - ) - ) - cg.add(numb.set_function(MicroNovaFunctions.STOVE_FUNCTION_POWER_LEVEL)) diff --git a/esphome/components/micronova/number/micronova_number.cpp b/esphome/components/micronova/number/micronova_number.cpp index b311c85b99..8027947468 100644 --- a/esphome/components/micronova/number/micronova_number.cpp +++ b/esphome/components/micronova/number/micronova_number.cpp @@ -3,41 +3,27 @@ namespace esphome::micronova { void MicroNovaNumber::process_value_from_stove(int value_from_stove) { - float new_sensor_value = 0; - if (value_from_stove == -1) { this->publish_state(NAN); return; } - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE: - new_sensor_value = ((float) value_from_stove) * this->traits.get_step(); - break; - case MicroNovaFunctions::STOVE_FUNCTION_POWER_LEVEL: - new_sensor_value = (float) value_from_stove; - break; - default: - break; + float new_value = static_cast(value_from_stove); + if (this->use_step_scaling_) { + new_value *= this->traits.get_step(); } - this->publish_state(new_sensor_value); + this->publish_state(new_value); } void MicroNovaNumber::control(float value) { - uint8_t new_number = 0; - - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE: - new_number = (uint8_t) (value / this->traits.get_step()); - break; - case MicroNovaFunctions::STOVE_FUNCTION_POWER_LEVEL: - new_number = (uint8_t) value; - break; - default: - break; + uint8_t new_number; + if (this->use_step_scaling_) { + new_number = static_cast(value / this->traits.get_step()); + } else { + new_number = static_cast(value); } - this->micronova_->write_address(this->memory_write_location_, this->memory_address_, new_number); - this->micronova_->update(); + this->micronova_->write_address(this->memory_location_, this->memory_address_, new_number); + this->micronova_->request_update_listeners(); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/number/micronova_number.h b/esphome/components/micronova/number/micronova_number.h index 79e59dbc28..3fc5838a4f 100644 --- a/esphome/components/micronova/number/micronova_number.h +++ b/esphome/components/micronova/number/micronova_number.h @@ -5,22 +5,24 @@ namespace esphome::micronova { -class MicroNovaNumber : public number::Number, public MicroNovaSensorListener { +class MicroNovaNumber : public number::Number, public MicroNovaListener { public: MicroNovaNumber() {} - MicroNovaNumber(MicroNova *m) : MicroNovaSensorListener(m) {} - void dump_config() override { LOG_NUMBER("", "Micronova number", this); } + MicroNovaNumber(MicroNova *m) : MicroNovaListener(m) {} + void dump_config() override { + LOG_NUMBER("", "Micronova number", this); + this->dump_base_config(); + } void control(float value) override; void request_value_from_stove() override { this->micronova_->request_address(this->memory_location_, this->memory_address_, this); } void process_value_from_stove(int value_from_stove) override; - void set_memory_write_location(uint8_t l) { this->memory_write_location_ = l; } - uint8_t get_memory_write_location() { return this->memory_write_location_; } + void set_use_step_scaling(bool v) { this->use_step_scaling_ = v; } protected: - uint8_t memory_write_location_ = 0; + bool use_step_scaling_ = false; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/sensor/__init__.py b/esphome/components/micronova/sensor/__init__.py index ceb4a9ef77..e53c49aca5 100644 --- a/esphome/components/micronova/sensor/__init__.py +++ b/esphome/components/micronova/sensor/__init__.py @@ -10,18 +10,19 @@ from esphome.const import ( ) from .. import ( - CONF_MEMORY_ADDRESS, - CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, - MICRONOVA_LISTENER_SCHEMA, + MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, + MicroNovaListener, micronova_ns, + to_code_micronova_listener, ) UNIT_BAR = "bar" -MicroNovaSensor = micronova_ns.class_("MicroNovaSensor", sensor.Sensor, cg.Component) +MicroNovaSensor = micronova_ns.class_( + "MicroNovaSensor", sensor.Sensor, MicroNovaListener +) CONF_ROOM_TEMPERATURE = "room_temperature" CONF_FUMES_TEMPERATURE = "fumes_temperature" @@ -42,8 +43,10 @@ CONFIG_SCHEMA = cv.Schema( state_class=STATE_CLASS_MEASUREMENT, accuracy_decimals=1, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x01 + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x01, + is_polling_component=True, ) ), cv.Optional(CONF_FUMES_TEMPERATURE): sensor.sensor_schema( @@ -53,8 +56,10 @@ CONFIG_SCHEMA = cv.Schema( state_class=STATE_CLASS_MEASUREMENT, accuracy_decimals=1, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x5A + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x5A, + is_polling_component=True, ) ), cv.Optional(CONF_STOVE_POWER): sensor.sensor_schema( @@ -62,8 +67,10 @@ CONFIG_SCHEMA = cv.Schema( state_class=STATE_CLASS_MEASUREMENT, accuracy_decimals=0, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x34 + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x34, + is_polling_component=True, ) ), cv.Optional(CONF_FAN_SPEED): sensor.sensor_schema( @@ -72,8 +79,10 @@ CONFIG_SCHEMA = cv.Schema( unit_of_measurement=UNIT_REVOLUTIONS_PER_MINUTE, ) .extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x37 + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x37, + is_polling_component=True, ) ) .extend( @@ -86,8 +95,10 @@ CONFIG_SCHEMA = cv.Schema( state_class=STATE_CLASS_MEASUREMENT, accuracy_decimals=1, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x3B + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x3B, + is_polling_component=True, ) ), cv.Optional(CONF_WATER_PRESSURE): sensor.sensor_schema( @@ -97,15 +108,17 @@ CONFIG_SCHEMA = cv.Schema( state_class=STATE_CLASS_MEASUREMENT, accuracy_decimals=1, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x3C + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x3C, + is_polling_component=True, ) ), cv.Optional(CONF_MEMORY_ADDRESS_SENSOR): sensor.sensor_schema( MicroNovaSensor, ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x00 + MICRONOVA_ADDRESS_SCHEMA( + is_polling_component=True, ) ), } @@ -115,58 +128,21 @@ CONFIG_SCHEMA = cv.Schema( async def to_code(config): mv = await cg.get_variable(config[CONF_MICRONOVA_ID]) - if room_temperature_config := config.get(CONF_ROOM_TEMPERATURE): - sens = await sensor.new_sensor(room_temperature_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(room_temperature_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(room_temperature_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_ROOM_TEMPERATURE)) - - if fumes_temperature_config := config.get(CONF_FUMES_TEMPERATURE): - sens = await sensor.new_sensor(fumes_temperature_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(fumes_temperature_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(fumes_temperature_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_FUMES_TEMPERATURE)) - - if stove_power_config := config.get(CONF_STOVE_POWER): - sens = await sensor.new_sensor(stove_power_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(stove_power_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(stove_power_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_STOVE_POWER)) + for key, divisor in { + CONF_ROOM_TEMPERATURE: 2, + CONF_FUMES_TEMPERATURE: None, + CONF_STOVE_POWER: None, + CONF_MEMORY_ADDRESS_SENSOR: None, + CONF_WATER_TEMPERATURE: 2, + CONF_WATER_PRESSURE: 10, + }.items(): + if sensor_config := config.get(key): + sens = await sensor.new_sensor(sensor_config, mv) + await to_code_micronova_listener(mv, sens, sensor_config) + if divisor: + cg.add(sens.set_divisor(divisor)) if fan_speed_config := config.get(CONF_FAN_SPEED): sens = await sensor.new_sensor(fan_speed_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(fan_speed_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(fan_speed_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_FAN_SPEED)) + await to_code_micronova_listener(mv, sens, fan_speed_config) cg.add(sens.set_fan_speed_offset(fan_speed_config[CONF_FAN_RPM_OFFSET])) - - if memory_address_sensor_config := config.get(CONF_MEMORY_ADDRESS_SENSOR): - sens = await sensor.new_sensor(memory_address_sensor_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add( - sens.set_memory_location(memory_address_sensor_config[CONF_MEMORY_LOCATION]) - ) - cg.add( - sens.set_memory_address(memory_address_sensor_config[CONF_MEMORY_ADDRESS]) - ) - cg.add( - sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_MEMORY_ADDRESS_SENSOR) - ) - - if water_temperature_config := config.get(CONF_WATER_TEMPERATURE): - sens = await sensor.new_sensor(water_temperature_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(water_temperature_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(water_temperature_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_WATER_TEMPERATURE)) - - if water_pressure_config := config.get(CONF_WATER_PRESSURE): - sens = await sensor.new_sensor(water_pressure_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(water_pressure_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(water_pressure_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_WATER_PRESSURE)) diff --git a/esphome/components/micronova/sensor/micronova_sensor.cpp b/esphome/components/micronova/sensor/micronova_sensor.cpp index 9fd8832f29..d845e0ab3c 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.cpp +++ b/esphome/components/micronova/sensor/micronova_sensor.cpp @@ -8,25 +8,15 @@ void MicroNovaSensor::process_value_from_stove(int value_from_stove) { return; } - float new_sensor_value = (float) value_from_stove; - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_ROOM_TEMPERATURE: - new_sensor_value = new_sensor_value / 2; - break; - case MicroNovaFunctions::STOVE_FUNCTION_THERMOSTAT_TEMPERATURE: - break; - case MicroNovaFunctions::STOVE_FUNCTION_FAN_SPEED: - new_sensor_value = new_sensor_value == 0 ? 0 : (new_sensor_value * 10) + this->fan_speed_offset_; - break; - case MicroNovaFunctions::STOVE_FUNCTION_WATER_TEMPERATURE: - new_sensor_value = new_sensor_value / 2; - break; - case MicroNovaFunctions::STOVE_FUNCTION_WATER_PRESSURE: - new_sensor_value = new_sensor_value / 10; - break; - default: - break; + float new_sensor_value = static_cast(value_from_stove); + + // Fan speed has special calculation: value * 10 + offset (when non-zero) + if (this->is_fan_speed_) { + new_sensor_value = value_from_stove == 0 ? 0.0f : (new_sensor_value * 10) + this->fan_speed_offset_; + } else if (this->divisor_ > 1) { + new_sensor_value = new_sensor_value / this->divisor_; } + this->publish_state(new_sensor_value); } diff --git a/esphome/components/micronova/sensor/micronova_sensor.h b/esphome/components/micronova/sensor/micronova_sensor.h index 081e68b09d..a2f232c7dc 100644 --- a/esphome/components/micronova/sensor/micronova_sensor.h +++ b/esphome/components/micronova/sensor/micronova_sensor.h @@ -5,21 +5,29 @@ namespace esphome::micronova { -class MicroNovaSensor : public sensor::Sensor, public MicroNovaSensorListener { +class MicroNovaSensor : public sensor::Sensor, public MicroNovaListener { public: - MicroNovaSensor(MicroNova *m) : MicroNovaSensorListener(m) {} - void dump_config() override { LOG_SENSOR("", "Micronova sensor", this); } + MicroNovaSensor(MicroNova *m) : MicroNovaListener(m) {} + void dump_config() override { + LOG_SENSOR("", "Micronova sensor", this); + this->dump_base_config(); + } void request_value_from_stove() override { this->micronova_->request_address(this->memory_location_, this->memory_address_, this); } void process_value_from_stove(int value_from_stove) override; - void set_fan_speed_offset(uint8_t f) { this->fan_speed_offset_ = f; } - uint8_t get_set_fan_speed_offset() { return this->fan_speed_offset_; } + void set_divisor(uint8_t d) { this->divisor_ = d; } + void set_fan_speed_offset(uint8_t offset) { + this->is_fan_speed_ = true; + this->fan_speed_offset_ = offset; + } protected: - int fan_speed_offset_ = 0; + uint8_t divisor_ = 1; + uint8_t fan_speed_offset_ = 0; + bool is_fan_speed_ = false; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/__init__.py b/esphome/components/micronova/switch/__init__.py index 43e5c9d844..c937a4cac9 100644 --- a/esphome/components/micronova/switch/__init__.py +++ b/esphome/components/micronova/switch/__init__.py @@ -4,20 +4,21 @@ import esphome.config_validation as cv from esphome.const import ICON_POWER from .. import ( - CONF_MEMORY_ADDRESS, - CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, - MICRONOVA_LISTENER_SCHEMA, + MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, + MicroNovaListener, micronova_ns, + to_code_micronova_listener, ) CONF_STOVE = "stove" CONF_MEMORY_DATA_ON = "memory_data_on" CONF_MEMORY_DATA_OFF = "memory_data_off" -MicroNovaSwitch = micronova_ns.class_("MicroNovaSwitch", switch.Switch, cg.Component) +MicroNovaSwitch = micronova_ns.class_( + "MicroNovaSwitch", switch.Switch, MicroNovaListener +) CONFIG_SCHEMA = cv.Schema( { @@ -27,8 +28,10 @@ CONFIG_SCHEMA = cv.Schema( icon=ICON_POWER, ) .extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x80, default_memory_address=0x21 + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x21, + is_polling_component=True, ) ) .extend( @@ -46,9 +49,6 @@ async def to_code(config): if stove_config := config.get(CONF_STOVE): sw = await switch.new_switch(stove_config, mv) - cg.add(mv.set_stove(sw)) - cg.add(sw.set_memory_location(stove_config[CONF_MEMORY_LOCATION])) - cg.add(sw.set_memory_address(stove_config[CONF_MEMORY_ADDRESS])) + await to_code_micronova_listener(mv, sw, stove_config) cg.add(sw.set_memory_data_on(stove_config[CONF_MEMORY_DATA_ON])) cg.add(sw.set_memory_data_off(stove_config[CONF_MEMORY_DATA_OFF])) - cg.add(sw.set_function(MicroNovaFunctions.STOVE_FUNCTION_SWITCH)) diff --git a/esphome/components/micronova/switch/micronova_switch.cpp b/esphome/components/micronova/switch/micronova_switch.cpp index 81d36adccb..9b9ad61018 100644 --- a/esphome/components/micronova/switch/micronova_switch.cpp +++ b/esphome/components/micronova/switch/micronova_switch.cpp @@ -3,31 +3,36 @@ namespace esphome::micronova { void MicroNovaSwitch::write_state(bool state) { - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_SWITCH: - if (state) { - // Only send power-on when current state is Off - if (this->micronova_->get_current_stove_state() == 0) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_); - this->publish_state(true); - } else { - ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", micronova_->get_current_stove_state()); - } - } else { - // don't send power-off when status is Off or Final cleaning - if (this->micronova_->get_current_stove_state() != 0 && micronova_->get_current_stove_state() != 6) { - this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_); - this->publish_state(false); - } else { - ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", micronova_->get_current_stove_state()); - } - } - this->micronova_->update(); - break; - - default: - break; + if (state) { + // Only send power-on when current state is Off + if (this->raw_state_ == 0) { + this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_on_); + this->publish_state(true); + } else { + ESP_LOGW(TAG, "Unable to turn stove on, invalid state: %d", this->raw_state_); + } + } else { + // don't send power-off when status is Off or Final cleaning + if (this->raw_state_ != 0 && this->raw_state_ != 6) { + this->micronova_->write_address(this->memory_location_, this->memory_address_, this->memory_data_off_); + this->publish_state(false); + } else { + ESP_LOGW(TAG, "Unable to turn stove off, invalid state: %d", this->raw_state_); + } } + this->set_needs_update(true); +} + +void MicroNovaSwitch::process_value_from_stove(int value_from_stove) { + this->raw_state_ = value_from_stove; + if (value_from_stove == -1) { + ESP_LOGE(TAG, "Error reading stove state"); + return; + } + + // set the stove switch to on for any value but 0 + bool state = value_from_stove != 0; + this->publish_state(state); } } // namespace esphome::micronova diff --git a/esphome/components/micronova/switch/micronova_switch.h b/esphome/components/micronova/switch/micronova_switch.h index 7019084355..96c2c14e9e 100644 --- a/esphome/components/micronova/switch/micronova_switch.h +++ b/esphome/components/micronova/switch/micronova_switch.h @@ -6,22 +6,28 @@ namespace esphome::micronova { -class MicroNovaSwitch : public Component, public switch_::Switch, public MicroNovaSwitchListener { +class MicroNovaSwitch : public switch_::Switch, public MicroNovaListener { public: - MicroNovaSwitch(MicroNova *m) : MicroNovaSwitchListener(m) {} - void dump_config() override { LOG_SWITCH("", "Micronova switch", this); } - - void set_stove_state(bool v) override { this->publish_state(v); } - bool get_stove_state() override { return this->state; } + MicroNovaSwitch(MicroNova *m) : MicroNovaListener(m) {} + void dump_config() override { + LOG_SWITCH("", "Micronova switch", this); + this->dump_base_config(); + } + void request_value_from_stove() override { + this->micronova_->request_address(this->memory_location_, this->memory_address_, this); + } + void process_value_from_stove(int value_from_stove) override; void set_memory_data_on(uint8_t f) { this->memory_data_on_ = f; } - uint8_t get_memory_data_on() { return this->memory_data_on_; } void set_memory_data_off(uint8_t f) { this->memory_data_off_ = f; } - uint8_t get_memory_data_off() { return this->memory_data_off_; } protected: void write_state(bool state) override; + + uint8_t memory_data_on_ = 0; + uint8_t memory_data_off_ = 0; + uint8_t raw_state_ = 0; }; } // namespace esphome::micronova diff --git a/esphome/components/micronova/text_sensor/__init__.py b/esphome/components/micronova/text_sensor/__init__.py index 474c30e13b..33d0779eae 100644 --- a/esphome/components/micronova/text_sensor/__init__.py +++ b/esphome/components/micronova/text_sensor/__init__.py @@ -3,19 +3,18 @@ from esphome.components import text_sensor import esphome.config_validation as cv from .. import ( - CONF_MEMORY_ADDRESS, - CONF_MEMORY_LOCATION, CONF_MICRONOVA_ID, - MICRONOVA_LISTENER_SCHEMA, + MICRONOVA_ADDRESS_SCHEMA, MicroNova, - MicroNovaFunctions, + MicroNovaListener, micronova_ns, + to_code_micronova_listener, ) CONF_STOVE_STATE = "stove_state" MicroNovaTextSensor = micronova_ns.class_( - "MicroNovaTextSensor", text_sensor.TextSensor, cg.Component + "MicroNovaTextSensor", text_sensor.TextSensor, MicroNovaListener ) CONFIG_SCHEMA = cv.Schema( @@ -24,8 +23,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_STOVE_STATE): text_sensor.text_sensor_schema( MicroNovaTextSensor ).extend( - MICRONOVA_LISTENER_SCHEMA( - default_memory_location=0x00, default_memory_address=0x21 + MICRONOVA_ADDRESS_SCHEMA( + default_memory_location=0x00, + default_memory_address=0x21, + is_polling_component=True, ) ), } @@ -37,7 +38,4 @@ async def to_code(config): if stove_state_config := config.get(CONF_STOVE_STATE): sens = await text_sensor.new_text_sensor(stove_state_config, mv) - cg.add(mv.register_micronova_listener(sens)) - cg.add(sens.set_memory_location(stove_state_config[CONF_MEMORY_LOCATION])) - cg.add(sens.set_memory_address(stove_state_config[CONF_MEMORY_ADDRESS])) - cg.add(sens.set_function(MicroNovaFunctions.STOVE_FUNCTION_STOVE_STATE)) + await to_code_micronova_listener(mv, sens, stove_state_config) diff --git a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp index b62fb1afce..2217ed6d6f 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.cpp @@ -8,22 +8,7 @@ void MicroNovaTextSensor::process_value_from_stove(int value_from_stove) { return; } - switch (this->get_function()) { - case MicroNovaFunctions::STOVE_FUNCTION_STOVE_STATE: - this->micronova_->set_current_stove_state(value_from_stove); - this->publish_state(STOVE_STATES[value_from_stove]); - // set the stove switch to on for any value but 0 - if (value_from_stove != 0 && this->micronova_->get_stove_switch() != nullptr && - !this->micronova_->get_stove_switch()->get_stove_state()) { - this->micronova_->get_stove_switch()->set_stove_state(true); - } else if (value_from_stove == 0 && this->micronova_->get_stove_switch() != nullptr && - this->micronova_->get_stove_switch()->get_stove_state()) { - this->micronova_->get_stove_switch()->set_stove_state(false); - } - break; - default: - break; - } + this->publish_state(STOVE_STATES[value_from_stove]); } } // 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 352f049654..290f0ca45a 100644 --- a/esphome/components/micronova/text_sensor/micronova_text_sensor.h +++ b/esphome/components/micronova/text_sensor/micronova_text_sensor.h @@ -5,10 +5,25 @@ namespace esphome::micronova { -class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaSensorListener { +static const char *const STOVE_STATES[11] = {"Off", + "Start", + "Pellets loading", + "Ignition", + "Working", + "Brazier Cleaning", + "Final Cleaning", + "Standby", + "No pellets alarm", + "No ignition alarm", + "Undefined alarm"}; + +class MicroNovaTextSensor : public text_sensor::TextSensor, public MicroNovaListener { public: - MicroNovaTextSensor(MicroNova *m) : MicroNovaSensorListener(m) {} - void dump_config() override { LOG_TEXT_SENSOR("", "Micronova text sensor", this); } + MicroNovaTextSensor(MicroNova *m) : MicroNovaListener(m) {} + void dump_config() override { + LOG_TEXT_SENSOR("", "Micronova text sensor", this); + this->dump_base_config(); + } void request_value_from_stove() override { this->micronova_->request_address(this->memory_location_, this->memory_address_, this); } diff --git a/esphome/components/mipi_dsi/display.py b/esphome/components/mipi_dsi/display.py index 4fc837be67..90c4cc082e 100644 --- a/esphome/components/mipi_dsi/display.py +++ b/esphome/components/mipi_dsi/display.py @@ -12,7 +12,7 @@ from esphome.components.const import ( CONF_DRAW_ROUNDING, ) from esphome.components.display import CONF_SHOW_TEST_CARD -from esphome.components.esp32 import const, only_on_variant +from esphome.components.esp32 import VARIANT_ESP32P4, only_on_variant from esphome.components.mipi import ( COLOR_ORDERS, CONF_COLOR_DEPTH, @@ -165,7 +165,7 @@ def model_schema(config): ) return cv.All( schema, - only_on_variant(supported=[const.VARIANT_ESP32P4]), + only_on_variant(supported=[VARIANT_ESP32P4]), cv.only_with_esp_idf, ) diff --git a/esphome/components/mipi_rgb/display.py b/esphome/components/mipi_rgb/display.py index 9d6b1fa729..61dbeb8ed4 100644 --- a/esphome/components/mipi_rgb/display.py +++ b/esphome/components/mipi_rgb/display.py @@ -11,7 +11,7 @@ from esphome.components.const import ( CONF_DRAW_ROUNDING, ) from esphome.components.display import CONF_SHOW_TEST_CARD -from esphome.components.esp32 import const, only_on_variant +from esphome.components.esp32 import VARIANT_ESP32S3, only_on_variant from esphome.components.mipi import ( COLOR_ORDERS, CONF_DE_PIN, @@ -24,7 +24,7 @@ from esphome.components.mipi import ( CONF_VSYNC_BACK_PORCH, CONF_VSYNC_FRONT_PORCH, CONF_VSYNC_PULSE_WIDTH, - MODE_BGR, + MODE_RGB, PIXEL_MODE_16BIT, PIXEL_MODE_18BIT, DriverChip, @@ -157,7 +157,7 @@ def model_schema(config): model.option(CONF_ENABLE_PIN, cv.UNDEFINED): cv.ensure_list( pins.gpio_output_pin_schema ), - model.option(CONF_COLOR_ORDER, MODE_BGR): cv.enum(COLOR_ORDERS, upper=True), + model.option(CONF_COLOR_ORDER, MODE_RGB): cv.enum(COLOR_ORDERS, upper=True), model.option(CONF_DRAW_ROUNDING, 2): power_of_two, model.option(CONF_PIXEL_MODE, PIXEL_MODE_16BIT): cv.one_of( *pixel_modes, lower=True @@ -224,7 +224,7 @@ def _config_schema(config): schema = model_schema(config) return cv.All( schema, - only_on_variant(supported=[const.VARIANT_ESP32S3]), + only_on_variant(supported=[VARIANT_ESP32S3]), cv.only_with_esp_idf, )(config) @@ -280,14 +280,9 @@ async def to_code(config): red_pins = config[CONF_DATA_PINS][CONF_RED] green_pins = config[CONF_DATA_PINS][CONF_GREEN] blue_pins = config[CONF_DATA_PINS][CONF_BLUE] - if config[CONF_COLOR_ORDER] == "BGR": - dpins.extend(red_pins) - dpins.extend(green_pins) - dpins.extend(blue_pins) - else: - dpins.extend(blue_pins) - dpins.extend(green_pins) - dpins.extend(red_pins) + dpins.extend(blue_pins) + dpins.extend(green_pins) + dpins.extend(red_pins) # swap bytes to match big-endian format dpins = dpins[8:16] + dpins[0:8] else: diff --git a/esphome/components/mipi_rgb/mipi_rgb.cpp b/esphome/components/mipi_rgb/mipi_rgb.cpp index 74eedae4f4..d5d1caf6d2 100644 --- a/esphome/components/mipi_rgb/mipi_rgb.cpp +++ b/esphome/components/mipi_rgb/mipi_rgb.cpp @@ -371,17 +371,10 @@ void MipiRgb::dump_config() { 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); - this->dump_pins_(13, 16, "Green", 0); - this->dump_pins_(0, 3, "Green", 3); - this->dump_pins_(3, 8, "Red", 0); - } else { - this->dump_pins_(8, 13, "Red", 0); - this->dump_pins_(13, 16, "Green", 0); - this->dump_pins_(0, 3, "Green", 3); - this->dump_pins_(3, 8, "Blue", 0); - } + this->dump_pins_(8, 13, "Blue", 0); + this->dump_pins_(13, 16, "Green", 0); + this->dump_pins_(0, 3, "Green", 3); + this->dump_pins_(3, 8, "Red", 0); } } // namespace mipi_rgb diff --git a/esphome/components/mipi_rgb/models/lilygo.py b/esphome/components/mipi_rgb/models/lilygo.py index 109dc42af6..c0e91cd8ae 100644 --- a/esphome/components/mipi_rgb/models/lilygo.py +++ b/esphome/components/mipi_rgb/models/lilygo.py @@ -7,7 +7,6 @@ ST7701S( "T-PANEL-S3", width=480, height=480, - color_order="BGR", invert_colors=False, swap_xy=UNDEFINED, spi_mode="MODE3", @@ -56,7 +55,6 @@ t_rgb = ST7701S( "T-RGB-2.1", width=480, height=480, - color_order="BGR", pixel_mode="18bit", invert_colors=False, swap_xy=UNDEFINED, diff --git a/esphome/components/mipi_rgb/models/st7701s.py b/esphome/components/mipi_rgb/models/st7701s.py index 0b0a9548ca..3c66380d04 100644 --- a/esphome/components/mipi_rgb/models/st7701s.py +++ b/esphome/components/mipi_rgb/models/st7701s.py @@ -82,7 +82,6 @@ st7701s.extend( "MAKERFABS-4", width=480, height=480, - color_order="RGB", invert_colors=True, pixel_mode="18bit", cs_pin=1, diff --git a/esphome/components/mipi_rgb/models/waveshare.py b/esphome/components/mipi_rgb/models/waveshare.py index 0fc765fd52..cd1fc341ef 100644 --- a/esphome/components/mipi_rgb/models/waveshare.py +++ b/esphome/components/mipi_rgb/models/waveshare.py @@ -1,13 +1,13 @@ -from esphome.components.mipi import DriverChip +from esphome.components.mipi import DriverChip, delay from esphome.config_validation import UNDEFINED from .st7701s import st7701s +# fmt: off wave_4_3 = DriverChip( "ESP32-S3-TOUCH-LCD-4.3", swap_xy=UNDEFINED, initsequence=(), - color_order="RGB", width=800, height=480, pclk_frequency="16MHz", @@ -55,10 +55,9 @@ wave_4_3.extend( ) st7701s.extend( - "WAVESHARE-4-480x480", + "WAVESHARE-4-480X480", data_rate="2MHz", spi_mode="MODE3", - color_order="BGR", pixel_mode="18bit", width=480, height=480, @@ -76,3 +75,72 @@ st7701s.extend( "blue": [5, 45, 48, 47, 21], }, ) + +st7701s.extend( + "WAVESHARE-3.16-320X820", + width=320, + height=820, + de_pin=40, + hsync_pin=38, + vsync_pin=39, + pclk_pin=41, + cs_pin={ + "number": 0, + "ignore_strapping_warning": True, + }, + pclk_frequency="18MHz", + reset_pin=16, + hsync_back_porch=30, + hsync_front_porch=30, + hsync_pulse_width=6, + vsync_back_porch=20, + vsync_front_porch=20, + vsync_pulse_width=40, + data_pins={ + "red": [17, 46, 3, 8, 18], + "green": [14, 13, 12, 11, 10, 9], + "blue": [21, 5, 45, 48, 47], + }, + initsequence=( + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), + (0xEF, 0x08), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x10), + (0xC0, 0xE5, 0x02), + (0xC1, 0x15, 0x0A), + (0xC2, 0x07, 0x02), + (0xCC, 0x10), + (0xB0, 0x00, 0x08, 0x51, 0x0D, 0xCE, 0x06, 0x00, 0x08, 0x08, 0x24, 0x05, 0xD0, 0x0F, 0x6F, 0x36, 0x1F), + (0xB1, 0x00, 0x10, 0x4F, 0x0C, 0x11, 0x05, 0x00, 0x07, 0x07, 0x18, 0x02, 0xD3, 0x11, 0x6E, 0x34, 0x1F), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x11), + (0xB0, 0x4D), + (0xB1, 0x37), + (0xB2, 0x87), + (0xB3, 0x80), + (0xB5, 0x4A), + (0xB7, 0x85), + (0xB8, 0x21), + (0xB9, 0x00, 0x13), + (0xC0, 0x09), + (0xC1, 0x78), + (0xC2, 0x78), + (0xD0, 0x88), + (0xE0, 0x80, 0x00, 0x02), + (0xE1, 0x0F, 0xA0, 0x00, 0x00, 0x10, 0xA0, 0x00, 0x00, 0x00, 0x60, 0x60), + (0xE2, 0x30, 0x30, 0x60, 0x60, 0x45, 0xA0, 0x00, 0x00, 0x46, 0xA0, 0x00, 0x00, 0x00), + (0xE3, 0x00, 0x00, 0x33, 0x33), + (0xE4, 0x44, 0x44), + (0xE5, 0x0F, 0x4A, 0xA0, 0xA0, 0x11, 0x4A, 0xA0, 0xA0, 0x13, 0x4A, 0xA0, 0xA0, 0x15, 0x4A, 0xA0, 0xA0), + (0xE6, 0x00, 0x00, 0x33, 0x33), + (0xE7, 0x44, 0x44), + (0xE8, 0x10, 0x4A, 0xA0, 0xA0, 0x12, 0x4A, 0xA0, 0xA0, 0x14, 0x4A, 0xA0, 0xA0, 0x16, 0x4A, 0xA0, 0xA0), + (0xEB, 0x02, 0x00, 0x4E, 0x4E, 0xEE, 0x44, 0x00), + (0xED, 0xFF, 0xFF, 0x04, 0x56, 0x72, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x27, 0x65, 0x40, 0xFF, 0xFF), + (0xEF, 0x08, 0x08, 0x08, 0x40, 0x3F, 0x64), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x13), + (0xE8, 0x00, 0x0E), + (0xE8, 0x00, 0x0C), + delay(10), + (0xE8, 0x00, 0x00), + (0xFF, 0x77, 0x01, 0x00, 0x00, 0x00), + ) +) diff --git a/esphome/components/mipi_spi/models/ili.py b/esphome/components/mipi_spi/models/ili.py index 0102c0f665..60a25c32a9 100644 --- a/esphome/components/mipi_spi/models/ili.py +++ b/esphome/components/mipi_spi/models/ili.py @@ -148,6 +148,19 @@ ILI9341 = DriverChip( ), ), ) +# M5Stack Core2 uses ILI9341 chip - mirror_x disabled for correct orientation +ILI9341.extend( + "M5CORE2", + width=320, + height=240, + mirror_x=False, + cs_pin=5, + dc_pin=15, + invert_colors=True, + pixel_mode="18bit", + data_rate="40MHz", +) + DriverChip( "ILI9481", mirror_x=True, diff --git a/esphome/components/mqtt/mqtt_component.cpp b/esphome/components/mqtt/mqtt_component.cpp index 1cd818964e..5d2bedae79 100644 --- a/esphome/components/mqtt/mqtt_component.cpp +++ b/esphome/components/mqtt/mqtt_component.cpp @@ -154,7 +154,7 @@ bool MQTTComponent::send_discovery_() { device_info[MQTT_DEVICE_MANUFACTURER] = model == nullptr ? ESPHOME_PROJECT_NAME : std::string(ESPHOME_PROJECT_NAME, model - ESPHOME_PROJECT_NAME); #else - device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_VERSION " (" + App.get_compilation_time() + ")"; + device_info[MQTT_DEVICE_SW_VERSION] = ESPHOME_VERSION " (" + App.get_compilation_time_ref() + ")"; device_info[MQTT_DEVICE_MODEL] = ESPHOME_BOARD; #if defined(USE_ESP8266) || defined(USE_ESP32) device_info[MQTT_DEVICE_MANUFACTURER] = "Espressif"; diff --git a/esphome/components/neopixelbus/_methods.py b/esphome/components/neopixelbus/_methods.py index 5a00fa2804..9072f78035 100644 --- a/esphome/components/neopixelbus/_methods.py +++ b/esphome/components/neopixelbus/_methods.py @@ -2,12 +2,12 @@ from dataclasses import dataclass from typing import Any import esphome.codegen as cg -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C3, VARIANT_ESP32S2, VARIANT_ESP32S3, + get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import ( diff --git a/esphome/components/neopixelbus/light.py b/esphome/components/neopixelbus/light.py index 0c9604e932..d071059185 100644 --- a/esphome/components/neopixelbus/light.py +++ b/esphome/components/neopixelbus/light.py @@ -1,8 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import light -from esphome.components.esp32 import get_esp32_variant -from esphome.components.esp32.const import VARIANT_ESP32C3, VARIANT_ESP32S3 +from esphome.components.esp32 import VARIANT_ESP32C3, VARIANT_ESP32S3, get_esp32_variant import esphome.config_validation as cv from esphome.const import ( CONF_CHANNEL, diff --git a/esphome/components/nextion/nextion.h b/esphome/components/nextion/nextion.h index 61068b52fc..7e8f563a96 100644 --- a/esphome/components/nextion/nextion.h +++ b/esphome/components/nextion/nextion.h @@ -171,7 +171,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * * This will change the image of the component `pic` to the image with ID `4`. */ - void set_component_picture(const char *component, uint8_t picture_id); + void set_component_picture(const char *component, uint8_t picture_id) { set_component_picc(component, picture_id); }; /** * Set the background color of a component. @@ -374,7 +374,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * * This will change the picture id of the component `textview`. */ - void set_component_pic(const char *component, uint8_t pic_id); + void set_component_pic(const char *component, uint16_t pic_id); /** * Set the background picture id of component. @@ -388,7 +388,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * * This will change the background picture id of the component `textview`. */ - void set_component_picc(const char *component, uint8_t pic_id); + void set_component_picc(const char *component, uint16_t pic_id); /** * Set the font color of a component. @@ -910,7 +910,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe * Draws a QR code with a Wi-Fi network credentials starting at the given coordinates (25,25). */ void qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size = 200, uint16_t background_color = 65535, - uint16_t foreground_color = 0, uint8_t logo_pic = -1, uint8_t border_width = 8); + uint16_t foreground_color = 0, int32_t logo_pic = -1, uint8_t border_width = 8); /** * Draws a QR code in the screen @@ -935,7 +935,7 @@ class Nextion : public NextionBase, public PollingComponent, public uart::UARTDe */ void qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size, Color background_color = Color(255, 255, 255), Color foreground_color = Color(0, 0, 0), - uint8_t logo_pic = -1, uint8_t border_width = 8); + int32_t logo_pic = -1, uint8_t border_width = 8); /** Set the brightness of the backlight. * diff --git a/esphome/components/nextion/nextion_commands.cpp b/esphome/components/nextion/nextion_commands.cpp index cfaae7e3e0..2adf314a2e 100644 --- a/esphome/components/nextion/nextion_commands.cpp +++ b/esphome/components/nextion/nextion_commands.cpp @@ -143,12 +143,12 @@ void Nextion::set_component_pressed_font_color(const char *component, Color colo } // Set picture -void Nextion::set_component_pic(const char *component, uint8_t pic_id) { - this->add_no_result_to_queue_with_printf_("set_component_pic", "%s.pic=%" PRIu8, component, pic_id); +void Nextion::set_component_pic(const char *component, uint16_t pic_id) { + this->add_no_result_to_queue_with_printf_("set_component_pic", "%s.pic=%" PRIu16, component, pic_id); } -void Nextion::set_component_picc(const char *component, uint8_t pic_id) { - this->add_no_result_to_queue_with_printf_("set_component_picc", "%s.picc=%" PRIu8, component, pic_id); +void Nextion::set_component_picc(const char *component, uint16_t pic_id) { + this->add_no_result_to_queue_with_printf_("set_component_picc", "%s.picc=%" PRIu16, component, pic_id); } // Set video @@ -217,10 +217,6 @@ void Nextion::disable_component_touch(const char *component) { this->add_no_result_to_queue_with_printf_("disable_component_touch", "tsw %s,0", component); } -void Nextion::set_component_picture(const char *component, uint8_t picture_id) { - this->add_no_result_to_queue_with_printf_("set_component_picture", "%s.pic=%" PRIu8, component, picture_id); -} - void Nextion::set_component_text(const char *component, const char *text) { this->add_no_result_to_queue_with_printf_("set_component_text", "%s.txt=\"%s\"", component, text); } @@ -330,14 +326,14 @@ void Nextion::filled_circle(uint16_t center_x, uint16_t center_y, uint16_t radiu } void Nextion::qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size, uint16_t background_color, - uint16_t foreground_color, uint8_t logo_pic, uint8_t border_width) { + uint16_t foreground_color, int32_t logo_pic, uint8_t border_width) { this->add_no_result_to_queue_with_printf_( "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu8 ",%" PRIu8 ",\"%s\"", x1, y1, size, background_color, foreground_color, logo_pic, border_width, content); } void Nextion::qrcode(uint16_t x1, uint16_t y1, const char *content, uint16_t size, Color background_color, - Color foreground_color, uint8_t logo_pic, uint8_t border_width) { + Color foreground_color, int32_t logo_pic, uint8_t border_width) { this->add_no_result_to_queue_with_printf_( "qrcode", "qrcode %" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu16 ",%" PRIu8 ",%" PRIu8 ",\"%s\"", x1, y1, size, display::ColorUtil::color_to_565(background_color), display::ColorUtil::color_to_565(foreground_color), diff --git a/esphome/components/packages/__init__.py b/esphome/components/packages/__init__.py index 67fd2770e9..6d353ccf11 100644 --- a/esphome/components/packages/__init__.py +++ b/esphome/components/packages/__init__.py @@ -1,5 +1,9 @@ +from collections import UserDict +from collections.abc import Callable +from functools import reduce import logging from pathlib import Path +from typing import Any from esphome import git, yaml_util from esphome.components.substitutions.jinja import has_jinja @@ -15,6 +19,7 @@ from esphome.const import ( CONF_PATH, CONF_REF, CONF_REFRESH, + CONF_SUBSTITUTIONS, CONF_URL, CONF_USERNAME, CONF_VARS, @@ -27,32 +32,43 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = CONF_PACKAGES -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) +def validate_has_jinja(value: Any): + if not isinstance(value, str) or not has_jinja(value): + raise cv.Invalid("string does not contain Jinja syntax") + return value - # 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 +def valid_package_contents(allow_jinja: bool = True) -> Callable[[Any], dict]: + """Returns a validator that checks if a package_config that will be merged looks as + much as possible to a valid config to fail early on obvious mistakes.""" - raise cv.Invalid("Package contents must be a dict") + def validator(package_config: dict) -> dict: + 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 allow_jinja and isinstance(v, str) and has_jinja(v): + # e.g: remote package shorthand: + # package_name: github://esphome/repo/file.yaml@${ branch }, or: + # switch: ${ expression that evals to a switch } + continue + + raise cv.Invalid("Invalid component content in package definition") + return package_config + + raise cv.Invalid("Package contents must be a dict") + + return validator def expand_file_to_files(config: dict): @@ -91,7 +107,16 @@ def validate_source_shorthand(value): def deprecate_single_package(config): _LOGGER.warning( - "Including a single package under `packages:` is deprecated. Use a list instead." + """ + Including a single package under `packages:`, i.e., `packages: !include mypackage.yaml` is deprecated. + This method for including packages will go away in 2026.7.0 + Please use a list instead: + + packages: + - !include mypackage.yaml + + See https://github.com/esphome/esphome/pull/12116 + """ ) return config @@ -133,7 +158,10 @@ REMOTE_PACKAGE_SCHEMA = cv.All( 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}} + validate_has_jinja, # a Jinja string that may resolve to a package, or + valid_package_contents( + allow_jinja=True + ), # 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. ) @@ -226,32 +254,84 @@ def _process_remote_package(config: dict, skip_update: bool = False) -> dict: return {"packages": packages} -def _process_package(package_config, config, skip_update: bool = False): - recursive_package = package_config - if CONF_URL in package_config: - 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) - - -def do_packages_pass(config: dict, skip_update: bool = False): +def _walk_packages( + config: dict, callback: Callable[[dict], dict], validate_deprecated: bool = True +) -> dict: if CONF_PACKAGES not in config: return config packages = config[CONF_PACKAGES] - with cv.prepend_path(CONF_PACKAGES): + + # The following block and `validate_deprecated` parameter can be safely removed + # once single-package deprecation is effective + if validate_deprecated: packages = CONFIG_SCHEMA(packages) + + with cv.prepend_path(CONF_PACKAGES): if isinstance(packages, dict): for package_name, package_config in reversed(packages.items()): with cv.prepend_path(package_name): - config = _process_package(package_config, config, skip_update) + package_config = callback(package_config) + packages[package_name] = _walk_packages(package_config, callback) elif isinstance(packages, list): - for package_config in reversed(packages): - config = _process_package(package_config, config, skip_update) + for idx in reversed(range(len(packages))): + with cv.prepend_path(idx): + package_config = callback(packages[idx]) + packages[idx] = _walk_packages(package_config, callback) else: raise cv.Invalid( f"Packages must be a key to value mapping or list, got {type(packages)} instead" ) + config[CONF_PACKAGES] = packages + return config - del config[CONF_PACKAGES] + +def do_packages_pass(config: dict, skip_update: bool = False) -> dict: + """Processes, downloads and validates all packages in the config. + Also extracts and merges all substitutions found in packages into the main config substitutions. + """ + if CONF_PACKAGES not in config: + return config + + substitutions = UserDict(config.pop(CONF_SUBSTITUTIONS, {})) + + def process_package_callback(package_config: dict) -> dict: + """This will be called for each package found in the config.""" + package_config = PACKAGE_SCHEMA(package_config) + if isinstance(package_config, str): + return package_config # Jinja string, skip processing + if CONF_URL in package_config: + package_config = _process_remote_package(package_config, skip_update) + # Extract substitutions from the package and merge them into the main substitutions: + substitutions.data = merge_config( + package_config.pop(CONF_SUBSTITUTIONS, {}), substitutions.data + ) + return package_config + + _walk_packages(config, process_package_callback) + + if substitutions: + config[CONF_SUBSTITUTIONS] = substitutions.data + + return config + + +def merge_packages(config: dict) -> dict: + """Merges all packages into the main config and removes the `packages:` key.""" + if CONF_PACKAGES not in config: + return config + + # Build flat list of all package configs to merge in priority order: + merge_list: list[dict] = [] + + validate_package = valid_package_contents(allow_jinja=False) + + def process_package_callback(package_config: dict) -> dict: + """This will be called for each package found in the config.""" + merge_list.append(validate_package(package_config)) + return package_config + + _walk_packages(config, process_package_callback, validate_deprecated=False) + # Merge all packages into the main config: + config = reduce(lambda new, old: merge_config(old, new), merge_list, config) + del config[CONF_PACKAGES] return config diff --git a/esphome/components/packet_transport/__init__.py b/esphome/components/packet_transport/__init__.py index 43da7740fe..1930e45e85 100644 --- a/esphome/components/packet_transport/__init__.py +++ b/esphome/components/packet_transport/__init__.py @@ -176,17 +176,22 @@ async def register_packet_transport(var, config): if encryption := provider.get(CONF_ENCRYPTION): cg.add(var.set_provider_encryption(name, hash_encryption_key(encryption))) + is_provider = False for sens_conf in config.get(CONF_SENSORS, ()): + is_provider = True sens_id = sens_conf[CONF_ID] sensor = await cg.get_variable(sens_id) bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id) cg.add(var.add_sensor(bcst_id, sensor)) for sens_conf in config.get(CONF_BINARY_SENSORS, ()): + is_provider = True sens_id = sens_conf[CONF_ID] sensor = await cg.get_variable(sens_id) bcst_id = sens_conf.get(CONF_BROADCAST_ID, sens_id.id) cg.add(var.add_binary_sensor(bcst_id, sensor)) + if is_provider: + cg.add(var.set_is_provider(True)) if encryption := config.get(CONF_ENCRYPTION): cg.add(var.set_encryption_key(hash_encryption_key(encryption))) return providers diff --git a/esphome/components/packet_transport/packet_transport.cpp b/esphome/components/packet_transport/packet_transport.cpp index 37e5f3d9e1..da7f5f8bff 100644 --- a/esphome/components/packet_transport/packet_transport.cpp +++ b/esphome/components/packet_transport/packet_transport.cpp @@ -263,6 +263,7 @@ void PacketTransport::flush_() { xxtea::encrypt((uint32_t *) (encode_buffer.data() + header_len), len / 4, (uint32_t *) this->encryption_key_.data()); } + ESP_LOGVV(TAG, "Sending packet %s", format_hex_pretty(encode_buffer.data(), encode_buffer.size()).c_str()); this->send_packet(encode_buffer); } @@ -316,6 +317,9 @@ void PacketTransport::send_data_(bool all) { } void PacketTransport::update() { + // resend all sensors if required + if (this->is_provider_) + this->send_data_(true); if (!this->ping_pong_enable_) { return; } @@ -551,7 +555,7 @@ void PacketTransport::loop() { if (this->resend_ping_key_) this->send_ping_pong_request_(); if (this->updated_) { - this->send_data_(this->resend_data_); + this->send_data_(false); } } diff --git a/esphome/components/packet_transport/packet_transport.h b/esphome/components/packet_transport/packet_transport.h index a2370e9749..86ec564fce 100644 --- a/esphome/components/packet_transport/packet_transport.h +++ b/esphome/components/packet_transport/packet_transport.h @@ -91,6 +91,7 @@ class PacketTransport : public PollingComponent { } } + void set_is_provider(bool is_provider) { this->is_provider_ = is_provider; } void set_encryption_key(std::vector key) { this->encryption_key_ = std::move(key); } void set_rolling_code_enable(bool enable) { this->rolling_code_enable_ = enable; } void set_ping_pong_enable(bool enable) { this->ping_pong_enable_ = enable; } @@ -129,7 +130,7 @@ class PacketTransport : public PollingComponent { uint32_t ping_pong_recyle_time_{}; uint32_t last_key_time_{}; bool resend_ping_key_{}; - bool resend_data_{}; + bool is_provider_{}; const char *name_{}; ESPPreferenceObject pref_{}; diff --git a/esphome/components/pca9685/__init__.py b/esphome/components/pca9685/__init__.py index 50f58cdfb9..56101c2d62 100644 --- a/esphome/components/pca9685/__init__.py +++ b/esphome/components/pca9685/__init__.py @@ -1,7 +1,12 @@ import esphome.codegen as cg from esphome.components import i2c import esphome.config_validation as cv -from esphome.const import CONF_EXTERNAL_CLOCK_INPUT, CONF_FREQUENCY, CONF_ID +from esphome.const import ( + CONF_EXTERNAL_CLOCK_INPUT, + CONF_FREQUENCY, + CONF_ID, + CONF_PHASE_BALANCER, +) DEPENDENCIES = ["i2c"] MULTI_CONF = True @@ -9,6 +14,12 @@ MULTI_CONF = True pca9685_ns = cg.esphome_ns.namespace("pca9685") PCA9685Output = pca9685_ns.class_("PCA9685Output", cg.Component, i2c.I2CDevice) +phase_balancer = pca9685_ns.enum("PhaseBalancer", is_class=True) +PHASE_BALANCERS = { + "none": phase_balancer.NONE, + "linear": phase_balancer.LINEAR, +} + def validate_frequency(config): if config[CONF_EXTERNAL_CLOCK_INPUT]: @@ -30,6 +41,9 @@ CONFIG_SCHEMA = cv.All( cv.frequency, cv.Range(min=23.84, max=1525.88) ), cv.Optional(CONF_EXTERNAL_CLOCK_INPUT, default=False): cv.boolean, + cv.Optional(CONF_PHASE_BALANCER, default="linear"): cv.enum( + PHASE_BALANCERS + ), } ) .extend(cv.COMPONENT_SCHEMA) @@ -43,5 +57,6 @@ async def to_code(config): if CONF_FREQUENCY in config: cg.add(var.set_frequency(config[CONF_FREQUENCY])) cg.add(var.set_extclk(config[CONF_EXTERNAL_CLOCK_INPUT])) + cg.add(var.set_phase_balancer(config[CONF_PHASE_BALANCER])) await cg.register_component(var, config) await i2c.register_i2c_device(var, config) diff --git a/esphome/components/pca9685/pca9685_output.cpp b/esphome/components/pca9685/pca9685_output.cpp index 6df708ac84..77e3d5a6c6 100644 --- a/esphome/components/pca9685/pca9685_output.cpp +++ b/esphome/components/pca9685/pca9685_output.cpp @@ -105,7 +105,18 @@ void PCA9685Output::loop() { const uint16_t num_channels = this->max_channel_ - this->min_channel_ + 1; const uint16_t phase_delta_begin = 4096 / num_channels; for (uint8_t channel = this->min_channel_; channel <= this->max_channel_; channel++) { - uint16_t phase_begin = (channel - this->min_channel_) * phase_delta_begin; + uint16_t phase_begin; + switch (this->balancer_) { + case PhaseBalancer::NONE: + phase_begin = 0; + break; + case PhaseBalancer::LINEAR: + phase_begin = (channel - this->min_channel_) * phase_delta_begin; + break; + default: + ESP_LOGE(TAG, "Unknown phase balancer %d", static_cast(this->balancer_)); + return; + } uint16_t phase_end; uint16_t amount = this->pwm_amounts_[channel]; if (amount == 0) { diff --git a/esphome/components/pca9685/pca9685_output.h b/esphome/components/pca9685/pca9685_output.h index 8e547d0032..288c923d4c 100644 --- a/esphome/components/pca9685/pca9685_output.h +++ b/esphome/components/pca9685/pca9685_output.h @@ -7,6 +7,11 @@ namespace esphome { namespace pca9685 { +enum class PhaseBalancer { + NONE = 0x00, + LINEAR = 0x01, +}; + /// Inverts polarity of channel output signal extern const uint8_t PCA9685_MODE_INVERTED; /// Channel update happens upon ACK (post-set) rather than on STOP (endTransmission) @@ -47,6 +52,7 @@ class PCA9685Output : public Component, public i2c::I2CDevice { void loop() override; void set_extclk(bool extclk) { this->extclk_ = extclk; } void set_frequency(float frequency) { this->frequency_ = frequency; } + void set_phase_balancer(PhaseBalancer balancer) { this->balancer_ = balancer; } protected: friend PCA9685Channel; @@ -60,6 +66,7 @@ class PCA9685Output : public Component, public i2c::I2CDevice { float frequency_; uint8_t mode_; bool extclk_ = false; + PhaseBalancer balancer_ = PhaseBalancer::LINEAR; uint8_t min_channel_{0xFF}; uint8_t max_channel_{0x00}; diff --git a/esphome/components/pn532/__init__.py b/esphome/components/pn532/__init__.py index 3f04e8e1cc..6f679ed10a 100644 --- a/esphome/components/pn532/__init__.py +++ b/esphome/components/pn532/__init__.py @@ -55,7 +55,7 @@ def CONFIG_SCHEMA(conf): if conf: raise cv.Invalid( "This component has been moved in 1.16, please see the docs for updated " - "instructions. https://esphome.io/components/binary_sensor/pn532.html" + "instructions. https://esphome.io/components/binary_sensor/pn532/" ) diff --git a/esphome/components/psram/__init__.py b/esphome/components/psram/__init__.py index 4ee4e97696..39afb407f1 100644 --- a/esphome/components/psram/__init__.py +++ b/esphome/components/psram/__init__.py @@ -7,14 +7,12 @@ from esphome.components.esp32 import ( CONF_CPU_FREQUENCY, CONF_ENABLE_IDF_EXPERIMENTAL_FEATURES, VARIANT_ESP32, - add_idf_sdkconfig_option, - get_esp32_variant, -) -from esphome.components.esp32.const import ( VARIANT_ESP32C5, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + add_idf_sdkconfig_option, + get_esp32_variant, ) import esphome.config_validation as cv from esphome.const import ( @@ -54,18 +52,18 @@ CONF_ENABLE_ECC = "enable_ecc" SPIRAM_MODES = { VARIANT_ESP32: (TYPE_QUAD,), + VARIANT_ESP32C5: (TYPE_QUAD,), VARIANT_ESP32S2: (TYPE_QUAD,), VARIANT_ESP32S3: (TYPE_QUAD, TYPE_OCTAL), - VARIANT_ESP32C5: (TYPE_QUAD,), VARIANT_ESP32P4: (TYPE_HEX,), } SPIRAM_SPEEDS = { VARIANT_ESP32: (40, 80, 120), + VARIANT_ESP32C5: (40, 80, 120), VARIANT_ESP32S2: (40, 80, 120), VARIANT_ESP32S3: (40, 80, 120), - VARIANT_ESP32C5: (40, 80, 120), VARIANT_ESP32P4: (20, 100, 200), } @@ -199,7 +197,6 @@ async def to_code(config): add_idf_sdkconfig_option("CONFIG_SPIRAM_SPEED", speed) if config[CONF_MODE] == TYPE_OCTAL and speed == 120: add_idf_sdkconfig_option("CONFIG_ESPTOOLPY_FLASHFREQ_120M", True) - add_idf_sdkconfig_option("CONFIG_BOOTLOADER_FLASH_DC_AWARE", True) if CORE.data[KEY_CORE][KEY_FRAMEWORK_VERSION] >= cv.Version(5, 4, 0): add_idf_sdkconfig_option( "CONFIG_SPIRAM_TIMING_TUNING_POINT_VIA_TEMPERATURE_SENSOR", True diff --git a/esphome/components/remote_base/abbwelcome_protocol.h b/esphome/components/remote_base/abbwelcome_protocol.h index 4b922eb2f1..b8d9293c11 100644 --- a/esphome/components/remote_base/abbwelcome_protocol.h +++ b/esphome/components/remote_base/abbwelcome_protocol.h @@ -232,10 +232,10 @@ template class ABBWelcomeAction : public RemoteTransmitterAction data.set_message_id(this->message_id_.value(x...)); data.auto_message_id = this->auto_message_id_.value(x...); std::vector data_vec; - if (this->len_ >= 0) { + if (this->len_ > 0) { // Static mode: copy from flash to vector data_vec.assign(this->data_.data, this->data_.data + this->len_); - } else { + } else if (this->len_ < 0) { // Template mode: call function data_vec = this->data_.func(x...); } @@ -245,7 +245,7 @@ template class ABBWelcomeAction : public RemoteTransmitterAction } protected: - ssize_t len_{-1}; // -1 = template mode, >=0 = static mode with length + ssize_t len_{0}; // <0 = template mode, >=0 = static mode with length union Data { std::vector (*func)(Ts...); // Function pointer (stateless lambdas) const uint8_t *data; // Pointer to static data in flash diff --git a/esphome/components/remote_receiver/__init__.py b/esphome/components/remote_receiver/__init__.py index e79b3f91ed..f5d89f2f0f 100644 --- a/esphome/components/remote_receiver/__init__.py +++ b/esphome/components/remote_receiver/__init__.py @@ -65,7 +65,7 @@ RemoteReceiverComponent = remote_receiver_ns.class_( def validate_config(config): if CORE.is_esp32: variant = esp32.get_esp32_variant() - if variant in (esp32.const.VARIANT_ESP32, esp32.const.VARIANT_ESP32S2): + if variant in (esp32.VARIANT_ESP32, esp32.VARIANT_ESP32S2): max_idle = 65535 else: max_idle = 32767 @@ -131,13 +131,13 @@ CONFIG_SCHEMA = remote_base.validate_triggers( cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=192, - esp32_s2=192, - esp32_s3=192, - esp32_p4=192, esp32_c3=96, esp32_c5=96, esp32_c6=96, esp32_h2=96, + esp32_p4=192, + esp32_s2=192, + esp32_s3=192, ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), cv.Optional(CONF_FILTER_SYMBOLS): cv.All( cv.only_on_esp32, cv.int_range(min=0) @@ -148,7 +148,7 @@ CONFIG_SCHEMA = remote_base.validate_triggers( ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( - supported=[esp32.const.VARIANT_ESP32S3, esp32.const.VARIANT_ESP32P4] + supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] ), cv.boolean, ), diff --git a/esphome/components/remote_receiver/remote_receiver.cpp b/esphome/components/remote_receiver/remote_receiver.cpp index 53bfb0890f..a7ac74199d 100644 --- a/esphome/components/remote_receiver/remote_receiver.cpp +++ b/esphome/components/remote_receiver/remote_receiver.cpp @@ -5,63 +5,79 @@ #if defined(USE_LIBRETINY) || defined(USE_ESP8266) || defined(USE_RP2040) -namespace esphome { -namespace remote_receiver { +namespace esphome::remote_receiver { static const char *const TAG = "remote_receiver"; +static void IRAM_ATTR HOT write_value(RemoteReceiverComponentStore *arg, uint32_t delta, bool level) { + // convert level to -1 or +1 and write the delta to the buffer + int32_t multiplier = ((int32_t) level << 1) - 1; + uint32_t buffer_write = arg->buffer_write; + arg->buffer[buffer_write++] = (int32_t) delta * multiplier; + if (buffer_write >= arg->buffer_size) { + buffer_write = 0; + } + + // detect overflow and reset the write pointer + if (buffer_write == arg->buffer_read) { + buffer_write = arg->buffer_start; + arg->overflow = true; + } + + // detect idle and start a new sequence unless there is only idle in + // which case reset the write pointer instead + if (delta >= arg->idle_us) { + if (arg->buffer_write == arg->buffer_start) { + buffer_write = arg->buffer_start; + } else { + arg->buffer_start = buffer_write; + } + } + arg->buffer_write = buffer_write; +} + +static void IRAM_ATTR HOT commit_value(RemoteReceiverComponentStore *arg, uint32_t micros, bool level) { + // commit value if the level is different from the last commit level + if (level != arg->commit_level) { + write_value(arg, micros - arg->commit_micros, level); + arg->commit_micros = micros; + arg->commit_level = level; + } +} + void IRAM_ATTR HOT RemoteReceiverComponentStore::gpio_intr(RemoteReceiverComponentStore *arg) { - const uint32_t now = micros(); - // If the lhs is 1 (rising edge) we should write to an uneven index and vice versa - const uint32_t next = (arg->buffer_write_at + 1) % arg->buffer_size; - const bool level = arg->pin.digital_read(); - if (level != next % 2) - return; + // invert the level so it matches the level of the signal before the edge + const bool curr_level = !arg->pin.digital_read(); + const uint32_t curr_micros = micros(); + const bool prev_level = arg->prev_level; + const uint32_t prev_micros = arg->prev_micros; - // If next is buffer_read, we have hit an overflow - if (next == arg->buffer_read_at) - return; - - const uint32_t last_change = arg->buffer[arg->buffer_write_at]; - const uint32_t time_since_change = now - last_change; - if (time_since_change <= arg->filter_us) - return; - - arg->buffer[arg->buffer_write_at = next] = now; // NOLINT(clang-diagnostic-deprecated-volatile) + // commit the previous value if the pulse is not filtered and the level is different + if (curr_micros - prev_micros >= arg->filter_us && prev_level != curr_level) { + commit_value(arg, prev_micros, prev_level); + } + arg->prev_micros = curr_micros; + arg->prev_level = curr_level; } void RemoteReceiverComponent::setup() { this->pin_->setup(); - auto &s = this->store_; - s.filter_us = this->filter_us_; - s.pin = this->pin_->to_isr(); - s.buffer_size = this->buffer_size_; - - this->high_freq_.start(); - if (s.buffer_size % 2 != 0) { - // Make sure divisible by two. This way, we know that every 0bxxx0 index is a space and every 0bxxx1 index is a mark - s.buffer_size++; - } - - s.buffer = new uint32_t[s.buffer_size]; - void *buf = (void *) s.buffer; - memset(buf, 0, s.buffer_size * sizeof(uint32_t)); - - // First index is a space. - if (this->pin_->digital_read()) { - s.buffer_write_at = s.buffer_read_at = 1; - } else { - s.buffer_write_at = s.buffer_read_at = 0; - } + this->store_.idle_us = this->idle_us_; + this->store_.filter_us = this->filter_us_; + this->store_.pin = this->pin_->to_isr(); + this->store_.buffer = new int32_t[this->buffer_size_]; + this->store_.buffer_size = this->buffer_size_; + this->store_.prev_micros = micros(); + this->store_.commit_micros = this->store_.prev_micros; + this->store_.prev_level = this->pin_->digital_read(); + this->store_.commit_level = this->store_.prev_level; this->pin_->attach_interrupt(RemoteReceiverComponentStore::gpio_intr, &this->store_, gpio::INTERRUPT_ANY_EDGE); + this->high_freq_.start(); } + void RemoteReceiverComponent::dump_config() { ESP_LOGCONFIG(TAG, "Remote Receiver:"); LOG_PIN(" Pin: ", this->pin_); - if (this->pin_->digital_read()) { - ESP_LOGW(TAG, "Remote Receiver Signal starts with a HIGH value. Usually this means you have to " - "invert the signal using 'inverted: True' in the pin schema!"); - } ESP_LOGCONFIG(TAG, " Buffer Size: %u\n" " Tolerance: %u%s\n" @@ -73,53 +89,54 @@ void RemoteReceiverComponent::dump_config() { } void RemoteReceiverComponent::loop() { + // check for overflow auto &s = this->store_; - - // copy write at to local variables, as it's volatile - const uint32_t write_at = s.buffer_write_at; - const uint32_t dist = (s.buffer_size + write_at - s.buffer_read_at) % s.buffer_size; - // signals must at least one rising and one leading edge - if (dist <= 1) - return; - const uint32_t now = micros(); - if (now - s.buffer[write_at] < this->idle_us_) { - // The last change was fewer than the configured idle time ago. - return; + if (s.overflow) { + ESP_LOGW(TAG, "Buffer overflow"); + s.overflow = false; } - ESP_LOGVV(TAG, "read_at=%u write_at=%u dist=%u now=%u end=%u", s.buffer_read_at, write_at, dist, now, - s.buffer[write_at]); - - // Skip first value, it's from the previous idle level - s.buffer_read_at = (s.buffer_read_at + 1) % s.buffer_size; - uint32_t prev = s.buffer_read_at; - s.buffer_read_at = (s.buffer_read_at + 1) % s.buffer_size; - const uint32_t reserve_size = 1 + (s.buffer_size + write_at - s.buffer_read_at) % s.buffer_size; - this->temp_.clear(); - this->temp_.reserve(reserve_size); - int32_t multiplier = s.buffer_read_at % 2 == 0 ? 1 : -1; - - for (uint32_t i = 0; prev != write_at; i++) { - int32_t delta = s.buffer[s.buffer_read_at] - s.buffer[prev]; - if (uint32_t(delta) >= this->idle_us_) { - // already found a space longer than idle. There must have been two pulses - break; + // if no data is available check for uncommitted data stuck in the buffer and commit + // the previous value if needed + uint32_t last_index = s.buffer_start; + if (last_index == s.buffer_read) { + InterruptLock lock; + if (s.buffer_read == s.buffer_start && s.buffer_write != s.buffer_start && + micros() - s.prev_micros >= this->idle_us_) { + commit_value(&s, s.prev_micros, s.prev_level); + write_value(&s, s.idle_us, !s.commit_level); + last_index = s.buffer_start; } - - ESP_LOGVV(TAG, " i=%u buffer[%u]=%u - buffer[%u]=%u -> %d", i, s.buffer_read_at, s.buffer[s.buffer_read_at], prev, - s.buffer[prev], multiplier * delta); - this->temp_.push_back(multiplier * delta); - prev = s.buffer_read_at; - s.buffer_read_at = (s.buffer_read_at + 1) % s.buffer_size; - multiplier *= -1; } - s.buffer_read_at = (s.buffer_size + s.buffer_read_at - 1) % s.buffer_size; - this->temp_.push_back(this->idle_us_ * multiplier); + if (last_index == s.buffer_read) { + return; + } + // find the size of the packet and reserve the memory + uint32_t temp_read = s.buffer_read; + uint32_t reserve_size = 0; + while (temp_read != last_index && (uint32_t) std::abs(s.buffer[temp_read]) < this->idle_us_) { + reserve_size++; + temp_read++; + if (temp_read >= s.buffer_size) { + temp_read = 0; + } + } + this->temp_.clear(); + this->temp_.reserve(reserve_size + 1); + + // read the buffer + for (uint32_t i = 0; i < reserve_size + 1; i++) { + this->temp_.push_back((int32_t) s.buffer[s.buffer_read++]); + if (s.buffer_read >= s.buffer_size) { + s.buffer_read = 0; + } + } + + // call the listeners and dumpers this->call_listeners_dumpers_(); } -} // namespace remote_receiver -} // namespace esphome +} // namespace esphome::remote_receiver #endif diff --git a/esphome/components/remote_receiver/remote_receiver.h b/esphome/components/remote_receiver/remote_receiver.h index 3d2f7f0ef9..3d9199a904 100644 --- a/esphome/components/remote_receiver/remote_receiver.h +++ b/esphome/components/remote_receiver/remote_receiver.h @@ -9,25 +9,31 @@ #include #endif -namespace esphome { -namespace remote_receiver { +namespace esphome::remote_receiver { #if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) struct RemoteReceiverComponentStore { static void gpio_intr(RemoteReceiverComponentStore *arg); - /// Stores the time (in micros) that the leading/falling edge happened at - /// * An even index means a falling edge appeared at the time stored at the index - /// * An uneven index means a rising edge appeared at the time stored at the index - volatile uint32_t *buffer{nullptr}; + /// Stores pulse durations in microseconds as signed integers + /// * Positive values indicate high pulses (marks) + /// * Negative values indicate low pulses (spaces) + volatile int32_t *buffer{nullptr}; /// The position last written to - volatile uint32_t buffer_write_at; + volatile uint32_t buffer_write{0}; + /// The start position of the last sequence + volatile uint32_t buffer_start{0}; /// The position last read from - uint32_t buffer_read_at{0}; - bool overflow{false}; + uint32_t buffer_read{0}; + volatile uint32_t commit_micros{0}; + volatile uint32_t prev_micros{0}; uint32_t buffer_size{1000}; uint32_t filter_us{10}; + uint32_t idle_us{10000}; ISRInternalGPIOPin pin; + volatile bool commit_level{false}; + volatile bool prev_level{false}; + volatile bool overflow{false}; }; #elif defined(USE_ESP32) struct RemoteReceiverComponentStore { @@ -84,8 +90,11 @@ class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, std::string error_string_{""}; #endif -#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_ESP32) || defined(USE_RP2040) +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) || defined(USE_ESP32) RemoteReceiverComponentStore store_; +#endif + +#if defined(USE_ESP8266) || defined(USE_LIBRETINY) || defined(USE_RP2040) HighFrequencyLoopRequester high_freq_; #endif @@ -94,5 +103,4 @@ class RemoteReceiverComponent : public remote_base::RemoteReceiverBase, uint32_t idle_us_{10000}; }; -} // namespace remote_receiver -} // namespace esphome +} // namespace esphome::remote_receiver diff --git a/esphome/components/remote_receiver/remote_receiver_esp32.cpp b/esphome/components/remote_receiver/remote_receiver_esp32.cpp index 49358eef3f..bd0bc8e57b 100644 --- a/esphome/components/remote_receiver/remote_receiver_esp32.cpp +++ b/esphome/components/remote_receiver/remote_receiver_esp32.cpp @@ -4,8 +4,7 @@ #ifdef USE_ESP32 #include -namespace esphome { -namespace remote_receiver { +namespace esphome::remote_receiver { static const char *const TAG = "remote_receiver.esp32"; #ifdef USE_ESP32_VARIANT_ESP32H2 @@ -248,7 +247,6 @@ void RemoteReceiverComponent::decode_rmt_(rmt_symbol_word_t *item, size_t item_c } } -} // namespace remote_receiver -} // namespace esphome +} // namespace esphome::remote_receiver #endif diff --git a/esphome/components/remote_transmitter/__init__.py b/esphome/components/remote_transmitter/__init__.py index ff055b959b..f182a1ec0d 100644 --- a/esphome/components/remote_transmitter/__init__.py +++ b/esphome/components/remote_transmitter/__init__.py @@ -55,20 +55,20 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_EOT_LEVEL): cv.All(cv.only_on_esp32, cv.boolean), cv.Optional(CONF_USE_DMA): cv.All( esp32.only_on_variant( - supported=[esp32.const.VARIANT_ESP32S3, esp32.const.VARIANT_ESP32P4] + supported=[esp32.VARIANT_ESP32P4, esp32.VARIANT_ESP32S3] ), cv.boolean, ), cv.SplitDefault( CONF_RMT_SYMBOLS, esp32=64, - esp32_s2=64, - esp32_s3=48, - esp32_p4=48, esp32_c3=48, esp32_c5=48, esp32_c6=48, esp32_h2=48, + esp32_p4=48, + esp32_s2=64, + esp32_s3=48, ): cv.All(cv.only_on_esp32, cv.int_range(min=2)), cv.Optional(CONF_NON_BLOCKING): cv.All(cv.only_on_esp32, cv.boolean), cv.Optional(CONF_ON_TRANSMIT): automation.validate_automation(single=True), diff --git a/esphome/components/rpi_dpi_rgb/display.py b/esphome/components/rpi_dpi_rgb/display.py index 513ed8eb58..8e9da43a74 100644 --- a/esphome/components/rpi_dpi_rgb/display.py +++ b/esphome/components/rpi_dpi_rgb/display.py @@ -1,7 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display -from esphome.components.esp32 import const, only_on_variant +from esphome.components.esp32 import VARIANT_ESP32S3, only_on_variant from esphome.components.mipi import ( CONF_DE_PIN, CONF_HSYNC_BACK_PORCH, @@ -121,7 +121,7 @@ CONFIG_SCHEMA = cv.All( } ) ), - only_on_variant(supported=[const.VARIANT_ESP32S3]), + only_on_variant(supported=[VARIANT_ESP32S3]), cv.only_with_esp_idf, ) diff --git a/esphome/components/rtl87xx/__init__.py b/esphome/components/rtl87xx/__init__.py index d24ffcea3d..8f27544108 100644 --- a/esphome/components/rtl87xx/__init__.py +++ b/esphome/components/rtl87xx/__init__.py @@ -10,7 +10,9 @@ import esphome.codegen as cg from esphome.components import libretiny from esphome.components.libretiny.const import ( COMPONENT_RTL87XX, + FAMILY_RTL8710B, KEY_COMPONENT_DATA, + KEY_FAMILY, KEY_LIBRETINY, LibreTinyComponent, ) @@ -48,7 +50,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") + # Only for RTL8710B (ambz) - RTL8720C (ambz2) requires FreeRTOS 10.x + if CORE.data[KEY_LIBRETINY][KEY_FAMILY] == FAMILY_RTL8710B: + cg.add_platformio_option("custom_versions.freertos", "8.2.3") return await libretiny.component_to_code(config) diff --git a/esphome/components/select/select.cpp b/esphome/components/select/select.cpp index 3ec413f167..4fc4d79b08 100644 --- a/esphome/components/select/select.cpp +++ b/esphome/components/select/select.cpp @@ -56,12 +56,10 @@ size_t Select::size() const { return options.size(); } -optional Select::index_of(const std::string &option) const { return this->index_of(option.c_str()); } - -optional Select::index_of(const char *option) const { +optional Select::index_of(const char *option, size_t len) const { const auto &options = traits.get_options(); for (size_t i = 0; i < options.size(); i++) { - if (strcmp(options[i], option) == 0) { + if (strncmp(options[i], option, len) == 0 && options[i][len] == '\0') { return i; } } diff --git a/esphome/components/select/select.h b/esphome/components/select/select.h index c4d7412d50..63707f6bd6 100644 --- a/esphome/components/select/select.h +++ b/esphome/components/select/select.h @@ -62,8 +62,9 @@ class Select : public EntityBase { size_t size() const; /// Find the (optional) index offset of the provided option value. - optional index_of(const std::string &option) const; - optional index_of(const char *option) const; + optional index_of(const char *option, size_t len) const; + optional index_of(const std::string &option) const { return this->index_of(option.data(), option.size()); } + optional index_of(const char *option) const { return this->index_of(option, strlen(option)); } /// Return the (optional) index offset of the currently active option. optional active_index() const; diff --git a/esphome/components/select/select_call.cpp b/esphome/components/select/select_call.cpp index aecfed0d64..2ff99c961d 100644 --- a/esphome/components/select/select_call.cpp +++ b/esphome/components/select/select_call.cpp @@ -6,9 +6,7 @@ namespace esphome::select { static const char *const TAG = "select"; -SelectCall &SelectCall::set_option(const std::string &option) { return this->with_option(option); } - -SelectCall &SelectCall::set_option(const char *option) { return this->with_option(option); } +SelectCall &SelectCall::set_option(const char *option, size_t len) { return this->with_option(option, len); } SelectCall &SelectCall::set_index(size_t index) { return this->with_index(index); } @@ -32,12 +30,10 @@ SelectCall &SelectCall::with_cycle(bool cycle) { return *this; } -SelectCall &SelectCall::with_option(const std::string &option) { return this->with_option(option.c_str()); } - -SelectCall &SelectCall::with_option(const char *option) { +SelectCall &SelectCall::with_option(const char *option, size_t len) { this->operation_ = SELECT_OP_SET; // Find the option index - this validates the option exists - this->index_ = this->parent_->index_of(option); + this->index_ = this->parent_->index_of(option, len); return *this; } diff --git a/esphome/components/select/select_call.h b/esphome/components/select/select_call.h index b31d890ef6..c9abbc69a0 100644 --- a/esphome/components/select/select_call.h +++ b/esphome/components/select/select_call.h @@ -20,8 +20,9 @@ class SelectCall { explicit SelectCall(Select *parent) : parent_(parent) {} void perform(); - SelectCall &set_option(const std::string &option); - SelectCall &set_option(const char *option); + SelectCall &set_option(const char *option, size_t len); + SelectCall &set_option(const std::string &option) { return this->set_option(option.data(), option.size()); } + SelectCall &set_option(const char *option) { return this->set_option(option, strlen(option)); } SelectCall &set_index(size_t index); SelectCall &select_next(bool cycle); @@ -31,8 +32,9 @@ class SelectCall { SelectCall &with_operation(SelectOperation operation); SelectCall &with_cycle(bool cycle); - SelectCall &with_option(const std::string &option); - SelectCall &with_option(const char *option); + SelectCall &with_option(const char *option, size_t len); + SelectCall &with_option(const std::string &option) { return this->with_option(option.data(), option.size()); } + SelectCall &with_option(const char *option) { return this->with_option(option, strlen(option)); } SelectCall &with_index(size_t index); protected: diff --git a/esphome/components/sen5x/sen5x.cpp b/esphome/components/sen5x/sen5x.cpp index 3298a5b8db..ffb9e2bc02 100644 --- a/esphome/components/sen5x/sen5x.cpp +++ b/esphome/components/sen5x/sen5x.cpp @@ -157,7 +157,7 @@ void SEN5XComponent::setup() { // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time() + std::to_string(combined_serial)); + uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(combined_serial)); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { diff --git a/esphome/components/sgp30/sgp30.cpp b/esphome/components/sgp30/sgp30.cpp index 9e8d6b332c..fa548ce94e 100644 --- a/esphome/components/sgp30/sgp30.cpp +++ b/esphome/components/sgp30/sgp30.cpp @@ -75,7 +75,7 @@ void SGP30Component::setup() { // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time() + std::to_string(this->serial_number_)); + uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(this->serial_number_)); this->pref_ = global_preferences->make_preference(hash, true); if (this->store_baseline_ && this->pref_.load(&this->baselines_storage_)) { diff --git a/esphome/components/sgp40/sensor.py b/esphome/components/sgp40/sensor.py index ad9de6fe24..b16151ec1f 100644 --- a/esphome/components/sgp40/sensor.py +++ b/esphome/components/sgp40/sensor.py @@ -4,5 +4,5 @@ CODEOWNERS = ["@SenexCrenshaw"] CONFIG_SCHEMA = cv.invalid( "SGP40 is deprecated.\nPlease use the SGP4x platform instead.\nSGP4x supports both SPG40 and SGP41.\n" - " See https://esphome.io/components/sensor/sgp4x.html" + " See https://esphome.io/components/sensor/sgp4x/" ) diff --git a/esphome/components/sgp4x/sgp4x.cpp b/esphome/components/sgp4x/sgp4x.cpp index 99d88006f7..a0c957d608 100644 --- a/esphome/components/sgp4x/sgp4x.cpp +++ b/esphome/components/sgp4x/sgp4x.cpp @@ -59,7 +59,7 @@ void SGP4xComponent::setup() { // Hash with compilation time and serial number // This ensures the baseline storage is cleared after OTA // Serial numbers are unique to each sensor, so mulitple sensors can be used without conflict - uint32_t hash = fnv1_hash(App.get_compilation_time() + std::to_string(this->serial_number_)); + uint32_t hash = fnv1_hash(App.get_compilation_time_ref() + std::to_string(this->serial_number_)); this->pref_ = global_preferences->make_preference(hash, true); if (this->pref_.load(&this->voc_baselines_storage_)) { diff --git a/esphome/components/sha256/__init__.py b/esphome/components/sha256/__init__.py index f07157416d..5db0e77b76 100644 --- a/esphome/components/sha256/__init__.py +++ b/esphome/components/sha256/__init__.py @@ -12,6 +12,8 @@ CONFIG_SCHEMA = cv.Schema({}) async def to_code(config: ConfigType) -> None: + cg.add_define("USE_SHA256") + # Add OpenSSL library for host platform if not CORE.is_host: return diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index e0d93d8e2f..5538206058 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -14,13 +14,36 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" +#ifdef USE_ESP8266 +#include // For esp_schedule() +#endif + namespace esphome { namespace socket { +#ifdef USE_ESP8266 +// Flag to signal socket activity - checked by socket_delay() to exit early +// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) +static volatile bool s_socket_woke = false; + +void socket_delay(uint32_t ms) { + // Use esp_delay with a callback that checks if socket data arrived. + // This allows the delay to exit early when socket_wake() is called by + // lwip recv_fn/accept_fn callbacks, reducing socket latency. + s_socket_woke = false; + esp_delay(ms, []() { return !s_socket_woke; }); +} + +void socket_wake() { + s_socket_woke = true; + esp_schedule(); +} +#endif + static const char *const TAG = "socket.lwip"; // set to 1 to enable verbose lwip logging -#if 0 +#if 0 // NOLINT(readability-avoid-unconditional-preprocessor-if) #define LWIP_LOG(msg, ...) ESP_LOGVV(TAG, "socket %p: " msg, this, ##__VA_ARGS__) #else #define LWIP_LOG(msg, ...) @@ -323,9 +346,10 @@ class LWIPRawImpl : public Socket { for (int i = 0; i < iovcnt; i++) { ssize_t err = read(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { - if (ret != 0) + if (ret != 0) { // if we already read some don't return an error break; + } return err; } ret += err; @@ -334,6 +358,12 @@ class LWIPRawImpl : public Socket { } return ret; } + + ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) override { + errno = ENOTSUP; + return -1; + } + ssize_t internal_write(const void *buf, size_t len) { if (pcb_ == nullptr) { errno = ECONNRESET; @@ -387,9 +417,10 @@ class LWIPRawImpl : public Socket { ssize_t written = internal_write(buf, len); if (written == -1) return -1; - if (written == 0) + if (written == 0) { // no need to output if nothing written return 0; + } if (nodelay_) { int err = internal_output(); if (err == -1) @@ -402,18 +433,20 @@ class LWIPRawImpl : public Socket { for (int i = 0; i < iovcnt; i++) { ssize_t err = internal_write(reinterpret_cast(iov[i].iov_base), iov[i].iov_len); if (err == -1) { - if (written != 0) + if (written != 0) { // if we already read some don't return an error break; + } return err; } written += err; if ((size_t) err != iov[i].iov_len) break; } - if (written == 0) + if (written == 0) { // no need to output if nothing written return 0; + } if (nodelay_) { int err = internal_output(); if (err == -1) @@ -467,6 +500,10 @@ class LWIPRawImpl : public Socket { } else { pbuf_cat(rx_buf_, pb); } +#ifdef USE_ESP8266 + // Wake the main loop immediately so it can process the received data. + socket_wake(); +#endif return ERR_OK; } @@ -606,7 +643,7 @@ class LWIPRawListenImpl : public LWIPRawImpl { } private: - err_t accept_fn(struct tcp_pcb *newpcb, err_t err) { + err_t accept_fn_(struct tcp_pcb *newpcb, err_t err) { LWIP_LOG("accept(newpcb=%p err=%d)", newpcb, err); if (err != ERR_OK || newpcb == nullptr) { // "An error code if there has been an error accepting. Only return ERR_ABRT if you have @@ -627,12 +664,16 @@ class LWIPRawListenImpl : public LWIPRawImpl { sock->init(); accepted_sockets_[accepted_socket_count_++] = std::move(sock); LWIP_LOG("Accepted connection, queue size: %d", accepted_socket_count_); +#ifdef USE_ESP8266 + // Wake the main loop immediately so it can accept the new connection. + socket_wake(); +#endif return ERR_OK; } static err_t s_accept_fn(void *arg, struct tcp_pcb *newpcb, err_t err) { LWIPRawListenImpl *arg_this = reinterpret_cast(arg); - return arg_this->accept_fn(newpcb, err); + return arg_this->accept_fn_(newpcb, err); } // Accept queue - holds incoming connections briefly until the event loop calls accept() diff --git a/esphome/components/socket/lwip_sockets_impl.cpp b/esphome/components/socket/lwip_sockets_impl.cpp index f8a1cbc046..d94c1fb2ff 100644 --- a/esphome/components/socket/lwip_sockets_impl.cpp +++ b/esphome/components/socket/lwip_sockets_impl.cpp @@ -113,6 +113,9 @@ class LwIPSocketImpl : public Socket { } int listen(int backlog) override { return lwip_listen(fd_, backlog); } ssize_t read(void *buf, size_t len) override { return lwip_read(fd_, buf, len); } + ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) override { + return lwip_recvfrom(fd_, buf, len, 0, addr, addr_len); + } ssize_t readv(const struct iovec *iov, int iovcnt) override { return lwip_readv(fd_, iov, iovcnt); } ssize_t write(const void *buf, size_t len) override { return lwip_write(fd_, buf, len); } ssize_t send(void *buf, size_t len, int flags) { return lwip_send(fd_, buf, len, flags); } diff --git a/esphome/components/socket/socket.h b/esphome/components/socket/socket.h index 8f0d28362e..8936b2cd10 100644 --- a/esphome/components/socket/socket.h +++ b/esphome/components/socket/socket.h @@ -39,9 +39,7 @@ class Socket { virtual int setsockopt(int level, int optname, const void *optval, socklen_t optlen) = 0; virtual int listen(int backlog) = 0; virtual ssize_t read(void *buf, size_t len) = 0; -#ifdef USE_SOCKET_IMPL_BSD_SOCKETS virtual ssize_t recvfrom(void *buf, size_t len, sockaddr *addr, socklen_t *addr_len) = 0; -#endif virtual ssize_t readv(const struct iovec *iov, int iovcnt) = 0; virtual ssize_t write(const void *buf, size_t len) = 0; virtual ssize_t writev(const struct iovec *iov, int iovcnt) = 0; @@ -84,6 +82,15 @@ socklen_t set_sockaddr(struct sockaddr *addr, socklen_t addrlen, const std::stri /// Set a sockaddr to the any address and specified port for the IP version used by socket_ip(). socklen_t set_sockaddr_any(struct sockaddr *addr, socklen_t addrlen, uint16_t port); +#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) +/// Delay that can be woken early by socket activity. +/// On ESP8266, lwip callbacks set a flag and call esp_schedule() to wake the delay. +void socket_delay(uint32_t ms); + +/// Called by lwip callbacks to signal socket activity and wake delay. +void socket_wake(); +#endif + } // namespace socket } // namespace esphome #endif diff --git a/esphome/components/spi/__init__.py b/esphome/components/spi/__init__.py index d803ee66dc..88bb3406e1 100644 --- a/esphome/components/spi/__init__.py +++ b/esphome/components/spi/__init__.py @@ -3,16 +3,18 @@ from typing import Any from esphome import pins import esphome.codegen as cg -from esphome.components.esp32 import only_on_variant -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( KEY_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + only_on_variant, ) from esphome.config_helpers import filter_source_files_from_platform import esphome.config_validation as cv @@ -128,7 +130,9 @@ def get_hw_interface_list(): if get_target_variant() in [ VARIANT_ESP32C2, VARIANT_ESP32C3, + VARIANT_ESP32C5, VARIANT_ESP32C6, + VARIANT_ESP32C61, VARIANT_ESP32H2, ]: return [["spi", "spi2"]] @@ -310,7 +314,7 @@ def spi_mode_schema(mode): if pin_count == 8: onlys.append( only_on_variant( - supported=[VARIANT_ESP32S3, VARIANT_ESP32S2, VARIANT_ESP32P4] + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3] ) ) return cv.All( diff --git a/esphome/components/sps30/automation.h b/esphome/components/sps30/automation.h index 67af813687..5eafc1b6c2 100644 --- a/esphome/components/sps30/automation.h +++ b/esphome/components/sps30/automation.h @@ -1,20 +1,25 @@ #pragma once -#include "esphome/core/component.h" #include "esphome/core/automation.h" +#include "esphome/core/helpers.h" #include "sps30.h" namespace esphome { namespace sps30 { -template class StartFanAction : public Action { +template class StartFanAction : public Action, public Parented { public: - explicit StartFanAction(SPS30Component *sps30) : sps30_(sps30) {} + void play(const Ts &...x) override { this->parent_->start_fan_cleaning(); } +}; - void play(const Ts &...x) override { this->sps30_->start_fan_cleaning(); } +template class StartMeasurementAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->start_measurement(); } +}; - protected: - SPS30Component *sps30_; +template class StopMeasurementAction : public Action, public Parented { + public: + void play(const Ts &...x) override { this->parent_->stop_measurement(); } }; } // namespace sps30 diff --git a/esphome/components/sps30/sensor.py b/esphome/components/sps30/sensor.py index d4f91b4188..3c967fc01b 100644 --- a/esphome/components/sps30/sensor.py +++ b/esphome/components/sps30/sensor.py @@ -38,8 +38,11 @@ SPS30Component = sps30_ns.class_( # Actions StartFanAction = sps30_ns.class_("StartFanAction", automation.Action) +StartMeasurementAction = sps30_ns.class_("StartMeasurementAction", automation.Action) +StopMeasurementAction = sps30_ns.class_("StopMeasurementAction", automation.Action) CONF_AUTO_CLEANING_INTERVAL = "auto_cleaning_interval" +CONF_IDLE_INTERVAL = "idle_interval" CONFIG_SCHEMA = ( cv.Schema( @@ -109,6 +112,7 @@ CONFIG_SCHEMA = ( state_class=STATE_CLASS_MEASUREMENT, ), cv.Optional(CONF_AUTO_CLEANING_INTERVAL): cv.update_interval, + cv.Optional(CONF_IDLE_INTERVAL): cv.update_interval, } ) .extend(cv.polling_component_schema("60s")) @@ -164,6 +168,9 @@ async def to_code(config): if CONF_AUTO_CLEANING_INTERVAL in config: cg.add(var.set_auto_cleaning_interval(config[CONF_AUTO_CLEANING_INTERVAL])) + if CONF_IDLE_INTERVAL in config: + cg.add(var.set_idle_interval(config[CONF_IDLE_INTERVAL])) + SPS30_ACTION_SCHEMA = maybe_simple_id( { @@ -175,6 +182,13 @@ SPS30_ACTION_SCHEMA = maybe_simple_id( @automation.register_action( "sps30.start_fan_autoclean", StartFanAction, SPS30_ACTION_SCHEMA ) -async def sps30_fan_to_code(config, action_id, template_arg, args): - paren = await cg.get_variable(config[CONF_ID]) - return cg.new_Pvariable(action_id, template_arg, paren) +@automation.register_action( + "sps30.start_measurement", StartMeasurementAction, SPS30_ACTION_SCHEMA +) +@automation.register_action( + "sps30.stop_measurement", StopMeasurementAction, SPS30_ACTION_SCHEMA +) +async def sps30_action_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/sps30/sps30.cpp b/esphome/components/sps30/sps30.cpp index 21a782e49a..dbb44743d2 100644 --- a/esphome/components/sps30/sps30.cpp +++ b/esphome/components/sps30/sps30.cpp @@ -20,6 +20,7 @@ static const uint16_t SPS30_CMD_START_FAN_CLEANING = 0x5607; static const uint16_t SPS30_CMD_SOFT_RESET = 0xD304; static const size_t SERIAL_NUMBER_LENGTH = 8; static const uint8_t MAX_SKIPPED_DATA_CYCLES_BEFORE_ERROR = 5; +static const uint32_t SPS30_WARM_UP_SEC = 30; void SPS30Component::setup() { this->write_command(SPS30_CMD_SOFT_RESET); @@ -63,6 +64,8 @@ void SPS30Component::setup() { this->status_clear_warning(); this->skipped_data_read_cycles_ = 0; this->start_continuous_measurement_(); + this->next_state_ms_ = millis() + SPS30_WARM_UP_SEC * 1000; + this->next_state_ = READ; this->setup_complete_ = true; }); }); @@ -101,6 +104,9 @@ void SPS30Component::dump_config() { " Serial number: %s\n" " Firmware version v%0d.%0d", this->serial_number_, this->raw_firmware_version_ >> 8, this->raw_firmware_version_ & 0xFF); + if (this->idle_interval_.has_value()) { + ESP_LOGCONFIG(TAG, " Idle interval: %us", this->idle_interval_.value() / 1000); + } LOG_SENSOR(" ", "PM1.0 Weight Concentration", this->pm_1_0_sensor_); LOG_SENSOR(" ", "PM2.5 Weight Concentration", this->pm_2_5_sensor_); LOG_SENSOR(" ", "PM4 Weight Concentration", this->pm_4_0_sensor_); @@ -132,6 +138,26 @@ void SPS30Component::update() { } return; } + + // If its not time to take an action, do nothing. + const uint32_t update_start_ms = millis(); + if (this->next_state_ != NONE && (int32_t) (this->next_state_ms_ - update_start_ms) > 0) { + ESP_LOGD(TAG, "Sensor waiting for %ums before transitioning to state %d.", (this->next_state_ms_ - update_start_ms), + this->next_state_); + return; + } + + switch (this->next_state_) { + case WAKE: + this->start_measurement(); + return; + case NONE: + return; + case READ: + // Read logic continues below + break; + } + /// Check if measurement is ready before reading the value if (!this->write_command(SPS30_CMD_GET_DATA_READY_STATUS)) { this->status_set_warning(); @@ -211,6 +237,16 @@ void SPS30Component::update() { this->status_clear_warning(); this->skipped_data_read_cycles_ = 0; + + // Stop measurements and wait if we have an idle interval. If not using idle mode, let the next state just execute + // on next update. + if (this->idle_interval_.has_value()) { + this->stop_measurement(); + this->next_state_ms_ = millis() + this->idle_interval_.value(); + this->next_state_ = WAKE; + } else { + this->next_state_ms_ = millis(); + } }); } @@ -219,6 +255,26 @@ bool SPS30Component::start_continuous_measurement_() { ESP_LOGE(TAG, "Error initiating measurements"); return false; } + ESP_LOGD(TAG, "Started measurements"); + + // Notify the state machine to wait the warm up interval before reading + this->next_state_ms_ = millis() + SPS30_WARM_UP_SEC * 1000; + this->next_state_ = READ; + return true; +} + +bool SPS30Component::start_measurement() { return start_continuous_measurement_(); } + +bool SPS30Component::stop_measurement() { + if (!write_command(SPS30_CMD_STOP_MEASUREMENTS)) { + ESP_LOGE(TAG, "Error stopping measurements"); + return false; + } else { + ESP_LOGD(TAG, "Stopped measurements"); + // Exit the state machine if measurement is stopped. + this->next_state_ms_ = 0; + this->next_state_ = NONE; + } return true; } diff --git a/esphome/components/sps30/sps30.h b/esphome/components/sps30/sps30.h index 18847e16d9..4e9b90ba7e 100644 --- a/esphome/components/sps30/sps30.h +++ b/esphome/components/sps30/sps30.h @@ -23,17 +23,23 @@ class SPS30Component : public PollingComponent, public sensirion_common::Sensiri void set_pm_size_sensor(sensor::Sensor *pm_size) { pm_size_sensor_ = pm_size; } void set_auto_cleaning_interval(uint32_t auto_cleaning_interval) { fan_interval_ = auto_cleaning_interval; } + void set_idle_interval(uint32_t idle_interval) { idle_interval_ = idle_interval; } void setup() override; void update() override; void dump_config() override; bool start_fan_cleaning(); + bool stop_measurement(); + bool start_measurement(); protected: bool setup_complete_{false}; uint16_t raw_firmware_version_; char serial_number_[17] = {0}; /// Terminating NULL character uint8_t skipped_data_read_cycles_ = 0; + uint32_t next_state_ms_ = 0; + + enum NextState : uint8_t { WAKE, READ, NONE } next_state_{NONE}; bool start_continuous_measurement_(); @@ -58,6 +64,7 @@ class SPS30Component : public PollingComponent, public sensirion_common::Sensiri sensor::Sensor *pmc_10_0_sensor_{nullptr}; sensor::Sensor *pm_size_sensor_{nullptr}; optional fan_interval_; + optional idle_interval_; }; } // namespace sps30 diff --git a/esphome/components/st7701s/display.py b/esphome/components/st7701s/display.py index 497740b8d2..6e4bff6431 100644 --- a/esphome/components/st7701s/display.py +++ b/esphome/components/st7701s/display.py @@ -1,7 +1,7 @@ from esphome import pins import esphome.codegen as cg from esphome.components import display, spi -from esphome.components.esp32 import const, only_on_variant +from esphome.components.esp32 import VARIANT_ESP32S3, only_on_variant from esphome.components.mipi import ( CONF_DE_PIN, CONF_HSYNC_BACK_PORCH, @@ -161,7 +161,7 @@ CONFIG_SCHEMA = cv.All( } ).extend(spi.spi_device_schema(cs_pin_required=False, default_data_rate=1e6)) ), - only_on_variant(supported=[const.VARIANT_ESP32S3]), + only_on_variant(supported=[VARIANT_ESP32S3]), cv.only_with_esp_idf, ) diff --git a/esphome/components/sx126x/__init__.py b/esphome/components/sx126x/__init__.py index 1eb83b7a33..4641db6483 100644 --- a/esphome/components/sx126x/__init__.py +++ b/esphome/components/sx126x/__init__.py @@ -1,6 +1,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv from esphome.const import CONF_BUSY_PIN, CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID, TimePeriod @@ -14,7 +15,6 @@ CONF_SX126X_ID = "sx126x_id" CONF_BANDWIDTH = "bandwidth" CONF_BITRATE = "bitrate" CONF_CODING_RATE = "coding_rate" -CONF_CRC_ENABLE = "crc_enable" CONF_CRC_INVERTED = "crc_inverted" CONF_CRC_SIZE = "crc_size" CONF_CRC_POLYNOMIAL = "crc_polynomial" @@ -23,7 +23,6 @@ CONF_DEVIATION = "deviation" CONF_DIO1_PIN = "dio1_pin" CONF_HW_VERSION = "hw_version" CONF_MODULATION = "modulation" -CONF_ON_PACKET = "on_packet" CONF_PA_POWER = "pa_power" CONF_PA_RAMP = "pa_ramp" CONF_PAYLOAD_LENGTH = "payload_length" diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp index 2cfc4b700e..59d80bd297 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.cpp +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.cpp @@ -12,12 +12,6 @@ void SX126xTransport::setup() { this->parent_->register_listener(this); } -void SX126xTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = true; -} - void SX126xTransport::send_packet(const std::vector &buf) const { this->parent_->transmit_packet(buf); } void SX126xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } diff --git a/esphome/components/sx126x/packet_transport/sx126x_transport.h b/esphome/components/sx126x/packet_transport/sx126x_transport.h index 755d30417d..640c6a76f9 100644 --- a/esphome/components/sx126x/packet_transport/sx126x_transport.h +++ b/esphome/components/sx126x/packet_transport/sx126x_transport.h @@ -11,7 +11,6 @@ namespace sx126x { class SX126xTransport : public packet_transport::PacketTransport, public Parented, public SX126xListener { public: void setup() override; - void update() override; void on_packet(const std::vector &packet, float rssi, float snr) override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/sx127x/__init__.py b/esphome/components/sx127x/__init__.py index 77cb61f7f8..b569a75972 100644 --- a/esphome/components/sx127x/__init__.py +++ b/esphome/components/sx127x/__init__.py @@ -1,6 +1,7 @@ from esphome import automation, pins import esphome.codegen as cg from esphome.components import spi +from esphome.components.const import CONF_CRC_ENABLE, CONF_ON_PACKET import esphome.config_validation as cv from esphome.const import CONF_DATA, CONF_FREQUENCY, CONF_ID from esphome.core import ID @@ -16,11 +17,9 @@ CONF_BANDWIDTH = "bandwidth" CONF_BITRATE = "bitrate" CONF_BITSYNC = "bitsync" CONF_CODING_RATE = "coding_rate" -CONF_CRC_ENABLE = "crc_enable" CONF_DEVIATION = "deviation" CONF_DIO0_PIN = "dio0_pin" CONF_MODULATION = "modulation" -CONF_ON_PACKET = "on_packet" CONF_PA_PIN = "pa_pin" CONF_PA_POWER = "pa_power" CONF_PA_RAMP = "pa_ramp" diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp index b1d014bb96..893726e816 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.cpp +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.cpp @@ -12,12 +12,6 @@ void SX127xTransport::setup() { this->parent_->register_listener(this); } -void SX127xTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = true; -} - void SX127xTransport::send_packet(const std::vector &buf) const { this->parent_->transmit_packet(buf); } void SX127xTransport::on_packet(const std::vector &packet, float rssi, float snr) { this->process_(packet); } diff --git a/esphome/components/sx127x/packet_transport/sx127x_transport.h b/esphome/components/sx127x/packet_transport/sx127x_transport.h index e27b7f8d57..6208372971 100644 --- a/esphome/components/sx127x/packet_transport/sx127x_transport.h +++ b/esphome/components/sx127x/packet_transport/sx127x_transport.h @@ -11,7 +11,6 @@ namespace sx127x { class SX127xTransport : public packet_transport::PacketTransport, public Parented, public SX127xListener { public: void setup() override; - void update() override; void on_packet(const std::vector &packet, float rssi, float snr) override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } diff --git a/esphome/components/sx1509/sx1509.h b/esphome/components/sx1509/sx1509.h index 2afd0d0e4e..f98fc0a44f 100644 --- a/esphome/components/sx1509/sx1509.h +++ b/esphome/components/sx1509/sx1509.h @@ -40,7 +40,7 @@ class SX1509Component : public Component, void setup() override; void dump_config() override; - float get_setup_priority() const override { return setup_priority::HARDWARE; } + float get_setup_priority() const override { return setup_priority::IO; } void loop() override; uint16_t read_key_data(); 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 f025435261..50e43da8d5 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 @@ -6,8 +6,7 @@ #include "esphome/core/helpers.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { using namespace esphome::alarm_control_panel; @@ -286,5 +285,4 @@ void TemplateAlarmControlPanel::control(const AlarmControlPanelCall &call) { } } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ 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 80ce34b8ae..bdd3747372 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 @@ -14,8 +14,7 @@ #include "esphome/components/binary_sensor/binary_sensor.h" #endif -namespace esphome { -namespace template_ { +namespace esphome::template_ { #ifdef USE_BINARY_SENSOR enum BinarySensorFlags : uint16_t { @@ -169,5 +168,4 @@ class TemplateAlarmControlPanel final : public alarm_control_panel::AlarmControl void arm_(optional code, alarm_control_panel::AlarmControlPanelState state, uint32_t delay); }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.cpp b/esphome/components/template/binary_sensor/template_binary_sensor.cpp index 806aed49b1..b63121d2db 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.cpp +++ b/esphome/components/template/binary_sensor/template_binary_sensor.cpp @@ -1,8 +1,7 @@ #include "template_binary_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.binary_sensor"; @@ -23,5 +22,4 @@ void TemplateBinarySensor::loop() { void TemplateBinarySensor::dump_config() { LOG_BINARY_SENSOR("", "Template Binary Sensor", this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/binary_sensor/template_binary_sensor.h b/esphome/components/template/binary_sensor/template_binary_sensor.h index 0af709b097..c78a95e0e3 100644 --- a/esphome/components/template/binary_sensor/template_binary_sensor.h +++ b/esphome/components/template/binary_sensor/template_binary_sensor.h @@ -4,8 +4,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/binary_sensor/binary_sensor.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateBinarySensor final : public Component, public binary_sensor::BinarySensor { public: @@ -21,5 +20,4 @@ class TemplateBinarySensor final : public Component, public binary_sensor::Binar TemplateLambda f_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/button/template_button.h b/esphome/components/template/button/template_button.h index 5bda82c58f..f64a85eef0 100644 --- a/esphome/components/template/button/template_button.h +++ b/esphome/components/template/button/template_button.h @@ -2,8 +2,7 @@ #include "esphome/components/button/button.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateButton final : public button::Button { public: @@ -11,5 +10,4 @@ class TemplateButton final : public button::Button { void press_action() override{}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/cover/template_cover.cpp b/esphome/components/template/cover/template_cover.cpp index a87f28ccec..9c8a8fc9bc 100644 --- a/esphome/components/template/cover/template_cover.cpp +++ b/esphome/components/template/cover/template_cover.cpp @@ -1,8 +1,7 @@ #include "template_cover.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { using namespace esphome::cover; @@ -133,5 +132,4 @@ void TemplateCover::stop_prev_trigger_() { } } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/cover/template_cover.h b/esphome/components/template/cover/template_cover.h index 125c67bb86..9c4a787283 100644 --- a/esphome/components/template/cover/template_cover.h +++ b/esphome/components/template/cover/template_cover.h @@ -5,8 +5,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/cover/cover.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { enum TemplateCoverRestoreMode { COVER_NO_RESTORE, @@ -63,5 +62,4 @@ class TemplateCover final : public cover::Cover, public Component { bool has_tilt_{false}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/datetime/template_date.cpp b/esphome/components/template/datetime/template_date.cpp index 3f6626e847..303d5ae2b0 100644 --- a/esphome/components/template/datetime/template_date.cpp +++ b/esphome/components/template/datetime/template_date.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.date"; @@ -104,7 +103,6 @@ void TemplateDate::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_DATE diff --git a/esphome/components/template/datetime/template_date.h b/esphome/components/template/datetime/template_date.h index fe64b0ba14..0379a9bc67 100644 --- a/esphome/components/template/datetime/template_date.h +++ b/esphome/components/template/datetime/template_date.h @@ -11,8 +11,7 @@ #include "esphome/core/time.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateDate final : public datetime::DateEntity, public PollingComponent { public: @@ -41,7 +40,6 @@ class TemplateDate final : public datetime::DateEntity, public PollingComponent ESPPreferenceObject pref_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_DATE diff --git a/esphome/components/template/datetime/template_datetime.cpp b/esphome/components/template/datetime/template_datetime.cpp index 62f842a7ad..81a823f53e 100644 --- a/esphome/components/template/datetime/template_datetime.cpp +++ b/esphome/components/template/datetime/template_datetime.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.datetime"; @@ -143,7 +142,6 @@ void TemplateDateTime::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_DATETIME diff --git a/esphome/components/template/datetime/template_datetime.h b/esphome/components/template/datetime/template_datetime.h index c44bd85265..b7eb490933 100644 --- a/esphome/components/template/datetime/template_datetime.h +++ b/esphome/components/template/datetime/template_datetime.h @@ -11,8 +11,7 @@ #include "esphome/core/time.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateDateTime final : public datetime::DateTimeEntity, public PollingComponent { public: @@ -41,7 +40,6 @@ class TemplateDateTime final : public datetime::DateTimeEntity, public PollingCo ESPPreferenceObject pref_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_DATETIME diff --git a/esphome/components/template/datetime/template_time.cpp b/esphome/components/template/datetime/template_time.cpp index dab28d01cc..21f843dcc7 100644 --- a/esphome/components/template/datetime/template_time.cpp +++ b/esphome/components/template/datetime/template_time.cpp @@ -4,8 +4,7 @@ #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.time"; @@ -104,7 +103,6 @@ void TemplateTime::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_TIME diff --git a/esphome/components/template/datetime/template_time.h b/esphome/components/template/datetime/template_time.h index 0c95330d27..cb83b1b3e5 100644 --- a/esphome/components/template/datetime/template_time.h +++ b/esphome/components/template/datetime/template_time.h @@ -11,8 +11,7 @@ #include "esphome/core/time.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateTime final : public datetime::TimeEntity, public PollingComponent { public: @@ -41,7 +40,6 @@ class TemplateTime final : public datetime::TimeEntity, public PollingComponent ESPPreferenceObject pref_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ #endif // USE_DATETIME_TIME diff --git a/esphome/components/template/event/template_event.h b/esphome/components/template/event/template_event.h index 5467a64141..fe83dc9f34 100644 --- a/esphome/components/template/event/template_event.h +++ b/esphome/components/template/event/template_event.h @@ -3,10 +3,8 @@ #include "esphome/core/component.h" #include "esphome/components/event/event.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateEvent final : public Component, public event::Event {}; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/fan/template_fan.cpp b/esphome/components/template/fan/template_fan.cpp index eba4c673b5..384e6b0ca1 100644 --- a/esphome/components/template/fan/template_fan.cpp +++ b/esphome/components/template/fan/template_fan.cpp @@ -1,8 +1,7 @@ #include "template_fan.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.fan"; @@ -34,5 +33,4 @@ void TemplateFan::control(const fan::FanCall &call) { this->publish_state(); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/fan/template_fan.h b/esphome/components/template/fan/template_fan.h index 052b385b93..b7e1d4ab5a 100644 --- a/esphome/components/template/fan/template_fan.h +++ b/esphome/components/template/fan/template_fan.h @@ -3,8 +3,7 @@ #include "esphome/core/component.h" #include "esphome/components/fan/fan.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateFan final : public Component, public fan::Fan { public: @@ -27,5 +26,4 @@ class TemplateFan final : public Component, public fan::Fan { std::vector preset_modes_{}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/lock/automation.h b/esphome/components/template/lock/automation.h index bd110b7b0c..42a2a826e2 100644 --- a/esphome/components/template/lock/automation.h +++ b/esphome/components/template/lock/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { template class TemplateLockPublishAction : public Action, public Parented { public: @@ -14,5 +13,4 @@ template class TemplateLockPublishAction : public Action, void play(const Ts &...x) override { this->parent_->publish_state(this->state_.value(x...)); } }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/lock/template_lock.cpp b/esphome/components/template/lock/template_lock.cpp index 8ed87b9736..de8f9b762c 100644 --- a/esphome/components/template/lock/template_lock.cpp +++ b/esphome/components/template/lock/template_lock.cpp @@ -1,8 +1,7 @@ #include "template_lock.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { using namespace esphome::lock; @@ -56,5 +55,4 @@ void TemplateLock::dump_config() { ESP_LOGCONFIG(TAG, " Optimistic: %s", YESNO(this->optimistic_)); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/lock/template_lock.h b/esphome/components/template/lock/template_lock.h index ac10794e4d..f4396c2c5d 100644 --- a/esphome/components/template/lock/template_lock.h +++ b/esphome/components/template/lock/template_lock.h @@ -5,8 +5,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/lock/lock.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateLock final : public lock::Lock, public Component { public: @@ -36,5 +35,4 @@ class TemplateLock final : public lock::Lock, public Component { Trigger<> *prev_trigger_{nullptr}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/number/template_number.cpp b/esphome/components/template/number/template_number.cpp index 145a89a2f7..76fef82225 100644 --- a/esphome/components/template/number/template_number.cpp +++ b/esphome/components/template/number/template_number.cpp @@ -1,8 +1,7 @@ #include "template_number.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.number"; @@ -51,5 +50,4 @@ void TemplateNumber::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/number/template_number.h b/esphome/components/template/number/template_number.h index 876ec96b3b..42c27fc3ca 100644 --- a/esphome/components/template/number/template_number.h +++ b/esphome/components/template/number/template_number.h @@ -6,8 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateNumber final : public number::Number, public PollingComponent { public: @@ -34,5 +33,4 @@ class TemplateNumber final : public number::Number, public PollingComponent { ESPPreferenceObject pref_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/output/template_output.h b/esphome/components/template/output/template_output.h index 9ecfc446b9..e536660b02 100644 --- a/esphome/components/template/output/template_output.h +++ b/esphome/components/template/output/template_output.h @@ -4,8 +4,7 @@ #include "esphome/components/output/binary_output.h" #include "esphome/components/output/float_output.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateBinaryOutput final : public output::BinaryOutput { public: @@ -27,5 +26,4 @@ class TemplateFloatOutput final : public output::FloatOutput { Trigger *trigger_ = new Trigger(); }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/select/template_select.cpp b/esphome/components/template/select/template_select.cpp index 112f24e919..9d2df0956b 100644 --- a/esphome/components/template/select/template_select.cpp +++ b/esphome/components/template/select/template_select.cpp @@ -1,8 +1,7 @@ #include "template_select.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.select"; @@ -63,5 +62,4 @@ void TemplateSelect::dump_config() { YESNO(this->optimistic_), this->option_at(this->initial_option_index_), YESNO(this->restore_value_)); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/select/template_select.h b/esphome/components/template/select/template_select.h index cb5b546976..2757c51405 100644 --- a/esphome/components/template/select/template_select.h +++ b/esphome/components/template/select/template_select.h @@ -6,8 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateSelect final : public select::Select, public PollingComponent { public: @@ -34,5 +33,4 @@ class TemplateSelect final : public select::Select, public PollingComponent { ESPPreferenceObject pref_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/sensor/template_sensor.cpp b/esphome/components/template/sensor/template_sensor.cpp index 1558ea9b15..313a163e38 100644 --- a/esphome/components/template/sensor/template_sensor.cpp +++ b/esphome/components/template/sensor/template_sensor.cpp @@ -2,8 +2,7 @@ #include "esphome/core/log.h" #include -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.sensor"; @@ -24,5 +23,4 @@ void TemplateSensor::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/sensor/template_sensor.h b/esphome/components/template/sensor/template_sensor.h index 3ca965dde3..825a2b4ffa 100644 --- a/esphome/components/template/sensor/template_sensor.h +++ b/esphome/components/template/sensor/template_sensor.h @@ -4,8 +4,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/sensor/sensor.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateSensor final : public sensor::Sensor, public PollingComponent { public: @@ -21,5 +20,4 @@ class TemplateSensor final : public sensor::Sensor, public PollingComponent { TemplateLambda f_; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/switch/template_switch.cpp b/esphome/components/template/switch/template_switch.cpp index 95e8692da5..cfa8798e75 100644 --- a/esphome/components/template/switch/template_switch.cpp +++ b/esphome/components/template/switch/template_switch.cpp @@ -1,8 +1,7 @@ #include "template_switch.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.switch"; @@ -57,5 +56,4 @@ void TemplateSwitch::dump_config() { } void TemplateSwitch::set_assumed_state(bool assumed_state) { this->assumed_state_ = assumed_state; } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/switch/template_switch.h b/esphome/components/template/switch/template_switch.h index 35c18af448..91b7b396f6 100644 --- a/esphome/components/template/switch/template_switch.h +++ b/esphome/components/template/switch/template_switch.h @@ -5,8 +5,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/switch/switch.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateSwitch final : public switch_::Switch, public Component { public: @@ -37,5 +36,4 @@ class TemplateSwitch final : public switch_::Switch, public Component { Trigger<> *prev_trigger_{nullptr}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/text/template_text.cpp b/esphome/components/template/text/template_text.cpp index a917c72a14..32ed8f047b 100644 --- a/esphome/components/template/text/template_text.cpp +++ b/esphome/components/template/text/template_text.cpp @@ -1,8 +1,7 @@ #include "template_text.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.text"; @@ -16,7 +15,7 @@ void TemplateText::setup() { uint32_t key = this->get_preference_hash(); key += this->traits.get_min_length() << 2; key += this->traits.get_max_length() << 4; - key += fnv1_hash(this->traits.get_pattern()) << 6; + key += fnv1_hash(this->traits.get_pattern_c_str()) << 6; this->pref_->setup(key, value); } if (!value.empty()) @@ -51,5 +50,4 @@ void TemplateText::dump_config() { LOG_UPDATE_INTERVAL(this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/text/template_text.h b/esphome/components/template/text/template_text.h index 1a0a66ed5b..178b410ed2 100644 --- a/esphome/components/template/text/template_text.h +++ b/esphome/components/template/text/template_text.h @@ -6,8 +6,7 @@ #include "esphome/core/preferences.h" #include "esphome/core/template_lambda.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { // We keep this separate so we don't have to template and duplicate // the text input for each different size flash allocation. @@ -84,5 +83,4 @@ class TemplateText final : public text::Text, public PollingComponent { TemplateTextSaverBase *pref_ = nullptr; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/text_sensor/template_text_sensor.cpp b/esphome/components/template/text_sensor/template_text_sensor.cpp index 024d0093a2..89a15b6081 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.cpp +++ b/esphome/components/template/text_sensor/template_text_sensor.cpp @@ -1,8 +1,7 @@ #include "template_text_sensor.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { static const char *const TAG = "template.text_sensor"; @@ -20,5 +19,4 @@ float TemplateTextSensor::get_setup_priority() const { return setup_priority::HA void TemplateTextSensor::dump_config() { LOG_TEXT_SENSOR("", "Template Sensor", this); } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/text_sensor/template_text_sensor.h b/esphome/components/template/text_sensor/template_text_sensor.h index da5c518c7f..0538a7ec21 100644 --- a/esphome/components/template/text_sensor/template_text_sensor.h +++ b/esphome/components/template/text_sensor/template_text_sensor.h @@ -5,8 +5,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/text_sensor/text_sensor.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { class TemplateTextSensor final : public text_sensor::TextSensor, public PollingComponent { public: @@ -22,5 +21,4 @@ class TemplateTextSensor final : public text_sensor::TextSensor, public PollingC TemplateLambda f_{}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/valve/automation.h b/esphome/components/template/valve/automation.h index e3f394ac7c..a27e98b25c 100644 --- a/esphome/components/template/valve/automation.h +++ b/esphome/components/template/valve/automation.h @@ -4,8 +4,7 @@ #include "esphome/core/automation.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { template class TemplateValvePublishAction : public Action, public Parented { TEMPLATABLE_VALUE(float, position) @@ -20,5 +19,4 @@ template class TemplateValvePublishAction : public Action } }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/valve/template_valve.cpp b/esphome/components/template/valve/template_valve.cpp index b91b32473e..4e772f9253 100644 --- a/esphome/components/template/valve/template_valve.cpp +++ b/esphome/components/template/valve/template_valve.cpp @@ -1,8 +1,7 @@ #include "template_valve.h" #include "esphome/core/log.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { using namespace esphome::valve; @@ -127,5 +126,4 @@ void TemplateValve::stop_prev_trigger_() { } } -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/template/valve/template_valve.h b/esphome/components/template/valve/template_valve.h index c452648193..4205682a2a 100644 --- a/esphome/components/template/valve/template_valve.h +++ b/esphome/components/template/valve/template_valve.h @@ -5,8 +5,7 @@ #include "esphome/core/template_lambda.h" #include "esphome/components/valve/valve.h" -namespace esphome { -namespace template_ { +namespace esphome::template_ { enum TemplateValveRestoreMode { VALVE_NO_RESTORE, @@ -57,5 +56,4 @@ class TemplateValve final : public valve::Valve, public Component { bool has_position_{false}; }; -} // namespace template_ -} // namespace esphome +} // namespace esphome::template_ diff --git a/esphome/components/text/text_traits.h b/esphome/components/text/text_traits.h index ceaba2dead..473daafb8e 100644 --- a/esphome/components/text/text_traits.h +++ b/esphome/components/text/text_traits.h @@ -1,8 +1,7 @@ #pragma once -#include +#include -#include "esphome/core/helpers.h" #include "esphome/core/string_ref.h" namespace esphome { @@ -22,8 +21,9 @@ class TextTraits { int get_max_length() const { return this->max_length_; } // Set/get the pattern. - void set_pattern(std::string pattern) { this->pattern_ = std::move(pattern); } - std::string get_pattern() const { return this->pattern_; } + void set_pattern(const char *pattern) { this->pattern_ = pattern; } + std::string get_pattern() const { return std::string(this->pattern_); } + const char *get_pattern_c_str() const { return this->pattern_; } StringRef get_pattern_ref() const { return StringRef(this->pattern_); } // Set/get the frontend mode. @@ -33,7 +33,7 @@ class TextTraits { protected: int min_length_; int max_length_; - std::string pattern_; + const char *pattern_{""}; TextMode mode_{TEXT_MODE_TEXT}; }; diff --git a/esphome/components/text_sensor/filter.cpp b/esphome/components/text_sensor/filter.cpp index 40a37febee..4cace372ae 100644 --- a/esphome/components/text_sensor/filter.cpp +++ b/esphome/components/text_sensor/filter.cpp @@ -56,10 +56,16 @@ optional ToLowerFilter::new_value(std::string value) { } // Append -optional AppendFilter::new_value(std::string value) { return value + this->suffix_; } +optional AppendFilter::new_value(std::string value) { + value.append(this->suffix_); + return value; +} // Prepend -optional PrependFilter::new_value(std::string value) { return this->prefix_ + value; } +optional PrependFilter::new_value(std::string value) { + value.insert(0, this->prefix_); + return value; +} // Substitute SubstituteFilter::SubstituteFilter(const std::initializer_list &substitutions) @@ -67,12 +73,15 @@ SubstituteFilter::SubstituteFilter(const std::initializer_list &su optional SubstituteFilter::new_value(std::string value) { for (const auto &sub : this->substitutions_) { + // Compute lengths once per substitution (strlen is fast, called infrequently) + const size_t from_len = strlen(sub.from); + const size_t to_len = strlen(sub.to); std::size_t pos = 0; - while ((pos = value.find(sub.from, pos)) != std::string::npos) { - value.replace(pos, sub.from.size(), sub.to); + while ((pos = value.find(sub.from, pos, from_len)) != std::string::npos) { + value.replace(pos, from_len, sub.to, to_len); // Advance past the replacement to avoid infinite loop when // the replacement contains the search pattern (e.g., f -> foo) - pos += sub.to.size(); + pos += to_len; } } return value; @@ -83,8 +92,10 @@ MapFilter::MapFilter(const std::initializer_list &mappings) : mapp optional MapFilter::new_value(std::string value) { for (const auto &mapping : this->mappings_) { - if (mapping.from == value) - return mapping.to; + if (value == mapping.from) { + value.assign(mapping.to); + return value; + } } return value; // Pass through if no match } diff --git a/esphome/components/text_sensor/filter.h b/esphome/components/text_sensor/filter.h index 85acac5c8d..0f66b753b4 100644 --- a/esphome/components/text_sensor/filter.h +++ b/esphome/components/text_sensor/filter.h @@ -92,26 +92,26 @@ class ToLowerFilter : public Filter { /// A simple filter that adds a string to the end of another string class AppendFilter : public Filter { public: - AppendFilter(std::string suffix) : suffix_(std::move(suffix)) {} + explicit AppendFilter(const char *suffix) : suffix_(suffix) {} optional new_value(std::string value) override; protected: - std::string suffix_; + const char *suffix_; }; /// A simple filter that adds a string to the start of another string class PrependFilter : public Filter { public: - PrependFilter(std::string prefix) : prefix_(std::move(prefix)) {} + explicit PrependFilter(const char *prefix) : prefix_(prefix) {} optional new_value(std::string value) override; protected: - std::string prefix_; + const char *prefix_; }; struct Substitution { - std::string from; - std::string to; + const char *from; + const char *to; }; /// A simple filter that replaces a substring with another substring diff --git a/esphome/components/text_sensor/text_sensor.h b/esphome/components/text_sensor/text_sensor.h index fbd02c6688..6410cbd961 100644 --- a/esphome/components/text_sensor/text_sensor.h +++ b/esphome/components/text_sensor/text_sensor.h @@ -24,7 +24,17 @@ void log_text_sensor(const char *tag, const char *prefix, const char *type, Text class TextSensor : public EntityBase, public EntityBase_DeviceClass { public: + 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; + TextSensor() = default; + ~TextSensor() = default; +#pragma GCC diagnostic pop /// Getter-syntax for .state. std::string get_state() const; @@ -49,15 +59,6 @@ class TextSensor : public EntityBase, public EntityBase_DeviceClass { /// Add a callback that will be called every time the sensor sends a raw value. 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/tinyusb/__init__.py b/esphome/components/tinyusb/__init__.py index 72afc18387..90043e969c 100644 --- a/esphome/components/tinyusb/__init__.py +++ b/esphome/components/tinyusb/__init__.py @@ -1,10 +1,11 @@ import esphome.codegen as cg from esphome.components import esp32 -from esphome.components.esp32 import add_idf_component, add_idf_sdkconfig_option -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3, + add_idf_component, + add_idf_sdkconfig_option, ) import esphome.config_validation as cv from esphome.const import CONF_ID diff --git a/esphome/components/tinyusb/tinyusb_component.cpp b/esphome/components/tinyusb/tinyusb_component.cpp index a2057c90ce..19bb545c4b 100644 --- a/esphome/components/tinyusb/tinyusb_component.cpp +++ b/esphome/components/tinyusb/tinyusb_component.cpp @@ -41,4 +41,4 @@ void TinyUSB::dump_config() { } } // namespace esphome::tinyusb -#endif +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/tinyusb/tinyusb_component.h b/esphome/components/tinyusb/tinyusb_component.h index 56c286f455..7d8caade74 100644 --- a/esphome/components/tinyusb/tinyusb_component.h +++ b/esphome/components/tinyusb/tinyusb_component.h @@ -69,4 +69,4 @@ class TinyUSB : public Component { }; } // namespace esphome::tinyusb -#endif +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/uart/packet_transport/uart_transport.cpp b/esphome/components/uart/packet_transport/uart_transport.cpp index 4a9aa0fe47..6b8eae611c 100644 --- a/esphome/components/uart/packet_transport/uart_transport.cpp +++ b/esphome/components/uart/packet_transport/uart_transport.cpp @@ -55,12 +55,6 @@ void UARTTransport::loop() { } } -void UARTTransport::update() { - this->updated_ = true; - this->resend_data_ = true; - PacketTransport::update(); -} - /** * Write a byte to the UART bus. If the byte is a flag or control byte, it will be escaped. * @param byte The byte to write. diff --git a/esphome/components/uart/packet_transport/uart_transport.h b/esphome/components/uart/packet_transport/uart_transport.h index e84bed95e6..1c92af536e 100644 --- a/esphome/components/uart/packet_transport/uart_transport.h +++ b/esphome/components/uart/packet_transport/uart_transport.h @@ -23,7 +23,6 @@ static const uint8_t CONTROL_BYTE = 0x7D; class UARTTransport : public packet_transport::PacketTransport, public UARTDevice { public: void loop() override; - void update() override; float get_setup_priority() const override { return setup_priority::PROCESSOR; } protected: diff --git a/esphome/components/udp/packet_transport/udp_transport.cpp b/esphome/components/udp/packet_transport/udp_transport.cpp index b92e0d64df..f3e33573a5 100644 --- a/esphome/components/udp/packet_transport/udp_transport.cpp +++ b/esphome/components/udp/packet_transport/udp_transport.cpp @@ -8,29 +8,14 @@ namespace udp { static const char *const TAG = "udp_transport"; -bool UDPTransport::should_send() { return this->should_broadcast_ && network::is_connected(); } +bool UDPTransport::should_send() { return network::is_connected(); } void UDPTransport::setup() { PacketTransport::setup(); - this->should_broadcast_ = this->ping_pong_enable_; -#ifdef USE_SENSOR - this->should_broadcast_ |= !this->sensors_.empty(); -#endif -#ifdef USE_BINARY_SENSOR - this->should_broadcast_ |= !this->binary_sensors_.empty(); -#endif - if (this->should_broadcast_) - this->parent_->set_should_broadcast(); if (!this->providers_.empty() || this->is_encrypted_()) { this->parent_->add_listener([this](std::vector &buf) { this->process_(buf); }); } } -void UDPTransport::update() { - PacketTransport::update(); - this->updated_ = true; - this->resend_data_ = this->should_broadcast_; -} - void UDPTransport::send_packet(const std::vector &buf) const { this->parent_->send_packet(buf); } } // namespace udp } // namespace esphome diff --git a/esphome/components/udp/packet_transport/udp_transport.h b/esphome/components/udp/packet_transport/udp_transport.h index c87eb62780..8d01ae0909 100644 --- a/esphome/components/udp/packet_transport/udp_transport.h +++ b/esphome/components/udp/packet_transport/udp_transport.h @@ -12,14 +12,12 @@ namespace udp { class UDPTransport : public packet_transport::PacketTransport, public Parented { public: void setup() override; - void update() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } protected: void send_packet(const std::vector &buf) const override; bool should_send() override; - bool should_broadcast_{false}; size_t get_max_packet_size() override { return MAX_PACKET_SIZE; } }; diff --git a/esphome/components/usb_cdc_acm/__init__.py b/esphome/components/usb_cdc_acm/__init__.py new file mode 100644 index 0000000000..6693d8e75e --- /dev/null +++ b/esphome/components/usb_cdc_acm/__init__.py @@ -0,0 +1,76 @@ +import esphome.codegen as cg +from esphome.components import esp32, uart +from esphome.components.esp32 import ( + VARIANT_ESP32P4, + VARIANT_ESP32S2, + VARIANT_ESP32S3, + add_idf_sdkconfig_option, +) +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_RX_BUFFER_SIZE, CONF_TX_BUFFER_SIZE +from esphome.types import ConfigType + +CODEOWNERS = ["@kbx81"] +AUTO_LOAD = ["uart"] +DEPENDENCIES = ["tinyusb"] + +CONF_INTERFACES = "interfaces" + +usb_cdc_acm_ns = cg.esphome_ns.namespace("usb_cdc_acm") +USBCDCACMComponent = usb_cdc_acm_ns.class_("USBCDCACMComponent", cg.Component) +USBCDCACMInstance = usb_cdc_acm_ns.class_( + "USBCDCACMInstance", uart.UARTComponent, cg.Parented.template(USBCDCACMComponent) +) + + +# Schema for individual CDC ACM interface instances +INTERFACE_SCHEMA = cv.Schema( + { + cv.GenerateID(): cv.declare_id(USBCDCACMInstance), + } +) + +# Main component schema +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(USBCDCACMComponent), + cv.Optional(CONF_RX_BUFFER_SIZE, default=256): cv.All( + cv.validate_bytes, cv.uint16_t + ), + cv.Optional(CONF_TX_BUFFER_SIZE, default=256): cv.All( + cv.validate_bytes, cv.uint16_t + ), + cv.Optional(CONF_INTERFACES, default=[{}]): cv.All( + cv.ensure_list(INTERFACE_SCHEMA), + cv.Length(min=1, max=2), # At least 1, at most 2 interfaces + ), + } + ).extend(cv.COMPONENT_SCHEMA), + esp32.only_on_variant( + supported=[VARIANT_ESP32P4, VARIANT_ESP32S2, VARIANT_ESP32S3], + ), +) + + +async def to_code(config: ConfigType) -> None: + var = cg.new_Pvariable(config[CONF_ID]) + await cg.register_component(var, config) + + # Create and register interface instances + for interface_index, interface_conf in enumerate(config[CONF_INTERFACES]): + interface = cg.new_Pvariable(interface_conf[CONF_ID]) + await cg.register_parented(interface, var) + cg.add(interface.set_interface_number(interface_index)) + cg.add(var.add_interface(interface)) + + # Configure TinyUSB with the correct number of CDC interfaces + num_interfaces = len(config[CONF_INTERFACES]) + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_ENABLED", True) + add_idf_sdkconfig_option("CONFIG_TINYUSB_CDC_COUNT", num_interfaces) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_RX_BUFSIZE", config[CONF_RX_BUFFER_SIZE] + ) + add_idf_sdkconfig_option( + "CONFIG_TINYUSB_CDC_TX_BUFSIZE", config[CONF_TX_BUFFER_SIZE] + ) diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp new file mode 100644 index 0000000000..1cf614286f --- /dev/null +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.cpp @@ -0,0 +1,495 @@ +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) +#include "usb_cdc_acm.h" +#include "esphome/core/application.h" +#include "esphome/core/log.h" + +#include +#include "freertos/FreeRTOS.h" +#include "freertos/ringbuf.h" +#include "freertos/task.h" +#include "esp_log.h" + +#include "tusb.h" +#include "tusb_cdc_acm.h" + +namespace esphome::usb_cdc_acm { + +static const char *TAG = "usb_cdc_acm"; + +static constexpr size_t USB_TX_TASK_STACK_SIZE = 4096; +static constexpr size_t USB_TX_TASK_STACK_SIZE_VV = 8192; + +// Global component instance for managing USB device +USBCDCACMComponent *global_usb_cdc_component = nullptr; + +static USBCDCACMInstance *get_instance_by_itf(int itf) { + if (global_usb_cdc_component == nullptr) { + return nullptr; + } + return global_usb_cdc_component->get_interface_by_number(itf); +} + +static void tinyusb_cdc_rx_callback(int itf, cdcacm_event_t *event) { + USBCDCACMInstance *instance = get_instance_by_itf(itf); + if (instance == nullptr) { + ESP_LOGE(TAG, "RX callback: invalid interface %d", itf); + return; + } + + size_t rx_size = 0; + static uint8_t rx_buf[CONFIG_TINYUSB_CDC_RX_BUFSIZE] = {0}; + + // read from USB + esp_err_t ret = + tinyusb_cdcacm_read(static_cast(itf), rx_buf, CONFIG_TINYUSB_CDC_RX_BUFSIZE, &rx_size); + ESP_LOGV(TAG, "tinyusb_cdc_rx_callback itf=%d (size: %u)", itf, rx_size); + ESP_LOGVV(TAG, "rx_buf = %s", format_hex_pretty(rx_buf, rx_size).c_str()); + + if (ret == ESP_OK && rx_size > 0) { + RingbufHandle_t rx_ringbuf = instance->get_rx_ringbuf(); + if (rx_ringbuf != nullptr) { + BaseType_t send_res = xRingbufferSend(rx_ringbuf, rx_buf, rx_size, 0); + if (send_res != pdTRUE) { + ESP_LOGE(TAG, "USB RX itf=%d: buffer full, %u bytes lost", itf, rx_size); + } else { + ESP_LOGV(TAG, "USB RX itf=%d: queued %u bytes", itf, rx_size); + } + } + } +} + +static void tinyusb_cdc_line_state_changed_callback(int itf, cdcacm_event_t *event) { + USBCDCACMInstance *instance = get_instance_by_itf(itf); + if (instance == nullptr) { + ESP_LOGE(TAG, "Line state callback: invalid interface %d", itf); + return; + } + + int dtr = event->line_state_changed_data.dtr; + int rts = event->line_state_changed_data.rts; + ESP_LOGV(TAG, "Line state itf=%d: DTR=%d, RTS=%d", itf, dtr, rts); + + // Queue event for processing in main loop + instance->queue_line_state_event(dtr != 0, rts != 0); +} + +static void tinyusb_cdc_line_coding_changed_callback(int itf, cdcacm_event_t *event) { + USBCDCACMInstance *instance = get_instance_by_itf(itf); + if (instance == nullptr) { + ESP_LOGE(TAG, "Line coding callback: invalid interface %d", itf); + return; + } + + uint32_t bit_rate = event->line_coding_changed_data.p_line_coding->bit_rate; + uint8_t stop_bits = event->line_coding_changed_data.p_line_coding->stop_bits; + uint8_t parity = event->line_coding_changed_data.p_line_coding->parity; + uint8_t data_bits = event->line_coding_changed_data.p_line_coding->data_bits; + ESP_LOGV(TAG, "Line coding itf=%d: bit_rate=%" PRIu32 " stop_bits=%u parity=%u data_bits=%u", itf, bit_rate, + stop_bits, parity, data_bits); + + // Queue event for processing in main loop + instance->queue_line_coding_event(bit_rate, stop_bits, parity, data_bits); +} + +static esp_err_t ringbuf_read_bytes(RingbufHandle_t ring_buf, uint8_t *out_buf, size_t out_buf_sz, size_t *rx_data_size, + TickType_t xTicksToWait) { + size_t read_sz; + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, xTicksToWait, out_buf_sz)); + + if (buf == nullptr) { + return ESP_FAIL; + } + + memcpy(out_buf, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size = read_sz; + + // Buffer's data can be wrapped, in which case we should perform another read + buf = static_cast(xRingbufferReceiveUpTo(ring_buf, &read_sz, 0, out_buf_sz - *rx_data_size)); + if (buf != nullptr) { + memcpy(out_buf + *rx_data_size, buf, read_sz); + vRingbufferReturnItem(ring_buf, (void *) buf); + *rx_data_size += read_sz; + } + + return ESP_OK; +} + +//============================================================================== +// USBCDCACMInstance Implementation +//============================================================================== + +void USBCDCACMInstance::setup() { + this->usb_tx_ringbuf_ = xRingbufferCreate(CONFIG_TINYUSB_CDC_TX_BUFSIZE, RINGBUF_TYPE_BYTEBUF); + if (this->usb_tx_ringbuf_ == nullptr) { + ESP_LOGE(TAG, "USB TX buffer creation error for itf %d", this->itf_); + this->parent_->mark_failed(); + return; + } + + this->usb_rx_ringbuf_ = xRingbufferCreate(CONFIG_TINYUSB_CDC_RX_BUFSIZE, RINGBUF_TYPE_BYTEBUF); + if (this->usb_rx_ringbuf_ == nullptr) { + ESP_LOGE(TAG, "USB RX buffer creation error for itf %d", this->itf_); + this->parent_->mark_failed(); + return; + } + + // Configure this CDC interface + const tinyusb_config_cdcacm_t acm_cfg = { + .usb_dev = TINYUSB_USBDEV_0, + .cdc_port = this->itf_, + .callback_rx = &tinyusb_cdc_rx_callback, + .callback_rx_wanted_char = NULL, + .callback_line_state_changed = &tinyusb_cdc_line_state_changed_callback, + .callback_line_coding_changed = &tinyusb_cdc_line_coding_changed_callback, + }; + + esp_err_t result = tusb_cdc_acm_init(&acm_cfg); + if (result != ESP_OK) { + ESP_LOGE(TAG, "tusb_cdc_acm_init failed: %d", result); + this->parent_->mark_failed(); + return; + } + + // Use a larger stack size for (very) verbose logging + const size_t stack_size = esp_log_level_get(TAG) > ESP_LOG_DEBUG ? USB_TX_TASK_STACK_SIZE_VV : USB_TX_TASK_STACK_SIZE; + + // Create a simple, unique task name per interface + char task_name[] = "usb_tx_0"; + task_name[sizeof(task_name) - 1] = format_hex_char(static_cast(this->itf_)); + xTaskCreate(usb_tx_task_fn, task_name, stack_size, this, 4, &this->usb_tx_task_handle_); + + if (this->usb_tx_task_handle_ == nullptr) { + ESP_LOGE(TAG, "Failed to create USB TX task for itf %d", this->itf_); + this->parent_->mark_failed(); + return; + } +} + +void USBCDCACMInstance::loop() { + // Process events from the lock-free queue + this->process_events_(); +} + +void USBCDCACMInstance::queue_line_state_event(bool dtr, bool rts) { + // Allocate event from pool + CDCEvent *event = this->event_pool_.allocate(); + if (event == nullptr) { + ESP_LOGW(TAG, "Event pool exhausted, line state event dropped (itf=%d)", this->itf_); + return; + } + + event->type = CDC_EVENT_LINE_STATE_CHANGED; + event->data.line_state.dtr = dtr; + event->data.line_state.rts = rts; + + if (!this->event_queue_.push(event)) { + ESP_LOGW(TAG, "Event queue full, line state event dropped (itf=%d)", this->itf_); + // Return event to pool since we couldn't queue it + this->event_pool_.release(event); + } else { + // Wake main loop immediately to process event +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + App.wake_loop_threadsafe(); +#endif + } +} + +void USBCDCACMInstance::queue_line_coding_event(uint32_t bit_rate, uint8_t stop_bits, uint8_t parity, + uint8_t data_bits) { + // Allocate event from pool + CDCEvent *event = this->event_pool_.allocate(); + if (event == nullptr) { + ESP_LOGW(TAG, "Event pool exhausted, line coding event dropped (itf=%d)", this->itf_); + return; + } + + event->type = CDC_EVENT_LINE_CODING_CHANGED; + event->data.line_coding.bit_rate = bit_rate; + event->data.line_coding.stop_bits = stop_bits; + event->data.line_coding.parity = parity; + event->data.line_coding.data_bits = data_bits; + + if (!this->event_queue_.push(event)) { + ESP_LOGW(TAG, "Event queue full, line coding event dropped (itf=%d)", this->itf_); + // Return event to pool since we couldn't queue it + this->event_pool_.release(event); + } else { + // Wake main loop immediately to process event +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) + App.wake_loop_threadsafe(); +#endif + } +} + +void USBCDCACMInstance::process_events_() { + // Process all pending events from the queue + CDCEvent *event; + while ((event = this->event_queue_.pop()) != nullptr) { + switch (event->type) { + case CDC_EVENT_LINE_STATE_CHANGED: { + bool dtr = event->data.line_state.dtr; + bool rts = event->data.line_state.rts; + + // Invoke user callback in main loop context + if (this->line_state_callback_ != nullptr) { + this->line_state_callback_(dtr, rts); + } + break; + } + case CDC_EVENT_LINE_CODING_CHANGED: { + uint32_t bit_rate = event->data.line_coding.bit_rate; + uint8_t stop_bits = event->data.line_coding.stop_bits; + uint8_t parity = event->data.line_coding.parity; + uint8_t data_bits = event->data.line_coding.data_bits; + + // Update UART configuration based on CDC line coding + this->baud_rate_ = bit_rate; + this->data_bits_ = data_bits; + + // Convert CDC stop bits to UART stop bits format + // CDC: 0=1 stop bit, 1=1.5 stop bits, 2=2 stop bits + this->stop_bits_ = (stop_bits == 0) ? 1 : (stop_bits == 1) ? 1 : 2; + + // Convert CDC parity to UART parity format + // CDC: 0=None, 1=Odd, 2=Even, 3=Mark, 4=Space + switch (parity) { + case 0: + this->parity_ = uart::UART_CONFIG_PARITY_NONE; + break; + case 1: + this->parity_ = uart::UART_CONFIG_PARITY_ODD; + break; + case 2: + this->parity_ = uart::UART_CONFIG_PARITY_EVEN; + break; + default: + // Mark and Space parity are not commonly supported, default to None + this->parity_ = uart::UART_CONFIG_PARITY_NONE; + break; + } + + // Invoke user callback in main loop context + if (this->line_coding_callback_ != nullptr) { + this->line_coding_callback_(bit_rate, stop_bits, parity, data_bits); + } + break; + } + } + // Return event to pool for reuse + this->event_pool_.release(event); + } +} + +void USBCDCACMInstance::usb_tx_task_fn(void *arg) { + auto *instance = static_cast(arg); + instance->usb_tx_task(); +} + +void USBCDCACMInstance::usb_tx_task() { + uint8_t data[CONFIG_TINYUSB_CDC_TX_BUFSIZE] = {0}; + size_t tx_data_size = 0; + + while (1) { + // Wait for a notification from the bridge component + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + // When we do wake up, we can be sure there is data in the ring buffer + esp_err_t ret = ringbuf_read_bytes(this->usb_tx_ringbuf_, data, CONFIG_TINYUSB_CDC_TX_BUFSIZE, &tx_data_size, 0); + + if (ret != ESP_OK) { + ESP_LOGE(TAG, "USB TX itf=%d: RingBuf read failed", this->itf_); + continue; + } else if (tx_data_size == 0) { + ESP_LOGD(TAG, "USB TX itf=%d: RingBuf empty, skipping", this->itf_); + continue; + } + + ESP_LOGV(TAG, "USB TX itf=%d: Read %d bytes from buffer", this->itf_, tx_data_size); + ESP_LOGVV(TAG, "data = %s", format_hex_pretty(data, tx_data_size).c_str()); + + // Serial data will be split up into 64 byte chunks to be sent over USB so this + // usually will take multiple iterations + uint8_t *data_head = &data[0]; + + while (tx_data_size > 0) { + size_t queued = tinyusb_cdcacm_write_queue(this->itf_, data_head, tx_data_size); + ESP_LOGV(TAG, "USB TX itf=%d: enqueued: size=%d, queued=%u", this->itf_, tx_data_size, queued); + + tx_data_size -= queued; + data_head += queued; + + ESP_LOGV(TAG, "USB TX itf=%d: waiting 10ms for flush", this->itf_); + esp_err_t flush_ret = tinyusb_cdcacm_write_flush(this->itf_, pdMS_TO_TICKS(10)); + + if (flush_ret != ESP_OK) { + ESP_LOGE(TAG, "USB TX itf=%d: flush failed", this->itf_); + tud_cdc_n_write_clear(this->itf_); + break; + } + } + } +} + +//============================================================================== +// UARTComponent Interface Implementation +//============================================================================== + +void USBCDCACMInstance::write_array(const uint8_t *data, size_t len) { + if (len == 0) { + return; + } + + // Write data to TX ring buffer + BaseType_t send_res = xRingbufferSend(this->usb_tx_ringbuf_, data, len, 0); + if (send_res != pdTRUE) { + ESP_LOGW(TAG, "USB TX itf=%d: buffer full, %u bytes dropped", this->itf_, len); + return; + } + + // Notify TX task that data is available + if (this->usb_tx_task_handle_ != nullptr) { + xTaskNotifyGive(this->usb_tx_task_handle_); + } +} + +bool USBCDCACMInstance::peek_byte(uint8_t *data) { + if (this->has_peek_) { + *data = this->peek_buffer_; + return true; + } + + if (this->read_byte(&this->peek_buffer_)) { + *data = this->peek_buffer_; + this->has_peek_ = true; + return true; + } + + return false; +} + +bool USBCDCACMInstance::read_array(uint8_t *data, size_t len) { + if (len == 0) { + return true; + } + + size_t original_len = len; + size_t bytes_read = 0; + + // First, use the peek buffer if available + if (this->has_peek_) { + data[0] = this->peek_buffer_; + this->has_peek_ = false; + bytes_read = 1; + data++; + if (--len == 0) { // Decrement len first, then check it... + return true; // No more to read + } + } + + // Read remaining bytes from RX ring buffer + size_t rx_size = 0; + uint8_t *buf = static_cast(xRingbufferReceiveUpTo(this->usb_rx_ringbuf_, &rx_size, 0, len)); + if (buf == nullptr) { + return false; + } + + memcpy(data, buf, rx_size); + vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + bytes_read += rx_size; + data += rx_size; + len -= rx_size; + if (len == 0) { + return true; // No more to read + } + + // Buffer's data may wrap around, in which case we should perform another read + buf = static_cast(xRingbufferReceiveUpTo(this->usb_rx_ringbuf_, &rx_size, 0, len)); + if (buf == nullptr) { + return false; + } + + memcpy(data, buf, rx_size); + vRingbufferReturnItem(this->usb_rx_ringbuf_, (void *) buf); + bytes_read += rx_size; + + return bytes_read == original_len; +} + +int USBCDCACMInstance::available() { + UBaseType_t waiting = 0; + if (this->usb_rx_ringbuf_ != nullptr) { + vRingbufferGetInfo(this->usb_rx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); + } + return static_cast(waiting) + (this->has_peek_ ? 1 : 0); +} + +void USBCDCACMInstance::flush() { + // Wait for TX ring buffer to be empty + if (this->usb_tx_ringbuf_ == nullptr) { + return; + } + + UBaseType_t waiting = 1; + while (waiting > 0) { + vRingbufferGetInfo(this->usb_tx_ringbuf_, nullptr, nullptr, nullptr, nullptr, &waiting); + if (waiting > 0) { + vTaskDelay(pdMS_TO_TICKS(1)); + } + } + + // Also wait for USB to finish transmitting + tinyusb_cdcacm_write_flush(this->itf_, pdMS_TO_TICKS(100)); +} + +//============================================================================== +// USBCDCACMComponent Implementation +//============================================================================== + +USBCDCACMComponent::USBCDCACMComponent() { global_usb_cdc_component = this; } + +void USBCDCACMComponent::setup() { + // Setup all registered interfaces + for (auto interface : this->interfaces_) { + if (interface != nullptr) { + interface->setup(); + } + } +} + +void USBCDCACMComponent::loop() { + // Call loop() on all registered interfaces to process events + for (auto interface : this->interfaces_) { + if (interface != nullptr) { + interface->loop(); + } + } +} + +void USBCDCACMComponent::dump_config() { + ESP_LOGCONFIG(TAG, + "USB CDC-ACM:\n" + " Number of Interfaces: %d", + this->interfaces_[MAX_USB_CDC_INSTANCES - 1] != nullptr ? MAX_USB_CDC_INSTANCES : 1); +} + +void USBCDCACMComponent::add_interface(USBCDCACMInstance *interface) { + uint8_t itf_num = static_cast(interface->get_itf()); + if (itf_num < MAX_USB_CDC_INSTANCES) { + this->interfaces_[itf_num] = interface; + } else { + ESP_LOGE(TAG, "Interface number must be less than %u", MAX_USB_CDC_INSTANCES); + } +} + +USBCDCACMInstance *USBCDCACMComponent::get_interface_by_number(uint8_t itf) { + for (auto interface : this->interfaces_) { + if ((interface != nullptr) && (interface->get_itf() == static_cast(itf))) { + return interface; + } + } + return nullptr; +} + +} // namespace esphome::usb_cdc_acm +#endif diff --git a/esphome/components/usb_cdc_acm/usb_cdc_acm.h b/esphome/components/usb_cdc_acm/usb_cdc_acm.h new file mode 100644 index 0000000000..8c00f5d52f --- /dev/null +++ b/esphome/components/usb_cdc_acm/usb_cdc_acm.h @@ -0,0 +1,135 @@ +#pragma once +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) + +#include "esphome/core/component.h" +#include "esphome/core/event_pool.h" +#include "esphome/core/lock_free_queue.h" +#include "esphome/components/uart/uart_component.h" + +#include +#include "freertos/ringbuf.h" +#include "tusb_cdc_acm.h" + +namespace esphome::usb_cdc_acm { + +static const uint8_t EVENT_QUEUE_SIZE = 12; +static const uint8_t MAX_USB_CDC_INSTANCES = 2; + +// Callback types for line coding and line state changes +using LineCodingCallback = std::function; +using LineStateCallback = std::function; + +// Event types +enum CDCEventType : uint8_t { + CDC_EVENT_LINE_STATE_CHANGED, + CDC_EVENT_LINE_CODING_CHANGED, +}; + +// Event structure for the queue +struct CDCEvent { + CDCEventType type; + union { + struct { + bool dtr; + bool rts; + } line_state; + struct { + uint32_t bit_rate; + uint8_t stop_bits; + uint8_t parity; + uint8_t data_bits; + } line_coding; + } data; + + // Required by EventPool - called before returning to pool + void release() { + // No dynamic memory to clean up, data is stored inline + } +}; + +// Forward declaration +class USBCDCACMComponent; + +/// Represents a single CDC ACM interface instance +class USBCDCACMInstance : public uart::UARTComponent, public Parented { + public: + void set_interface_number(uint8_t itf) { this->itf_ = static_cast(itf); } + + void setup(); + void loop(); + + // Get the CDC port number for this instance + tinyusb_cdcacm_itf_t get_itf() const { return this->itf_; } + + // Ring buffer accessors for bridge components + RingbufHandle_t get_tx_ringbuf() const { return this->usb_tx_ringbuf_; } + RingbufHandle_t get_rx_ringbuf() const { return this->usb_rx_ringbuf_; } + + // Task handle accessor for notifying TX task + TaskHandle_t get_tx_task_handle() const { return this->usb_tx_task_handle_; } + + // Callback registration for line coding and line state changes + void set_line_coding_callback(LineCodingCallback callback) { this->line_coding_callback_ = std::move(callback); } + void set_line_state_callback(LineStateCallback callback) { this->line_state_callback_ = std::move(callback); } + + // Called from TinyUSB task context (SPSC producer) - queues event for processing in main loop + void queue_line_coding_event(uint32_t bit_rate, uint8_t stop_bits, uint8_t parity, uint8_t data_bits); + void queue_line_state_event(bool dtr, bool rts); + + static void usb_tx_task_fn(void *arg); + void usb_tx_task(); + + // UARTComponent interface implementation + void write_array(const uint8_t *data, size_t len) override; + bool peek_byte(uint8_t *data) override; + bool read_array(uint8_t *data, size_t len) override; + int available() override; + void flush() override; + + protected: + void check_logger_conflict() override {} + + // Process queued events and invoke callbacks (called from main loop) + void process_events_(); + + TaskHandle_t usb_tx_task_handle_{nullptr}; + tinyusb_cdcacm_itf_t itf_{TINYUSB_CDC_ACM_0}; + + RingbufHandle_t usb_tx_ringbuf_{nullptr}; + RingbufHandle_t usb_rx_ringbuf_{nullptr}; + + // User-registered callbacks (called from main loop) + LineCodingCallback line_coding_callback_{nullptr}; + LineStateCallback line_state_callback_{nullptr}; + + // Lock-free queue and event pool for cross-task event passing + EventPool event_pool_; + LockFreeQueue event_queue_; + + // RX buffer for peek functionality + uint8_t peek_buffer_{0}; + bool has_peek_{false}; +}; + +/// Main USB CDC ACM component that manages the USB device and all CDC interfaces +class USBCDCACMComponent : public Component { + public: + USBCDCACMComponent(); + + void setup() override; + void loop() override; + void dump_config() override; + float get_setup_priority() const override { return setup_priority::IO; } + + // Interface management + void add_interface(USBCDCACMInstance *interface); + USBCDCACMInstance *get_interface_by_number(uint8_t itf); + + protected: + std::array interfaces_{nullptr, nullptr}; +}; + +extern USBCDCACMComponent *global_usb_cdc_component; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + +} // namespace esphome::usb_cdc_acm +#endif diff --git a/esphome/components/usb_host/usb_host.h b/esphome/components/usb_host/usb_host.h index 31bdde2df8..d11a148a0f 100644 --- a/esphome/components/usb_host/usb_host.h +++ b/esphome/components/usb_host/usb_host.h @@ -1,7 +1,7 @@ #pragma once // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "esphome/core/defines.h" #include "esphome/core/component.h" #include @@ -188,4 +188,4 @@ class USBHost : public Component { } // namespace usb_host } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_host/usb_host_client.cpp b/esphome/components/usb_host/usb_host_client.cpp index fe61353b5d..664f49d137 100644 --- a/esphome/components/usb_host/usb_host_client.cpp +++ b/esphome/components/usb_host/usb_host_client.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "usb_host.h" #include "esphome/core/log.h" #include "esphome/core/hal.h" @@ -531,4 +531,4 @@ void USBClient::release_trq(TransferRequest *trq) { } // namespace usb_host } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_host/usb_host_component.cpp b/esphome/components/usb_host/usb_host_component.cpp index 1e70c289df..790fe6713b 100644 --- a/esphome/components/usb_host/usb_host_component.cpp +++ b/esphome/components/usb_host/usb_host_component.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "usb_host.h" #include #include "esphome/core/log.h" @@ -31,4 +31,4 @@ void USBHost::loop() { } // namespace usb_host } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/ch34x.cpp b/esphome/components/usb_uart/ch34x.cpp index 889366b579..caa4b65657 100644 --- a/esphome/components/usb_uart/ch34x.cpp +++ b/esphome/components/usb_uart/ch34x.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -78,4 +78,4 @@ void USBUartTypeCH34X::enable_channels() { } } // namespace usb_uart } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/cp210x.cpp b/esphome/components/usb_uart/cp210x.cpp index 5fec0bed02..be024d1ba2 100644 --- a/esphome/components/usb_uart/cp210x.cpp +++ b/esphome/components/usb_uart/cp210x.cpp @@ -1,4 +1,4 @@ -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "usb_uart.h" #include "usb/usb_host.h" #include "esphome/core/log.h" @@ -123,4 +123,4 @@ void USBUartTypeCP210X::enable_channels() { } } // namespace usb_uart } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/usb_uart.cpp b/esphome/components/usb_uart/usb_uart.cpp index fefccd3645..edd01c26c6 100644 --- a/esphome/components/usb_uart/usb_uart.cpp +++ b/esphome/components/usb_uart/usb_uart.cpp @@ -1,5 +1,5 @@ // Should not be needed, but it's required to pass CI clang-tidy checks -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "usb_uart.h" #include "esphome/core/log.h" #include "esphome/core/application.h" @@ -392,4 +392,4 @@ void USBUartTypeCdcAcm::enable_channels() { } // namespace usb_uart } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/usb_uart/usb_uart.h b/esphome/components/usb_uart/usb_uart.h index a5e7905ac5..96c17bd155 100644 --- a/esphome/components/usb_uart/usb_uart.h +++ b/esphome/components/usb_uart/usb_uart.h @@ -1,6 +1,6 @@ #pragma once -#if defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32P4) +#if defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S2) || defined(USE_ESP32_VARIANT_ESP32S3) #include "esphome/core/component.h" #include "esphome/core/helpers.h" #include "esphome/components/uart/uart_component.h" @@ -173,4 +173,4 @@ class USBUartTypeCH34X : public USBUartTypeCdcAcm { } // namespace usb_uart } // namespace esphome -#endif // USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 +#endif // USE_ESP32_VARIANT_ESP32P4 || USE_ESP32_VARIANT_ESP32S2 || USE_ESP32_VARIANT_ESP32S3 diff --git a/esphome/components/version/version_text_sensor.cpp b/esphome/components/version/version_text_sensor.cpp index 65dbfd27cf..78d0fb501b 100644 --- a/esphome/components/version/version_text_sensor.cpp +++ b/esphome/components/version/version_text_sensor.cpp @@ -13,7 +13,7 @@ void VersionTextSensor::setup() { if (this->hide_timestamp_) { this->publish_state(ESPHOME_VERSION); } else { - this->publish_state(str_sprintf(ESPHOME_VERSION " %s", App.get_compilation_time().c_str())); + this->publish_state(str_sprintf(ESPHOME_VERSION " %s", App.get_compilation_time_ref().c_str())); } } float VersionTextSensor::get_setup_priority() const { return setup_priority::DATA; } diff --git a/esphome/components/web_server/web_server.cpp b/esphome/components/web_server/web_server.cpp index 1f3605a082..0c22c2f08d 100644 --- a/esphome/components/web_server/web_server.cpp +++ b/esphome/components/web_server/web_server.cpp @@ -287,7 +287,7 @@ std::string WebServer::get_config_json() { JsonObject root = builder.root(); root[ESPHOME_F("title")] = App.get_friendly_name().empty() ? App.get_name() : App.get_friendly_name(); - root[ESPHOME_F("comment")] = App.get_comment(); + root[ESPHOME_F("comment")] = App.get_comment_ref(); #if defined(USE_WEBSERVER_OTA_DISABLED) || !defined(USE_WEBSERVER_OTA) root[ESPHOME_F("ota")] = false; // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal #else @@ -1211,7 +1211,7 @@ std::string WebServer::text_json(text::Text *obj, const std::string &value, Json set_json_icon_state_value(root, obj, "text", state, value, start_config); 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(); + root[ESPHOME_F("pattern")] = obj->traits.get_pattern_c_str(); if (start_config == DETAIL_ALL) { root[ESPHOME_F("mode")] = (int) obj->traits.get_mode(); this->add_sorting_info_(root, obj); diff --git a/esphome/components/web_server/web_server.h b/esphome/components/web_server/web_server.h index 52cf0bedea..bb69d57872 100644 --- a/esphome/components/web_server/web_server.h +++ b/esphome/components/web_server/web_server.h @@ -171,7 +171,7 @@ class DeferredUpdateEventSourceList : public std::listtraits.get_max_length()); stream.print(R"(" pattern=")"); - stream.print(text->traits.get_pattern().c_str()); + stream.print(text->traits.get_pattern_c_str()); stream.print(R"(" value=")"); stream.print(text->state.c_str()); stream.print(R"("/>)"); @@ -190,9 +190,8 @@ void WebServer::handle_index_request(AsyncWebServerRequest *request) { } #endif - stream->print( - ESPHOME_F("

See ESPHome Web API for " - "REST API documentation.

")); + stream->print(ESPHOME_F("

See ESPHome Web API for " + "REST API documentation.

")); #if defined(USE_WEBSERVER_OTA) && !defined(USE_WEBSERVER_OTA_DISABLED) // Show OTA form only if web_server OTA is not explicitly disabled // Note: USE_WEBSERVER_OTA_DISABLED only affects web_server, not captive_portal diff --git a/esphome/components/web_server_idf/web_server_idf.cpp b/esphome/components/web_server_idf/web_server_idf.cpp index af99b85e53..8c3ad288c0 100644 --- a/esphome/components/web_server_idf/web_server_idf.cpp +++ b/esphome/components/web_server_idf/web_server_idf.cpp @@ -117,18 +117,6 @@ 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(); @@ -136,8 +124,11 @@ 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_; + // Always enable LRU purging to handle socket exhaustion gracefully. + // When max sockets is reached, the oldest connection is closed to make room for new ones. + // This prevents "httpd_accept_conn: error in accept (23)" errors. + // See: https://github.com/esphome/esphome/issues/12464 + config.lru_purge_enable = true; // 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) { diff --git a/esphome/components/web_server_idf/web_server_idf.h b/esphome/components/web_server_idf/web_server_idf.h index a139e9e4df..5f9f598388 100644 --- a/esphome/components/web_server_idf/web_server_idf.h +++ b/esphome/components/web_server_idf/web_server_idf.h @@ -199,13 +199,11 @@ 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; diff --git a/esphome/components/wifi/wifi_component.cpp b/esphome/components/wifi/wifi_component.cpp index 317507f242..a5e8c4a59d 100644 --- a/esphome/components/wifi/wifi_component.cpp +++ b/esphome/components/wifi/wifi_component.cpp @@ -205,6 +205,21 @@ static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500; /// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000; +/// Timeout for WiFi scan operations +/// This is a fallback in case we don't receive a scan done callback from the WiFi driver. +/// Normal scans complete via callback; this only triggers if something goes wrong. +static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000; + +/// Timeout for WiFi connection attempts +/// This is a fallback in case we don't receive connection success/failure callbacks. +/// Some platforms (especially LibreTiny/Beken) can take 30-60 seconds to connect, +/// particularly with fast_connect enabled where no prior scan provides channel info. +/// Do not lower this value - connection failures are detected via callbacks, not timeout. +/// If this timeout fires prematurely while a connection is still in progress, it causes +/// cascading failures: the subsequent scan will also fail because the WiFi driver is +/// still busy with the previous connection attempt. +static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000; + static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) { switch (phase) { case WiFiRetryPhase::INITIAL_CONNECT: @@ -360,7 +375,7 @@ void WiFiComponent::start() { 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; + uint32_t hash = this->has_sta() ? fnv1_hash(App.get_compilation_time_ref().c_str()) : 88491487UL; this->pref_ = global_preferences->make_preference(hash, true); #ifdef USE_WIFI_FAST_CONNECT @@ -1035,7 +1050,7 @@ __attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) void WiFiComponent::check_scanning_finished() { if (!this->scan_done_) { - if (millis() - this->action_started_ > 30000) { + if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) { ESP_LOGE(TAG, "Scan timeout"); this->retry_connect(); } @@ -1184,8 +1199,9 @@ void WiFiComponent::check_connecting_finished() { } uint32_t now = millis(); - if (now - this->action_started_ > 30000) { - ESP_LOGW(TAG, "Connection timeout"); + if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) { + ESP_LOGW(TAG, "Connection timeout, aborting connection attempt"); + this->wifi_disconnect_(); this->retry_connect(); return; } @@ -1264,8 +1280,8 @@ WiFiRetryPhase WiFiComponent::determine_next_phase_() { } 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()) { + // If scan found no networks or no matching networks, skip to hidden network mode + if (this->scan_result_.empty() || !this->scan_result_[0].get_matches()) { return WiFiRetryPhase::RETRY_HIDDEN; } @@ -1405,6 +1421,10 @@ bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) { // without disrupting the captive portal/improv connection if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) { this->restart_adapter(); + } else { + // Even when skipping full restart, disconnect to clear driver state + // Without this, platforms like LibreTiny may think we're still connecting + this->wifi_disconnect_(); } // Clear scan flag - we're starting a new retry cycle this->did_scan_this_cycle_ = false; diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 2148f2d4c7..be94e9462b 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -273,6 +273,16 @@ class WiFiConnectStateListener { virtual void on_wifi_connect_state(const std::string &ssid, const bssid_t &bssid) = 0; }; +/** Listener interface for WiFi power save mode changes. + * + * Components can implement this interface to receive power save mode updates + * without the overhead of std::function callbacks. + */ +class WiFiPowerSaveListener { + public: + virtual void on_wifi_power_save(WiFiPowerSaveMode mode) = 0; +}; + /// This component is responsible for managing the ESP WiFi interface. class WiFiComponent : public Component { public: @@ -419,6 +429,10 @@ class WiFiComponent : public Component { void add_connect_state_listener(WiFiConnectStateListener *listener) { this->connect_state_listeners_.push_back(listener); } + /** Add a listener for WiFi power save mode changes. + * Listener receives: WiFiPowerSaveMode + */ + void add_power_save_listener(WiFiPowerSaveListener *listener) { this->power_save_listeners_.push_back(listener); } #endif // USE_WIFI_LISTENERS #ifdef USE_WIFI_RUNTIME_POWER_SAVE @@ -581,6 +595,7 @@ class WiFiComponent : public Component { std::vector ip_state_listeners_; std::vector scan_results_listeners_; std::vector connect_state_listeners_; + std::vector power_save_listeners_; #endif // USE_WIFI_LISTENERS ESPPreferenceObject pref_; #ifdef USE_WIFI_FAST_CONNECT diff --git a/esphome/components/wifi/wifi_component_esp8266.cpp b/esphome/components/wifi/wifi_component_esp8266.cpp index c1c0dd470f..3b1a442bdb 100644 --- a/esphome/components/wifi/wifi_component_esp8266.cpp +++ b/esphome/components/wifi/wifi_component_esp8266.cpp @@ -104,7 +104,15 @@ bool WiFiComponent::wifi_apply_power_save_() { break; } wifi_fpm_auto_sleep_set_in_null_mode(1); - return wifi_set_sleep_type(power_save); + bool success = wifi_set_sleep_type(power_save); +#ifdef USE_WIFI_LISTENERS + if (success) { + for (auto *listener : this->power_save_listeners_) { + listener->on_wifi_power_save(this->power_save_); + } + } +#endif + return success; } #if LWIP_VERSION_MAJOR != 1 diff --git a/esphome/components/wifi/wifi_component_esp_idf.cpp b/esphome/components/wifi/wifi_component_esp_idf.cpp index e1f8108892..4a3c40a119 100644 --- a/esphome/components/wifi/wifi_component_esp_idf.cpp +++ b/esphome/components/wifi/wifi_component_esp_idf.cpp @@ -280,7 +280,15 @@ bool WiFiComponent::wifi_apply_power_save_() { power_save = WIFI_PS_NONE; break; } - return esp_wifi_set_ps(power_save) == ESP_OK; + bool success = esp_wifi_set_ps(power_save) == ESP_OK; +#ifdef USE_WIFI_LISTENERS + if (success) { + for (auto *listener : this->power_save_listeners_) { + listener->on_wifi_power_save(this->power_save_); + } + } +#endif + return success; } bool WiFiComponent::wifi_sta_connect_(const WiFiAP &ap) { @@ -712,6 +720,7 @@ void WiFiComponent::wifi_process_event_(IDFWiFiEvent *data) { } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_STOP) { ESP_LOGV(TAG, "STA stop"); s_sta_started = false; + s_sta_connecting = false; } else if (data->event_base == WIFI_EVENT && data->event_id == WIFI_EVENT_STA_AUTHMODE_CHANGE) { const auto &it = data->data.sta_authmode_change; diff --git a/esphome/components/wifi/wifi_component_libretiny.cpp b/esphome/components/wifi/wifi_component_libretiny.cpp index 0de7003899..36003a6eb4 100644 --- a/esphome/components/wifi/wifi_component_libretiny.cpp +++ b/esphome/components/wifi/wifi_component_libretiny.cpp @@ -69,7 +69,17 @@ bool WiFiComponent::wifi_sta_pre_setup_() { delay(10); return true; } -bool WiFiComponent::wifi_apply_power_save_() { return WiFi.setSleep(this->power_save_ != WIFI_POWER_SAVE_NONE); } +bool WiFiComponent::wifi_apply_power_save_() { + bool success = WiFi.setSleep(this->power_save_ != WIFI_POWER_SAVE_NONE); +#ifdef USE_WIFI_LISTENERS + if (success) { + for (auto *listener : this->power_save_listeners_) { + listener->on_wifi_power_save(this->power_save_); + } + } +#endif + return success; +} bool WiFiComponent::wifi_sta_ip_config_(const optional &manual_ip) { // enable STA if (!this->wifi_mode_(true, {})) @@ -281,6 +291,7 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ } case ESPHOME_EVENT_ID_WIFI_STA_STOP: { ESP_LOGV(TAG, "STA stop"); + s_sta_connecting = false; break; } case ESPHOME_EVENT_ID_WIFI_STA_CONNECTED: { @@ -302,6 +313,23 @@ void WiFiComponent::wifi_event_callback_(esphome_wifi_event_id_t event, esphome_ char buf[33]; memcpy(buf, it.ssid, it.ssid_len); buf[it.ssid_len] = '\0'; + + // LibreTiny can send spurious disconnect events with empty ssid/bssid during connection. + // These are typically "Association Leave" events that don't indicate actual failures: + // [W][wifi_lt]: Disconnected ssid='' bssid=00:00:00:00:00:00 reason='Association Leave' + // [W][wifi_lt]: Disconnected ssid='' bssid=00:00:00:00:00:00 reason='Association Leave' + // [V][wifi_lt]: Connected ssid='WIFI' bssid=... channel=3, authmode=WPA2 PSK + // Without this check, the spurious events set s_sta_connecting=false, causing + // wifi_sta_connect_status_() to return IDLE. The main loop then sees + // "Unknown connection status 0" (wifi_component.cpp check_connecting_finished) + // and calls retry_connect(), aborting a connection that may succeed moments later. + // Real connection failures will have ssid/bssid populated, or we'll hit the connection timeout. + if (it.ssid_len == 0 && s_sta_connecting) { + ESP_LOGV(TAG, "Ignoring disconnect event with empty ssid while connecting (reason=%s)", + get_disconnect_reason_str(it.reason)); + break; + } + if (it.reason == WIFI_REASON_NO_AP_FOUND) { ESP_LOGW(TAG, "Disconnected ssid='%s' reason='Probe Request Unsuccessful'", buf); } else { @@ -435,6 +463,7 @@ bool WiFiComponent::wifi_scan_start_(bool passive) { } void WiFiComponent::wifi_scan_done_callback_() { this->scan_result_.clear(); + this->scan_done_ = true; int16_t num = WiFi.scanComplete(); if (num < 0) @@ -453,7 +482,6 @@ void WiFiComponent::wifi_scan_done_callback_() { ssid.length() == 0); } 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_); @@ -500,7 +528,12 @@ bool WiFiComponent::wifi_start_ap_(const WiFiAP &ap) { network::IPAddress WiFiComponent::wifi_soft_ap_ip() { return {WiFi.softAPIP()}; } #endif // USE_WIFI_AP -bool WiFiComponent::wifi_disconnect_() { return WiFi.disconnect(); } +bool WiFiComponent::wifi_disconnect_() { + // Clear connecting flag first so disconnect events aren't ignored + // and wifi_sta_connect_status_() returns IDLE instead of CONNECTING + s_sta_connecting = false; + return WiFi.disconnect(); +} bssid_t WiFiComponent::wifi_bssid() { bssid_t bssid{}; diff --git a/esphome/components/wifi/wifi_component_pico_w.cpp b/esphome/components/wifi/wifi_component_pico_w.cpp index c7dc4120dd..0228755432 100644 --- a/esphome/components/wifi/wifi_component_pico_w.cpp +++ b/esphome/components/wifi/wifi_component_pico_w.cpp @@ -54,7 +54,15 @@ bool WiFiComponent::wifi_apply_power_save_() { break; } int ret = cyw43_wifi_pm(&cyw43_state, pm); - return ret == 0; + bool success = ret == 0; +#ifdef USE_WIFI_LISTENERS + if (success) { + for (auto *listener : this->power_save_listeners_) { + listener->on_wifi_power_save(this->power_save_); + } + } +#endif + return success; } // TODO: The driver doesn't seem to have an API for this diff --git a/esphome/components/wifi_info/text_sensor.py b/esphome/components/wifi_info/text_sensor.py index bc0c038f80..8a7f192367 100644 --- a/esphome/components/wifi_info/text_sensor.py +++ b/esphome/components/wifi_info/text_sensor.py @@ -6,6 +6,7 @@ from esphome.const import ( CONF_DNS_ADDRESS, CONF_IP_ADDRESS, CONF_MAC_ADDRESS, + CONF_POWER_SAVE_MODE, CONF_SCAN_RESULTS, CONF_SSID, ENTITY_CATEGORY_DIAGNOSTIC, @@ -30,6 +31,9 @@ MacAddressWifiInfo = wifi_info_ns.class_( DNSAddressWifiInfo = wifi_info_ns.class_( "DNSAddressWifiInfo", text_sensor.TextSensor, cg.Component ) +PowerSaveModeWiFiInfo = wifi_info_ns.class_( + "PowerSaveModeWiFiInfo", text_sensor.TextSensor, cg.Component +) CONFIG_SCHEMA = cv.Schema( { @@ -58,6 +62,10 @@ CONFIG_SCHEMA = cv.Schema( cv.Optional(CONF_DNS_ADDRESS): text_sensor.text_sensor_schema( DNSAddressWifiInfo, entity_category=ENTITY_CATEGORY_DIAGNOSTIC ), + cv.Optional(CONF_POWER_SAVE_MODE): text_sensor.text_sensor_schema( + PowerSaveModeWiFiInfo, + entity_category=ENTITY_CATEGORY_DIAGNOSTIC, + ), } ) @@ -68,6 +76,7 @@ _NETWORK_INFO_KEYS = { CONF_IP_ADDRESS, CONF_DNS_ADDRESS, CONF_SCAN_RESULTS, + CONF_POWER_SAVE_MODE, } @@ -90,6 +99,7 @@ async def to_code(config): await setup_conf(config, CONF_SCAN_RESULTS) wifi.request_wifi_scan_results() await setup_conf(config, CONF_DNS_ADDRESS) + await setup_conf(config, CONF_POWER_SAVE_MODE) if conf := config.get(CONF_IP_ADDRESS): wifi_info = await text_sensor.new_text_sensor(config[CONF_IP_ADDRESS]) await cg.register_component(wifi_info, config[CONF_IP_ADDRESS]) diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.cpp b/esphome/components/wifi_info/wifi_info_text_sensor.cpp index 6c9d0c00e5..56cf49028c 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.cpp +++ b/esphome/components/wifi_info/wifi_info_text_sensor.cpp @@ -2,6 +2,10 @@ #ifdef USE_WIFI #include "esphome/core/log.h" +#ifdef USE_ESP8266 +#include +#endif + namespace esphome::wifi_info { static const char *const TAG = "wifi_info"; @@ -100,6 +104,62 @@ void BSSIDWiFiInfo::on_wifi_connect_state(const std::string &ssid, const wifi::b this->publish_state(buf); } +/************************ + * PowerSaveModeWiFiInfo + ***********************/ + +void PowerSaveModeWiFiInfo::setup() { wifi::global_wifi_component->add_power_save_listener(this); } + +void PowerSaveModeWiFiInfo::dump_config() { LOG_TEXT_SENSOR("", "WiFi Power Save Mode", this); } + +void PowerSaveModeWiFiInfo::on_wifi_power_save(wifi::WiFiPowerSaveMode mode) { +#ifdef USE_ESP8266 +#define MODE_STR(s) static const char MODE_##s[] PROGMEM = #s + MODE_STR(NONE); + MODE_STR(LIGHT); + MODE_STR(HIGH); + MODE_STR(UNKNOWN); + + const char *mode_str_p; + switch (mode) { + case wifi::WIFI_POWER_SAVE_NONE: + mode_str_p = MODE_NONE; + break; + case wifi::WIFI_POWER_SAVE_LIGHT: + mode_str_p = MODE_LIGHT; + break; + case wifi::WIFI_POWER_SAVE_HIGH: + mode_str_p = MODE_HIGH; + break; + default: + mode_str_p = MODE_UNKNOWN; + break; + } + + char mode_str[8]; + strncpy_P(mode_str, mode_str_p, sizeof(mode_str)); + mode_str[sizeof(mode_str) - 1] = '\0'; +#undef MODE_STR +#else + const char *mode_str; + switch (mode) { + case wifi::WIFI_POWER_SAVE_NONE: + mode_str = "NONE"; + break; + case wifi::WIFI_POWER_SAVE_LIGHT: + mode_str = "LIGHT"; + break; + case wifi::WIFI_POWER_SAVE_HIGH: + mode_str = "HIGH"; + break; + default: + mode_str = "UNKNOWN"; + break; + } +#endif + this->publish_state(mode_str); +} + #endif /********************* diff --git a/esphome/components/wifi_info/wifi_info_text_sensor.h b/esphome/components/wifi_info/wifi_info_text_sensor.h index f1f85c114f..b2242372da 100644 --- a/esphome/components/wifi_info/wifi_info_text_sensor.h +++ b/esphome/components/wifi_info/wifi_info_text_sensor.h @@ -63,6 +63,17 @@ class BSSIDWiFiInfo final : public Component, public text_sensor::TextSensor, pu // WiFiConnectStateListener interface void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override; }; + +class PowerSaveModeWiFiInfo final : public Component, + public text_sensor::TextSensor, + public wifi::WiFiPowerSaveListener { + public: + void setup() override; + void dump_config() override; + + // WiFiPowerSaveListener interface + void on_wifi_power_save(wifi::WiFiPowerSaveMode mode) override; +}; #endif class MacAddressWifiInfo final : public Component, public text_sensor::TextSensor { diff --git a/esphome/components/wifi_signal/sensor.py b/esphome/components/wifi_signal/sensor.py index 99b51adea0..82cb90c745 100644 --- a/esphome/components/wifi_signal/sensor.py +++ b/esphome/components/wifi_signal/sensor.py @@ -1,5 +1,5 @@ import esphome.codegen as cg -from esphome.components import sensor +from esphome.components import sensor, wifi import esphome.config_validation as cv from esphome.const import ( DEVICE_CLASS_SIGNAL_STRENGTH, @@ -25,5 +25,6 @@ CONFIG_SCHEMA = sensor.sensor_schema( async def to_code(config): + wifi.request_wifi_listeners() var = await sensor.new_sensor(config) await cg.register_component(var, config) diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.cpp b/esphome/components/wifi_signal/wifi_signal_sensor.cpp index 4347295421..11d816a909 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.cpp +++ b/esphome/components/wifi_signal/wifi_signal_sensor.cpp @@ -2,13 +2,11 @@ #ifdef USE_WIFI #include "esphome/core/log.h" -namespace esphome { -namespace wifi_signal { +namespace esphome::wifi_signal { static const char *const TAG = "wifi_signal.sensor"; void WiFiSignalSensor::dump_config() { LOG_SENSOR("", "WiFi Signal", this); } -} // namespace wifi_signal -} // namespace esphome +} // namespace esphome::wifi_signal #endif diff --git a/esphome/components/wifi_signal/wifi_signal_sensor.h b/esphome/components/wifi_signal/wifi_signal_sensor.h index 5cfd19b523..9f581f1eb2 100644 --- a/esphome/components/wifi_signal/wifi_signal_sensor.h +++ b/esphome/components/wifi_signal/wifi_signal_sensor.h @@ -5,17 +5,32 @@ #include "esphome/components/sensor/sensor.h" #include "esphome/components/wifi/wifi_component.h" #ifdef USE_WIFI -namespace esphome { -namespace wifi_signal { +namespace esphome::wifi_signal { +#ifdef USE_WIFI_LISTENERS +class WiFiSignalSensor : public sensor::Sensor, public PollingComponent, public wifi::WiFiConnectStateListener { +#else class WiFiSignalSensor : public sensor::Sensor, public PollingComponent { +#endif public: - void update() override { this->publish_state(wifi::global_wifi_component->wifi_rssi()); } +#ifdef USE_WIFI_LISTENERS + void setup() override { wifi::global_wifi_component->add_connect_state_listener(this); } +#endif + void update() override { + int8_t rssi = wifi::global_wifi_component->wifi_rssi(); + if (rssi != wifi::WIFI_RSSI_DISCONNECTED) { + this->publish_state(rssi); + } + } void dump_config() override; float get_setup_priority() const override { return setup_priority::AFTER_WIFI; } + +#ifdef USE_WIFI_LISTENERS + // WiFiConnectStateListener interface - update RSSI immediately on connect + void on_wifi_connect_state(const std::string &ssid, const wifi::bssid_t &bssid) override { this->update(); } +#endif }; -} // namespace wifi_signal -} // namespace esphome +} // namespace esphome::wifi_signal #endif diff --git a/esphome/config.py b/esphome/config.py index 1c4cdd93c6..6f6ad4886b 100644 --- a/esphome/config.py +++ b/esphome/config.py @@ -1010,16 +1010,22 @@ def validate_config( result.add_error(err) return result + # 1.1. Merge packages + if CONF_PACKAGES in config: + from esphome.components.packages import merge_packages + + config = merge_packages(config) + CORE.raw_config = config - # 1.1. Resolve !extend and !remove and check for REPLACEME + # 1.2. Resolve !extend and !remove and check for REPLACEME # After this step, there will not be any Extend or Remove values in the config anymore try: resolve_extend_remove(config) except vol.Invalid as err: result.add_error(err) - # 1.2. Load external_components + # 1.3. Load external_components if CONF_EXTERNAL_COMPONENTS in config: from esphome.components.external_components import do_external_components_pass diff --git a/esphome/config_validation.py b/esphome/config_validation.py index a3fd271a86..08fffa6cec 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -71,6 +71,7 @@ from esphome.const import ( PLATFORM_ESP32, PLATFORM_ESP8266, PLATFORM_RP2040, + SCHEDULER_DONT_RUN, TYPE_GIT, TYPE_LOCAL, VALID_SUBSTITUTIONS_CHARACTERS, @@ -740,9 +741,10 @@ def has_at_most_one_key(*keys): if not isinstance(obj, dict): raise Invalid("expected dictionary") - number = sum(k in keys for k in obj) - if number > 1: - raise Invalid(f"Cannot specify more than one of {', '.join(keys)}.") + used = set(obj) & set(keys) + if len(used) > 1: + msg = "Cannot specify more than one of '" + "', '".join(used) + "'." + raise MultipleInvalid([Invalid(msg, path=[k]) for k in used]) return obj return validate @@ -893,7 +895,7 @@ def time_period_in_minutes_(value): def update_interval(value): if value == "never": - return 4294967295 # uint32_t max + return TimePeriodMilliseconds(milliseconds=SCHEDULER_DONT_RUN) return positive_time_period_milliseconds(value) @@ -1743,8 +1745,7 @@ class SplitDefault(Optional): def default(self): keys = [] if CORE.is_esp32: - from esphome.components.esp32 import get_esp32_variant - from esphome.components.esp32.const import VARIANT_ESP32 + from esphome.components.esp32 import VARIANT_ESP32, get_esp32_variant variant = get_esp32_variant().replace(VARIANT_ESP32, "").lower() framework = CORE.target_framework.replace("esp-", "") @@ -2009,7 +2010,7 @@ def polling_component_schema(default_update_interval): if default_update_interval is None: return COMPONENT_SCHEMA.extend( { - Required(CONF_UPDATE_INTERVAL): default_update_interval, + Required(CONF_UPDATE_INTERVAL): update_interval, } ) assert isinstance(default_update_interval, str) diff --git a/esphome/const.py b/esphome/const.py index 59bf0e8b8a..c94ead0be4 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -4,7 +4,7 @@ from enum import Enum from esphome.enum import StrEnum -__version__ = "2025.12.0-dev" +__version__ = "2026.1.0-dev" ALLOWED_NAME_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789-_" VALID_SUBSTITUTIONS_CHARACTERS = ( @@ -559,6 +559,7 @@ CONF_LOGS = "logs" CONF_LONGITUDE = "longitude" CONF_LOOP_TIME = "loop_time" CONF_LOW = "low" +CONF_LOW_POWER_MODE = "low_power_mode" CONF_LOW_VOLTAGE_REFERENCE = "low_voltage_reference" CONF_MAC_ADDRESS = "mac_address" CONF_MAGNITUDE = "magnitude" diff --git a/esphome/core/__init__.py b/esphome/core/__init__.py index 08753b0f2d..721cd5787d 100644 --- a/esphome/core/__init__.py +++ b/esphome/core/__init__.py @@ -541,8 +541,22 @@ class EsphomeCore: self.friendly_name: str | None = None # The area / zone of the node self.area: str | None = None - # Additional data components can store temporary data in - # The first key to this dict should always be the integration name + # Additional data components can store temporary data in. + # This dict is cleared between compilation runs. + # + # Usage pattern (use @dataclass for type safety): + # DOMAIN = "my_component" + # + # @dataclass + # class MyComponentData: + # feature_enabled: bool = False + # + # def _get_data() -> MyComponentData: + # if DOMAIN not in CORE.data: + # CORE.data[DOMAIN] = MyComponentData() + # return CORE.data[DOMAIN] + # + # The first key should always be the component domain name (DOMAIN constant). self.data = {} # The relative path to the configuration YAML self.config_path: Path | None = None diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 75814ae253..a85d671a07 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -12,6 +12,10 @@ #include "esphome/components/status_led/status_led.h" #endif +#if defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) +#include "esphome/components/socket/socket.h" +#endif + #ifdef USE_SOCKET_SELECT_SUPPORT #include @@ -627,6 +631,9 @@ void Application::yield_with_select_(uint32_t delay_ms) { // No sockets registered, use regular delay delay(delay_ms); } +#elif defined(USE_ESP8266) && defined(USE_SOCKET_IMPL_LWIP_TCP) + // No select support but can wake on socket activity via esp_schedule() + socket::socket_delay(delay_ms); #else // No select support, use regular delay delay(delay_ms); diff --git a/esphome/core/application.h b/esphome/core/application.h index 14e800342e..8e2035b7c5 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -256,6 +256,8 @@ class Application { /// Get the comment of this Application set by pre_setup(). std::string get_comment() const { return this->comment_; } + /// Get the comment as StringRef (avoids allocation) + StringRef get_comment_ref() const { return StringRef(this->comment_); } bool is_name_add_mac_suffix_enabled() const { return this->name_add_mac_suffix_; } diff --git a/esphome/core/automation.h b/esphome/core/automation.h index dacadd35e8..61d2944acf 100644 --- a/esphome/core/automation.h +++ b/esphome/core/automation.h @@ -45,6 +45,12 @@ template class TemplatableValue { public: TemplatableValue() : type_(NONE) {} + // For const char* when T is std::string: store pointer directly, no heap allocation + // String remains in flash and is only converted to std::string when value() is called + TemplatableValue(const char *str) requires std::same_as : type_(STATIC_STRING) { + this->static_str_ = str; + } + template TemplatableValue(F value) requires(!std::invocable) : type_(VALUE) { new (&this->value_) T(std::move(value)); } @@ -64,24 +70,28 @@ template class TemplatableValue { // Copy constructor TemplatableValue(const TemplatableValue &other) : type_(other.type_) { - if (type_ == VALUE) { + if (this->type_ == VALUE) { new (&this->value_) T(other.value_); - } else if (type_ == LAMBDA) { + } else if (this->type_ == LAMBDA) { this->f_ = new std::function(*other.f_); - } else if (type_ == STATELESS_LAMBDA) { + } else if (this->type_ == STATELESS_LAMBDA) { this->stateless_f_ = other.stateless_f_; + } else if (this->type_ == STATIC_STRING) { + this->static_str_ = other.static_str_; } } // Move constructor TemplatableValue(TemplatableValue &&other) noexcept : type_(other.type_) { - if (type_ == VALUE) { + if (this->type_ == VALUE) { new (&this->value_) T(std::move(other.value_)); - } else if (type_ == LAMBDA) { + } else if (this->type_ == LAMBDA) { this->f_ = other.f_; other.f_ = nullptr; - } else if (type_ == STATELESS_LAMBDA) { + } else if (this->type_ == STATELESS_LAMBDA) { this->stateless_f_ = other.stateless_f_; + } else if (this->type_ == STATIC_STRING) { + this->static_str_ = other.static_str_; } other.type_ = NONE; } @@ -104,12 +114,12 @@ template class TemplatableValue { } ~TemplatableValue() { - if (type_ == VALUE) { + if (this->type_ == VALUE) { this->value_.~T(); - } else if (type_ == LAMBDA) { + } else if (this->type_ == LAMBDA) { delete this->f_; } - // STATELESS_LAMBDA/NONE: no cleanup needed (function pointer or empty, not heap-allocated) + // STATELESS_LAMBDA/STATIC_STRING/NONE: no cleanup needed (pointers, not heap-allocated) } bool has_value() { return this->type_ != NONE; } @@ -122,6 +132,13 @@ template class TemplatableValue { return (*this->f_)(x...); // std::function call case VALUE: return this->value_; + case STATIC_STRING: + // if constexpr required: code must compile for all T, but STATIC_STRING + // can only be set when T is std::string (enforced by constructor constraint) + if constexpr (std::same_as) { + return std::string(this->static_str_); + } + __builtin_unreachable(); case NONE: default: return T{}; @@ -148,12 +165,14 @@ template class TemplatableValue { VALUE, LAMBDA, STATELESS_LAMBDA, + STATIC_STRING, // For const char* when T is std::string - avoids heap allocation } type_; union { T value_; std::function *f_; T (*stateless_f_)(X...); + const char *static_str_; // For STATIC_STRING type }; }; diff --git a/esphome/core/color.cpp b/esphome/core/color.cpp index 7e390b2354..14c41c2b0d 100644 --- a/esphome/core/color.cpp +++ b/esphome/core/color.cpp @@ -6,4 +6,18 @@ namespace esphome { constinit const Color Color::BLACK(0, 0, 0, 0); constinit const Color Color::WHITE(255, 255, 255, 255); +Color Color::gradient(const Color &to_color, uint8_t amnt) { + Color new_color; + float amnt_f = float(amnt) / 255.0f; + new_color.r = amnt_f * (to_color.r - this->r) + this->r; + new_color.g = amnt_f * (to_color.g - this->g) + this->g; + new_color.b = amnt_f * (to_color.b - this->b) + this->b; + new_color.w = amnt_f * (to_color.w - this->w) + this->w; + return new_color; +} + +Color Color::fade_to_white(uint8_t amnt) { return this->gradient(Color::WHITE, amnt); } + +Color Color::fade_to_black(uint8_t amnt) { return this->gradient(Color::BLACK, amnt); } + } // namespace esphome diff --git a/esphome/core/color.h b/esphome/core/color.h index 4b0ae5b57a..32d63b1856 100644 --- a/esphome/core/color.h +++ b/esphome/core/color.h @@ -174,17 +174,9 @@ struct Color { uint8_t((uint16_t(b) * 255U / max_rgb)), w); } - Color gradient(const Color &to_color, uint8_t amnt) { - Color new_color; - float amnt_f = float(amnt) / 255.0f; - new_color.r = amnt_f * (to_color.r - (*this).r) + (*this).r; - new_color.g = amnt_f * (to_color.g - (*this).g) + (*this).g; - new_color.b = amnt_f * (to_color.b - (*this).b) + (*this).b; - new_color.w = amnt_f * (to_color.w - (*this).w) + (*this).w; - return new_color; - } - Color fade_to_white(uint8_t amnt) { return (*this).gradient(Color::WHITE, amnt); } - Color fade_to_black(uint8_t amnt) { return (*this).gradient(Color::BLACK, amnt); } + Color gradient(const Color &to_color, uint8_t amnt); + Color fade_to_white(uint8_t amnt); + Color fade_to_black(uint8_t amnt); Color lighten(uint8_t delta) { return *this + delta; } Color darken(uint8_t delta) { return *this - delta; } diff --git a/esphome/core/component.cpp b/esphome/core/component.cpp index b7c0cedb76..97ab2edb5a 100644 --- a/esphome/core/component.cpp +++ b/esphome/core/component.cpp @@ -138,10 +138,19 @@ void Component::set_retry(const std::string &name, uint32_t initial_wait_time, u App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); } +void Component::set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, + std::function &&f, float backoff_increase_factor) { // NOLINT + App.scheduler.set_retry(this, name, initial_wait_time, max_attempts, std::move(f), backoff_increase_factor); +} + bool Component::cancel_retry(const std::string &name) { // NOLINT return App.scheduler.cancel_retry(this, name); } +bool Component::cancel_retry(const char *name) { // NOLINT + return App.scheduler.cancel_retry(this, name); +} + void Component::set_timeout(const std::string &name, uint32_t timeout, std::function &&f) { // NOLINT App.scheduler.set_timeout(this, name, timeout, std::move(f)); } diff --git a/esphome/core/component.h b/esphome/core/component.h index 3d45a020c4..32f594d6f8 100644 --- a/esphome/core/component.h +++ b/esphome/core/component.h @@ -367,6 +367,9 @@ class Component { void set_retry(const std::string &name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT + void set_retry(const char *name, uint32_t initial_wait_time, uint8_t max_attempts, // NOLINT + std::function &&f, float backoff_increase_factor = 1.0f); // NOLINT + void set_retry(uint32_t initial_wait_time, uint8_t max_attempts, std::function &&f, // NOLINT float backoff_increase_factor = 1.0f); // NOLINT @@ -376,6 +379,7 @@ class Component { * @return Whether a retry function was deleted. */ bool cancel_retry(const std::string &name); // NOLINT + bool cancel_retry(const char *name); // NOLINT /** Set a timeout function with a unique name. * diff --git a/esphome/core/config.py b/esphome/core/config.py index 0a239c5f5e..3adaf7eb9e 100644 --- a/esphome/core/config.py +++ b/esphome/core/config.py @@ -87,7 +87,7 @@ def validate_hostname(config): _LOGGER.warning( "'%s': Using the '_' (underscore) character in the hostname is discouraged " "as it can cause problems with some DHCP and local name services. " - "For more information, see https://esphome.io/guides/faq.html#why-shouldn-t-i-use-underscores-in-my-device-name", + "For more information, see https://esphome.io/guides/faq/#why-shouldnt-i-use-underscores-in-my-device-name", config[CONF_NAME], ) return config diff --git a/esphome/core/defines.h b/esphome/core/defines.h index 12dfdba5ce..750cab5bba 100644 --- a/esphome/core/defines.h +++ b/esphome/core/defines.h @@ -28,6 +28,7 @@ #define USE_BUTTON #define USE_CAMERA #define USE_CLIMATE +#define USE_CLIMATE_VISUAL_OVERRIDES #define USE_CONTROLLER_REGISTRY #define USE_COVER #define USE_DATETIME @@ -46,6 +47,7 @@ #define USE_GRAPHICAL_DISPLAY_MENU #define USE_HOMEASSISTANT_TIME #define USE_HTTP_REQUEST_OTA_WATCHDOG_TIMEOUT 8000 // NOLINT +#define USE_IMAGE #define USE_IMPROV_SERIAL_NEXT_URL #define USE_JSON #define USE_LIGHT @@ -89,7 +91,8 @@ #define USE_MDNS #define USE_MDNS_STORE_SERVICES #define MDNS_SERVICE_COUNT 3 -#define MDNS_DYNAMIC_TXT_COUNT 3 +#define USE_MDNS_DYNAMIC_TXT +#define MDNS_DYNAMIC_TXT_COUNT 2 #define SNTP_SERVER_COUNT 3 #define USE_MEDIA_PLAYER #define USE_NEXTION_TFT_UPLOAD @@ -128,6 +131,8 @@ #define USE_API_PLAINTEXT #define USE_API_USER_DEFINED_ACTIONS #define USE_API_CUSTOM_SERVICES +#define USE_API_USER_DEFINED_ACTION_RESPONSES +#define USE_API_USER_DEFINED_ACTION_RESPONSES_JSON #define API_MAX_SEND_QUEUE 8 #define USE_MD5 #define USE_SHA256 @@ -233,9 +238,9 @@ #if defined(USE_ESP32_VARIANT_ESP32S2) #define USE_LOGGER_USB_CDC -#elif defined(USE_ESP32_VARIANT_ESP32S3) || defined(USE_ESP32_VARIANT_ESP32C3) || \ - defined(USE_ESP32_VARIANT_ESP32C5) || defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32H2) || \ - defined(USE_ESP32_VARIANT_ESP32P4) +#elif defined(USE_ESP32_VARIANT_ESP32C3) || defined(USE_ESP32_VARIANT_ESP32C5) || \ + defined(USE_ESP32_VARIANT_ESP32C6) || defined(USE_ESP32_VARIANT_ESP32C61) || defined(USE_ESP32_VARIANT_ESP32H2) || \ + defined(USE_ESP32_VARIANT_ESP32P4) || defined(USE_ESP32_VARIANT_ESP32S3) #define USE_LOGGER_USB_CDC #define USE_LOGGER_USB_SERIAL_JTAG #endif diff --git a/esphome/core/entity_base.h b/esphome/core/entity_base.h index aa9b92877a..fdf3f6300a 100644 --- a/esphome/core/entity_base.h +++ b/esphome/core/entity_base.h @@ -205,7 +205,7 @@ template class StatefulEntityBase : public EntityBase { virtual bool has_state() const { return this->state_.has_value(); } virtual const T &get_state() const { return this->state_.value(); } virtual T get_state_default(T default_value) const { return this->state_.value_or(default_value); } - void invalidate_state() { this->set_state_({}); } + void invalidate_state() { this->set_new_state({}); } void add_full_state_callback(std::function previous, optional current)> &&callback) { if (this->full_state_callbacks_ == nullptr) @@ -227,20 +227,20 @@ template class StatefulEntityBase : public EntityBase { /** * Set a new state for this entity. This will trigger callbacks only if the new state is different from the previous. * - * @param state The new state. + * @param new_state The new state. * @return True if the state was changed, false if it was the same as before. */ - bool set_state_(const optional &state) { - if (this->state_ != state) { + virtual bool set_new_state(const optional &new_state) { + if (this->state_ != new_state) { // call the full state callbacks with the previous and new state if (this->full_state_callbacks_ != nullptr) - this->full_state_callbacks_->call(this->state_, state); + this->full_state_callbacks_->call(this->state_, new_state); // trigger legacy callbacks only if the new state is valid and either the trigger on initial state is enabled or // the previous state was valid auto had_state = this->has_state(); - this->state_ = state; - if (this->state_callbacks_ != nullptr && state.has_value() && (this->trigger_on_initial_state_ || had_state)) - this->state_callbacks_->call(state.value()); + this->state_ = new_state; + if (this->state_callbacks_ != nullptr && new_state.has_value() && (this->trigger_on_initial_state_ || had_state)) + this->state_callbacks_->call(new_state.value()); return true; } return false; diff --git a/esphome/core/helpers.cpp b/esphome/core/helpers.cpp index 1f675563c7..fb96869d21 100644 --- a/esphome/core/helpers.cpp +++ b/esphome/core/helpers.cpp @@ -266,19 +266,12 @@ std::string make_name_with_suffix(const std::string &name, char sep, const char // Parsing & formatting size_t parse_hex(const char *str, size_t length, uint8_t *data, size_t count) { - uint8_t val; size_t chars = std::min(length, 2 * count); for (size_t i = 2 * count - chars; i < 2 * count; i++, str++) { - if (*str >= '0' && *str <= '9') { - val = *str - '0'; - } else if (*str >= 'A' && *str <= 'F') { - val = 10 + (*str - 'A'); - } else if (*str >= 'a' && *str <= 'f') { - val = 10 + (*str - 'a'); - } else { + uint8_t val = parse_hex_char(*str); + if (val > 15) return 0; - } - data[i >> 1] = !(i & 1) ? val << 4 : data[i >> 1] | val; + data[i >> 1] = (i & 1) ? data[i >> 1] | val : val << 4; } return chars; } @@ -480,22 +473,13 @@ std::string base64_encode(const uint8_t *buf, size_t buf_len) { } size_t base64_decode(const std::string &encoded_string, uint8_t *buf, size_t buf_len) { - std::vector decoded = base64_decode(encoded_string); - if (decoded.size() > buf_len) { - ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); - decoded.resize(buf_len); - } - memcpy(buf, decoded.data(), decoded.size()); - return decoded.size(); -} - -std::vector base64_decode(const std::string &encoded_string) { int in_len = encoded_string.size(); int i = 0; int j = 0; int in = 0; + size_t out = 0; uint8_t char_array_4[4], char_array_3[3]; - std::vector ret; + bool truncated = false; // SAFETY: The loop condition checks is_base64() before processing each character. // This ensures base64_find_char() is only called on valid base64 characters, @@ -511,8 +495,13 @@ std::vector base64_decode(const std::string &encoded_string) { char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - for (i = 0; (i < 3); i++) - ret.push_back(char_array_3[i]); + for (i = 0; i < 3; i++) { + if (out < buf_len) { + buf[out++] = char_array_3[i]; + } else { + truncated = true; + } + } i = 0; } } @@ -528,10 +517,28 @@ std::vector base64_decode(const std::string &encoded_string) { char_array_3[1] = ((char_array_4[1] & 0xf) << 4) + ((char_array_4[2] & 0x3c) >> 2); char_array_3[2] = ((char_array_4[2] & 0x3) << 6) + char_array_4[3]; - for (j = 0; (j < i - 1); j++) - ret.push_back(char_array_3[j]); + for (j = 0; j < i - 1; j++) { + if (out < buf_len) { + buf[out++] = char_array_3[j]; + } else { + truncated = true; + } + } } + if (truncated) { + ESP_LOGW(TAG, "Base64 decode: buffer too small, truncating"); + } + + return out; +} + +std::vector base64_decode(const std::string &encoded_string) { + // Calculate maximum decoded size: every 4 base64 chars = 3 bytes + size_t max_len = ((encoded_string.size() + 3) / 4) * 3; + std::vector ret(max_len); + size_t actual_len = base64_decode(encoded_string, ret.data(), max_len); + ret.resize(actual_len); return ret; } @@ -642,17 +649,17 @@ std::string get_mac_address() { } std::string get_mac_address_pretty() { - char buf[18]; + char buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE]; return std::string(get_mac_address_pretty_into_buffer(buf)); } -void get_mac_address_into_buffer(std::span buf) { +void get_mac_address_into_buffer(std::span buf) { uint8_t mac[6]; get_mac_address_raw(mac); format_mac_addr_lower_no_sep(mac, buf.data()); } -const char *get_mac_address_pretty_into_buffer(std::span buf) { +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()); diff --git a/esphome/core/helpers.h b/esphome/core/helpers.h index 9a8132c53d..3055d7b31f 100644 --- a/esphome/core/helpers.h +++ b/esphome/core/helpers.h @@ -624,6 +624,17 @@ template::value, int> = 0> optional< return parse_hex(str.c_str(), str.length()); } +/// Parse a hex character to its nibble value (0-15), returns 255 on invalid input +constexpr uint8_t parse_hex_char(char c) { + if (c >= '0' && c <= '9') + return c - '0'; + if (c >= 'A' && c <= 'F') + return c - 'A' + 10; + if (c >= 'a' && c <= 'f') + return c - 'a' + 10; + return 255; +} + /// Convert a nibble (0-15) to lowercase hex char inline char format_hex_char(uint8_t v) { return v >= 10 ? 'a' + (v - 10) : '0' + v; } @@ -1123,6 +1134,12 @@ class HighFrequencyLoopRequester { /// Get the device MAC address as raw bytes, written into the provided byte array (6 bytes). void get_mac_address_raw(uint8_t *mac); // NOLINT(readability-non-const-parameter) +/// Buffer size for MAC address in lowercase hex notation (12 hex chars + null terminator) +constexpr size_t MAC_ADDRESS_BUFFER_SIZE = 13; + +/// Buffer size for MAC address in colon-separated uppercase hex notation (17 chars + null terminator) +constexpr size_t MAC_ADDRESS_PRETTY_BUFFER_SIZE = 18; + /// Get the device MAC address as a string, in lowercase hex notation. std::string get_mac_address(); @@ -1130,13 +1147,14 @@ std::string get_mac_address(); std::string get_mac_address_pretty(); /// Get the device MAC address into the given buffer, in lowercase hex notation. -/// Assumes buffer length is 13 (12 digits for hexadecimal representation followed by null terminator). -void get_mac_address_into_buffer(std::span buf); +/// Assumes buffer length is MAC_ADDRESS_BUFFER_SIZE (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). +/// Buffer must be exactly MAC_ADDRESS_PRETTY_BUFFER_SIZE 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); +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). diff --git a/esphome/core/progmem.h b/esphome/core/progmem.h index 67131fd113..f9508945e8 100644 --- a/esphome/core/progmem.h +++ b/esphome/core/progmem.h @@ -1,16 +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 +// On other platforms: Use plain strings (no PROGMEM) -#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 +#ifdef USE_ESP8266 +// ESP8266 uses Arduino macros #define ESPHOME_F(string_literal) F(string_literal) #define ESPHOME_PGM_P PGM_P #define ESPHOME_strncpy_P strncpy_P +#else +#define ESPHOME_F(string_literal) (string_literal) +#define ESPHOME_PGM_P const char * +#define ESPHOME_strncpy_P strncpy #endif diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 352587bf10..8b713523b6 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -204,13 +204,21 @@ bool HOT Scheduler::cancel_interval(Component *component, const char *name) { } struct RetryArgs { + // Ordered to minimize padding on 32-bit systems std::function func; - uint8_t retry_countdown; - uint32_t current_interval; Component *component; - std::string name; // Keep as std::string since retry uses it dynamically - float backoff_increase_factor; Scheduler *scheduler; + const char *name; // Points to static string or owned copy + uint32_t current_interval; + float backoff_increase_factor; + uint8_t retry_countdown; + bool name_is_dynamic; // True if name needs delete[] + + ~RetryArgs() { + if (this->name_is_dynamic && this->name) { + delete[] this->name; + } + } }; void retry_handler(const std::shared_ptr &args) { @@ -218,8 +226,10 @@ void retry_handler(const std::shared_ptr &args) { if (retry_result == RetryResult::DONE || args->retry_countdown <= 0) return; // second execution of `func` happens after `initial_wait_time` + // Pass is_static_string=true because args->name is owned by the shared_ptr + // which is captured in the lambda and outlives the SchedulerItem args->scheduler->set_timer_common_( - args->component, Scheduler::SchedulerItem::TIMEOUT, false, &args->name, args->current_interval, + args->component, Scheduler::SchedulerItem::TIMEOUT, true, args->name, args->current_interval, [args]() { retry_handler(args); }, /* is_retry= */ true); // backoff_increase_factor applied to third & later executions args->current_interval *= args->backoff_increase_factor; @@ -246,16 +256,35 @@ void HOT Scheduler::set_retry_common_(Component *component, bool is_static_strin auto args = std::make_shared(); args->func = std::move(func); - args->retry_countdown = max_attempts; - args->current_interval = initial_wait_time; args->component = component; - args->name = name_cstr ? name_cstr : ""; // Convert to std::string for RetryArgs - args->backoff_increase_factor = backoff_increase_factor; args->scheduler = this; + args->current_interval = initial_wait_time; + args->backoff_increase_factor = backoff_increase_factor; + args->retry_countdown = max_attempts; + + // Store name - either as static pointer or owned copy + if (name_cstr == nullptr || name_cstr[0] == '\0') { + // Empty or null name - use empty string literal + args->name = ""; + args->name_is_dynamic = false; + } else if (is_static_string) { + // Static string - just store the pointer + args->name = name_cstr; + args->name_is_dynamic = false; + } else { + // Dynamic string - make a copy + size_t len = strlen(name_cstr); + char *copy = new char[len + 1]; + memcpy(copy, name_cstr, len + 1); + args->name = copy; + args->name_is_dynamic = true; + } // First execution of `func` immediately - use set_timer_common_ with is_retry=true + // Pass is_static_string=true because args->name is owned by the shared_ptr + // which is captured in the lambda and outlives the SchedulerItem this->set_timer_common_( - component, SchedulerItem::TIMEOUT, false, &args->name, 0, [args]() { retry_handler(args); }, + component, SchedulerItem::TIMEOUT, true, args->name, 0, [args]() { retry_handler(args); }, /* is_retry= */ true); } @@ -315,7 +344,7 @@ void Scheduler::full_cleanup_removed_items_() { valid_items.push_back(std::move(item)); } else { // Recycle removed items - this->recycle_item_(std::move(item)); + this->recycle_item_main_loop_(std::move(item)); } } @@ -400,7 +429,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->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; } @@ -413,7 +442,7 @@ void HOT Scheduler::call(uint32_t now) { { LockGuard guard{this->lock_}; if (is_item_removed_(item.get())) { - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -422,7 +451,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->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -449,7 +478,7 @@ void HOT Scheduler::call(uint32_t now) { if (executed_item->remove) { // We were removed/cancelled in the function call, recycle and continue this->to_remove_--; - this->recycle_item_(std::move(executed_item)); + this->recycle_item_main_loop_(std::move(executed_item)); continue; } @@ -460,7 +489,7 @@ void HOT Scheduler::call(uint32_t now) { this->to_add_.push_back(std::move(executed_item)); } else { // Timeout completed - recycle it - this->recycle_item_(std::move(executed_item)); + this->recycle_item_main_loop_(std::move(executed_item)); } has_added_items |= !this->to_add_.empty(); @@ -475,7 +504,7 @@ void HOT Scheduler::process_to_add() { for (auto &it : this->to_add_) { if (is_item_removed_(it.get())) { // Recycle cancelled items - this->recycle_item_(std::move(it)); + this->recycle_item_main_loop_(std::move(it)); continue; } @@ -509,7 +538,7 @@ size_t HOT Scheduler::cleanup_() { if (!item->remove) break; this->to_remove_--; - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); } return this->items_.size(); } @@ -562,20 +591,15 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c #endif /* not ESPHOME_THREAD_SINGLE */ // Cancel items in the main heap - // Special case: if the last item in the heap matches, we can remove it immediately - // (removing the last element doesn't break heap structure) + // We only mark items for removal here - never recycle directly. + // The main loop may be executing an item's callback right now, and recycling + // would destroy the callback while it's running (use-after-free). + // Only the main loop in call() should recycle items after execution completes. if (!this->items_.empty()) { - auto &last_item = this->items_.back(); - 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_locked_(this->items_, component, name_cstr, type, match_retry); total_cancelled += heap_cancelled; - this->to_remove_ += heap_cancelled; // Track removals for heap items + this->to_remove_ += heap_cancelled; } // Cancel items in to_add_ @@ -749,7 +773,11 @@ bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, : (a->next_execution_high_ > b->next_execution_high_); } -void Scheduler::recycle_item_(std::unique_ptr item) { +// Recycle a SchedulerItem back to the pool for reuse. +// IMPORTANT: Caller must hold the scheduler lock before calling this function. +// This protects scheduler_item_pool_ from concurrent access by other threads +// that may be acquiring items from the pool in set_timer_common_(). +void Scheduler::recycle_item_main_loop_(std::unique_ptr item) { if (!item) return; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 08e003c9fb..5bf3d19adb 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -272,8 +272,11 @@ class Scheduler { return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); } - // Helper to recycle a SchedulerItem - void recycle_item_(std::unique_ptr item); + // Helper to recycle a SchedulerItem back to the pool. + // IMPORTANT: Only call from main loop context! Recycling clears the callback, + // so calling from another thread while the callback is executing causes use-after-free. + // IMPORTANT: Caller must hold the scheduler lock before calling this function. + void recycle_item_main_loop_(std::unique_ptr item); // Helper to perform full cleanup when too many items are cancelled void full_cleanup_removed_items_(); @@ -329,7 +332,10 @@ class Scheduler { now = this->execute_item_(item.get(), now); } // Recycle the defer item after execution - this->recycle_item_(std::move(item)); + { + LockGuard lock(this->lock_); + this->recycle_item_main_loop_(std::move(item)); + } } // If we've consumed all items up to the snapshot point, clean up the dead space diff --git a/esphome/core/string_ref.h b/esphome/core/string_ref.h index efaa17181d..505fdd906a 100644 --- a/esphome/core/string_ref.h +++ b/esphome/core/string_ref.h @@ -128,6 +128,17 @@ inline std::string operator+(const StringRef &lhs, const char *rhs) { return str; } +inline std::string operator+(const StringRef &lhs, const std::string &rhs) { + auto str = lhs.str(); + str.append(rhs); + return str; +} + +inline std::string operator+(const std::string &lhs, const StringRef &rhs) { + std::string str(lhs); + str.append(rhs.c_str(), rhs.size()); + return str; +} #ifdef USE_JSON // NOLINTNEXTLINE(readability-identifier-naming) inline void convertToJson(const StringRef &src, JsonVariant dst) { dst.set(src.c_str()); } diff --git a/esphome/dashboard/web_server.py b/esphome/dashboard/web_server.py index 804a2b99af..f94d8eea22 100644 --- a/esphome/dashboard/web_server.py +++ b/esphome/dashboard/web_server.py @@ -164,8 +164,24 @@ def websocket_method(name): return wrap +class CheckOriginMixin: + """Mixin to handle WebSocket origin checks for reverse proxy setups.""" + + def check_origin(self, origin: str) -> bool: + if "ESPHOME_TRUSTED_DOMAINS" not in os.environ: + return super().check_origin(origin) + trusted_domains = [ + s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",") + ] + url = urlparse(origin) + if url.hostname in trusted_domains: + return True + _LOGGER.info("check_origin %s, domain is not trusted", origin) + return False + + @websocket_class -class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): +class EsphomeCommandWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): """Base class for ESPHome websocket commands.""" def __init__( @@ -183,18 +199,6 @@ class EsphomeCommandWebSocket(tornado.websocket.WebSocketHandler): # use Popen() with a reading thread instead self._use_popen = os.name == "nt" - def check_origin(self, origin): - if "ESPHOME_TRUSTED_DOMAINS" not in os.environ: - return super().check_origin(origin) - trusted_domains = [ - s.strip() for s in os.environ["ESPHOME_TRUSTED_DOMAINS"].split(",") - ] - url = urlparse(origin) - if url.hostname in trusted_domains: - return True - _LOGGER.info("check_origin %s, domain is not trusted", origin) - return False - def open(self, *args: str, **kwargs: str) -> None: """Handle new WebSocket connection.""" # Ensure messages from the subprocess are sent immediately @@ -601,7 +605,7 @@ DASHBOARD_SUBSCRIBER = DashboardSubscriber() @websocket_class -class DashboardEventsWebSocket(tornado.websocket.WebSocketHandler): +class DashboardEventsWebSocket(CheckOriginMixin, tornado.websocket.WebSocketHandler): """WebSocket handler for real-time dashboard events.""" _event_listeners: list[Callable[[], None]] | None = None diff --git a/esphome/espota2.py b/esphome/espota2.py index 2b1b9a8328..6349ad0fa8 100644 --- a/esphome/espota2.py +++ b/esphome/espota2.py @@ -322,8 +322,8 @@ def perform_ota( hash_func, nonce_size, hash_name = _AUTH_METHODS[auth] perform_auth(sock, password, hash_func, nonce_size, hash_name) - # Set higher timeout during upload - sock.settimeout(30.0) + # Timeout must match device-side OTA_SOCKET_TIMEOUT_DATA to prevent premature failures + sock.settimeout(90.0) upload_size = len(upload_contents) upload_size_encoded = [ @@ -402,7 +402,7 @@ def run_ota_impl_( ) _LOGGER.error( "(If this error persists, please set a static IP address: " - "https://esphome.io/components/wifi.html#manual-ips)" + "https://esphome.io/components/wifi/#manual-ips)" ) raise OTAError(err) from err diff --git a/esphome/idf_component.yml b/esphome/idf_component.yml index b27b6b8ed1..9bb5967248 100644 --- a/esphome/idf_component.yml +++ b/esphome/idf_component.yml @@ -6,7 +6,7 @@ dependencies: espressif/mdns: version: 1.9.1 espressif/esp_wifi_remote: - version: 1.1.5 + version: 1.2.2 rules: - if: "target in [esp32h2, esp32p4]" espressif/eppp_link: @@ -14,7 +14,7 @@ dependencies: rules: - if: "target in [esp32h2, esp32p4]" espressif/esp_hosted: - version: 2.6.1 + version: 2.7.0 rules: - if: "target in [esp32h2, esp32p4]" zorxx/multipart-parser: diff --git a/esphome/mqtt.py b/esphome/mqtt.py index 0d50edbc2c..042df12d67 100644 --- a/esphome/mqtt.py +++ b/esphome/mqtt.py @@ -192,7 +192,7 @@ def get_esphome_device_ip( data = json.loads(payload) if "name" not in data or data["name"] != dev_name: - _LOGGER.Warn("Wrong device answer") + _LOGGER.warning("Wrong device answer") return dev_ip = [] diff --git a/esphome/pins.py b/esphome/pins.py index 601c05880a..bdaa0e28ab 100644 --- a/esphome/pins.py +++ b/esphome/pins.py @@ -274,7 +274,7 @@ def check_strapping_pin(conf, strapping_pin_list: set[int], logger: Logger): logger.warning( f"GPIO{num} is a strapping PIN and should only be used for I/O with care.\n" "Attaching external pullup/down resistors to strapping pins can cause unexpected failures.\n" - "See https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins", + "See https://esphome.io/guides/faq/#why-am-i-getting-a-warning-about-strapping-pins", ) # mitigate undisciplined use of strapping: if num not in strapping_pin_list and conf.get(CONF_IGNORE_STRAPPING_WARNING): diff --git a/esphome/util.py b/esphome/util.py index d41800dc20..7b896de27e 100644 --- a/esphome/util.py +++ b/esphome/util.py @@ -375,6 +375,6 @@ def get_esp32_arduino_flash_error_help() -> str | None: + "For detailed migration instructions, see:\n" + color( AnsiFore.BLUE, - "https://esphome.io/guides/esp32_arduino_to_idf.html\n\n", + "https://esphome.io/guides/esp32_arduino_to_idf/\n\n", ) ) diff --git a/esphome/wizard.py b/esphome/wizard.py index 97343eea99..d77450b04d 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -411,9 +411,7 @@ def wizard(path: Path) -> int: "https://docs.platformio.org/en/latest/platforms/espressif8266.html#boards" ) elif platform == "RP2040": - board_link = ( - "https://www.raspberrypi.com/documentation/microcontrollers/rp2040.html" - ) + board_link = "https://www.raspberrypi.com/documentation/microcontrollers/silicon.html#rp2040" elif platform in ["BK72XX", "LN882X", "RTL87XX"]: board_link = "https://docs.libretiny.eu/docs/status/supported/" else: @@ -555,7 +553,7 @@ def wizard(path: Path) -> int: safe_print("Next steps:") safe_print(" > Follow the rest of the getting started guide:") safe_print( - " > https://esphome.io/guides/getting_started_command_line.html#adding-some-features" + " > https://esphome.io/guides/getting_started_command_line/#adding-some-features" ) safe_print(" > to learn how to customize ESPHome and install it to your device.") return 0 diff --git a/platformio.ini b/platformio.ini index 94f58f84ab..d37c798c05 100644 --- a/platformio.ini +++ b/platformio.ini @@ -32,20 +32,24 @@ build_flags = ; This are common settings for all environments. [common] -lib_deps = - esphome/noise-c@0.1.10 ; api - improv/Improv@1.2.4 ; improv_serial / esp32_improv +; Base dependencies for all environments +lib_deps_base = bblanchon/ArduinoJson@7.4.2 ; json wjtje/qr-code-generator-library@1.7.0 ; qr_code functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 pavlodn/HaierProtocol@0.9.31 ; haier - kikuchan98/pngle@1.1.0 ; online_image https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps +; This is using the repository until a new release is published to PlatformIO + https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library + lvgl/lvgl@8.4.0 ; lvgl + +lib_deps = + ${common.lib_deps_base} + esphome/noise-c@0.1.10 ; api + improv/Improv@1.2.4 ; improv_serial / esp32_improv + kikuchan98/pngle@1.1.0 ; online_image ; Using the repository directly, otherwise ESP-IDF can't use the library https://github.com/bitbank2/JPEGDEC.git#ca1e0f2 ; online_image - ; This is using the repository until a new release is published to PlatformIO - https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library - lvgl/lvgl@8.4.0 ; lvgl ; This dependency is used only in unit tests. ; Must coincide with PLATFORMIO_GOOGLE_TEST_LIB in scripts/cpp_unit_test.py ; See scripts/cpp_unit_test.py and tests/components/README.md @@ -152,6 +156,7 @@ lib_deps = esphome/ESP32-audioI2S@2.3.0 ; i2s_audio droscy/esp_wireguard@0.4.2 ; wireguard esphome/esp-audio-libs@2.0.1 ; audio + esphome/esp-hub75@0.1.6 ; hub75 build_flags = ${common:arduino.build_flags} @@ -175,6 +180,7 @@ lib_deps = droscy/esp_wireguard@0.4.2 ; wireguard kahrendt/ESPMicroSpeechFeatures@1.1.0 ; micro_wake_word esphome/esp-audio-libs@2.0.1 ; audio + esphome/esp-hub75@0.1.6 ; hub75 build_flags = ${common:idf.build_flags} -Wno-nonnull-compare @@ -234,13 +240,7 @@ build_flags = -DUSE_ZEPHYR -DUSE_NRF52 lib_deps = - bblanchon/ArduinoJson@7.4.2 ; json - wjtje/qr-code-generator-library@1.7.0 ; qr_code - pavlodn/HaierProtocol@0.9.31 ; haier - functionpointer/arduino-MLX90393@1.0.2 ; mlx90393 - https://github.com/esphome/TinyGPSPlus.git#v1.1.0 ; gps - https://github.com/Sensirion/arduino-gas-index-algorithm.git#3.2.1 ; Sensirion Gas Index Algorithm Arduino Library - lvgl/lvgl@8.4.0 ; lvgl + ${common.lib_deps_base} ; All the actual environments are defined below. @@ -378,6 +378,18 @@ build_flags = build_unflags = ${common.build_unflags} +;;;;;;;; ESP32-P4 ;;;;;;;; + +[env:esp32p4-idf] +extends = common:esp32-idf +board = esp32-p4-evboard + +board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32p4-idf +build_flags = + ${common:esp32-idf.build_flags} + ${flags:runtime.build_flags} + -DUSE_ESP32_VARIANT_ESP32P4 + ;;;;;;;; ESP32-S2 ;;;;;;;; [env:esp32s2-arduino] @@ -466,18 +478,6 @@ build_flags = build_unflags = ${common.build_unflags} -;;;;;;;; ESP32-P4 ;;;;;;;; - -[env:esp32p4-idf] -extends = common:esp32-idf -board = esp32-p4-evboard - -board_build.esp-idf.sdkconfig_path = .temp/sdkconfig-esp32p4-idf -build_flags = - ${common:esp32-idf.build_flags} - ${flags:runtime.build_flags} - -DUSE_ESP32_VARIANT_ESP32P4 - ;;;;;;;; RP2040 ;;;;;;;; [env:rp2040-pico-arduino] diff --git a/requirements.txt b/requirements.txt index 5d824a6859..7a50e1296f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ PyYAML==6.0.3 paho-mqtt==1.6.1 colorama==0.4.6 icmplib==3.0.4 -tornado==6.5.2 +tornado==6.5.3 tzlocal==5.3.1 # from time tzdata>=2021.1 # from time pyserial==3.5 @@ -12,7 +12,7 @@ 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.10.0 +aioesphomeapi==43.2.1 zeroconf==0.148.0 puremagic==1.30 ruamel.yaml==0.18.16 # dashboard_import diff --git a/requirements_test.txt b/requirements_test.txt index 9d55d23272..bfb833e04d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,11 +1,11 @@ pylint==4.0.4 flake8==7.3.0 # also change in .pre-commit-config.yaml when updating -ruff==0.14.7 # also change in .pre-commit-config.yaml when updating +ruff==0.14.9 # 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==9.0.1 +pytest==9.0.2 pytest-cov==7.0.0 pytest-mock==3.15.1 pytest-asyncio==1.3.0 diff --git a/script/analyze_component_buses.py b/script/analyze_component_buses.py index 27a36f889f..427602dff2 100755 --- a/script/analyze_component_buses.py +++ b/script/analyze_component_buses.py @@ -87,6 +87,7 @@ ISOLATED_COMPONENTS = { "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", + "usb_cdc_acm": "Depends on tinyusb which conflicts with usb_host", } diff --git a/script/ci-custom.py b/script/ci-custom.py index 106aa438fe..609d89403f 100755 --- a/script/ci-custom.py +++ b/script/ci-custom.py @@ -554,10 +554,10 @@ def convert_path_to_relative(abspath, current): "esphome/components/web_server/__init__.py", ], ) -def lint_relative_py_import(fname, line, col, content): +def lint_relative_py_import(fname: Path, line, col, content): import_line = content.splitlines()[line] abspath = import_line[col:].split(" ")[0] - current = fname.removesuffix(".py").replace(os.path.sep, ".") + current = str(fname).removesuffix(".py").replace(os.path.sep, ".") replacement = convert_path_to_relative(abspath, current) newline = import_line.replace(abspath, replacement) return ( diff --git a/script/ci_memory_impact_comment.py b/script/ci_memory_impact_comment.py index 1331a44d03..a296130645 100755 --- a/script/ci_memory_impact_comment.py +++ b/script/ci_memory_impact_comment.py @@ -215,6 +215,20 @@ def prepare_symbol_changes_data( } +def format_components_str(components: list[str]) -> str: + """Format a list of components for display. + + Args: + components: List of component names + + Returns: + Formatted string with backtick-quoted component names + """ + if len(components) == 1: + return f"`{components[0]}`" + return ", ".join(f"`{c}`" for c in sorted(components)) + + def prepare_component_breakdown_data( target_analysis: dict | None, pr_analysis: dict | None ) -> list[tuple[str, int, int, int]] | None: @@ -316,11 +330,10 @@ def create_comment_body( } # Format components list + context["components_str"] = format_components_str(components) if len(components) == 1: - context["components_str"] = f"`{components[0]}`" context["config_note"] = "a representative test configuration" else: - context["components_str"] = ", ".join(f"`{c}`" for c in sorted(components)) context["config_note"] = ( f"a merged configuration with {len(components)} components" ) @@ -502,6 +515,43 @@ def post_or_update_comment(pr_number: str, comment_body: str) -> None: print("Comment posted/updated successfully", file=sys.stderr) +def create_target_unavailable_comment( + pr_data: dict, +) -> str: + """Create a comment body when target branch data is unavailable. + + This happens when the target branch (dev/beta/release) fails to build. + This can occur because: + 1. The target branch has a build issue independent of this PR + 2. This PR fixes a build issue on the target branch + In either case, we only care that the PR branch builds successfully. + + Args: + pr_data: Dictionary with PR branch analysis results + + Returns: + Formatted comment body + """ + components = pr_data.get("components", []) + platform = pr_data.get("platform", "unknown") + pr_ram = pr_data.get("ram_bytes", 0) + pr_flash = pr_data.get("flash_bytes", 0) + + env = Environment( + loader=FileSystemLoader(TEMPLATE_DIR), + trim_blocks=True, + lstrip_blocks=True, + ) + template = env.get_template("ci_memory_impact_target_unavailable.j2") + return template.render( + comment_marker=COMMENT_MARKER, + components_str=format_components_str(components), + platform=platform, + pr_ram=format_bytes(pr_ram), + pr_flash=format_bytes(pr_flash), + ) + + def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( @@ -523,15 +573,25 @@ def main() -> int: # Load analysis JSON files (all data comes from JSON for security) target_data: dict | None = load_analysis_json(args.target_json) - if not target_data: - print("Error: Failed to load target analysis JSON", file=sys.stderr) - sys.exit(1) - pr_data: dict | None = load_analysis_json(args.pr_json) + + # PR data is required - if the PR branch can't build, that's a real error if not pr_data: print("Error: Failed to load PR analysis JSON", file=sys.stderr) sys.exit(1) + # Target data is optional - target branch (dev) may fail to build because: + # 1. The target branch has a build issue independent of this PR + # 2. This PR fixes a build issue on the target branch + if not target_data: + print( + "Warning: Target branch analysis unavailable, posting limited comment", + file=sys.stderr, + ) + comment_body = create_target_unavailable_comment(pr_data) + post_or_update_comment(args.pr_number, comment_body) + return 0 + # Extract detailed analysis if available target_analysis: dict | None = None pr_analysis: dict | None = None diff --git a/script/templates/ci_memory_impact_target_unavailable.j2 b/script/templates/ci_memory_impact_target_unavailable.j2 new file mode 100644 index 0000000000..542bd49d85 --- /dev/null +++ b/script/templates/ci_memory_impact_target_unavailable.j2 @@ -0,0 +1,19 @@ +{{ comment_marker }} +## Memory Impact Analysis + +**Components:** {{ components_str }} +**Platform:** `{{ platform }}` + +| Metric | This PR | +|--------|---------| +| **RAM** | {{ pr_ram }} | +| **Flash** | {{ pr_flash }} | + +> ⚠️ **Target branch comparison unavailable** - The target branch failed to build. +> This can happen when the target branch has a build issue, or when this PR fixes a build issue on the target branch. +> The PR branch compiled successfully with the memory usage shown above. + +--- +> **Note:** This analysis measures **static RAM and Flash usage** only (compile-time allocation). + +*This analysis runs automatically when components change.* diff --git a/tests/component_tests/esp32/test_esp32.py b/tests/component_tests/esp32/test_esp32.py index 91e96f24d6..68bd3a5965 100644 --- a/tests/component_tests/esp32/test_esp32.py +++ b/tests/component_tests/esp32/test_esp32.py @@ -17,8 +17,7 @@ def test_esp32_config( ) -> None: set_core_config(PlatformFramework.ESP32_IDF) - from esphome.components.esp32 import CONFIG_SCHEMA - from esphome.components.esp32.const import VARIANT_ESP32, VARIANT_FRIENDLY + from esphome.components.esp32 import CONFIG_SCHEMA, VARIANT_ESP32, VARIANT_FRIENDLY # Example ESP32 configuration config = { diff --git a/tests/component_tests/mipi_spi/test_init.py b/tests/component_tests/mipi_spi/test_init.py index 56a52df2ab..0c7dea2286 100644 --- a/tests/component_tests/mipi_spi/test_init.py +++ b/tests/component_tests/mipi_spi/test_init.py @@ -304,14 +304,14 @@ def test_all_predefined_models( config = {"model": name} # Get the pins required by this model and find a compatible variant - pins = [ - pin - for pin in [ - model.get_default(pin, None) - for pin in ("dc_pin", "reset_pin", "cs_pin") - ] - if pin is not None - ] + pins = [] + for pin_name in ("dc_pin", "reset_pin", "cs_pin", "enable_pin"): + pin_value = model.get_default(pin_name, None) + if pin_value is not None: + if isinstance(pin_value, list): + pins.extend(pin_value) + else: + pins.append(pin_value) choose_variant_with_pins(pins) # Add required fields that don't have defaults diff --git a/tests/component_tests/packages/test_packages.py b/tests/component_tests/packages/test_packages.py index ac4e211fe6..22fb2c4e32 100644 --- a/tests/component_tests/packages/test_packages.py +++ b/tests/component_tests/packages/test_packages.py @@ -5,7 +5,8 @@ from unittest.mock import MagicMock, patch import pytest -from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass +from esphome.components.packages import CONFIG_SCHEMA, do_packages_pass, merge_packages +import esphome.config as config_module from esphome.config import resolve_extend_remove from esphome.config_helpers import Extend, Remove import esphome.config_validation as cv @@ -27,11 +28,13 @@ from esphome.const import ( CONF_REFRESH, CONF_SENSOR, CONF_SSID, + CONF_SUBSTITUTIONS, CONF_UPDATE_INTERVAL, CONF_URL, CONF_VARS, CONF_WIFI, ) +from esphome.core import CORE from esphome.util import OrderedDict # Test strings @@ -68,11 +71,12 @@ def fixture_basic_esphome(): def packages_pass(config): """Wrapper around packages_pass that also resolves Extend and Remove.""" config = do_packages_pass(config) + config = merge_packages(config) resolve_extend_remove(config) return config -def test_package_unused(basic_esphome, basic_wifi): +def test_package_unused(basic_esphome, basic_wifi) -> None: """ Ensures do_package_pass does not change a config if packages aren't used. """ @@ -82,7 +86,7 @@ def test_package_unused(basic_esphome, basic_wifi): assert actual == config -def test_package_invalid_dict(basic_esphome, basic_wifi): +def test_package_invalid_dict(basic_esphome, basic_wifi) -> None: """ If a url: key is present, it's expected to be well-formed remote package spec. Ensure an error is raised if not. Any other simple dict passed as a package will be merged as usual but may fail later validation. @@ -107,7 +111,7 @@ def test_package_invalid_dict(basic_esphome, basic_wifi): ], ], ) -def test_package_shorthand(packages): +def test_package_shorthand(packages) -> None: CONFIG_SCHEMA(packages) @@ -133,12 +137,12 @@ def test_package_shorthand(packages): [3], ], ) -def test_package_invalid(packages): +def test_package_invalid(packages) -> None: with pytest.raises(cv.Invalid): CONFIG_SCHEMA(packages) -def test_package_include(basic_wifi, basic_esphome): +def test_package_include(basic_wifi, basic_esphome) -> None: """ Tests the simple case where an independent config present in a package is added to the top-level config as is. @@ -159,7 +163,7 @@ def test_single_package( basic_esphome, basic_wifi, caplog: pytest.LogCaptureFixture, -): +) -> None: """ 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. @@ -176,13 +180,10 @@ def test_single_package( assert actual == expected - assert ( - "Including a single package under `packages:` is deprecated. Use a list instead." - in caplog.text - ) + assert "This method for including packages will go away in 2026.7.0" in caplog.text -def test_package_append(basic_wifi, basic_esphome): +def test_package_append(basic_wifi, basic_esphome) -> None: """ Tests the case where a key is present in both a package and top-level config. @@ -207,7 +208,7 @@ def test_package_append(basic_wifi, basic_esphome): assert actual == expected -def test_package_override(basic_wifi, basic_esphome): +def test_package_override(basic_wifi, basic_esphome) -> None: """ Ensures that the top-level configuration takes precedence over duplicate keys defined in a package. @@ -231,7 +232,7 @@ def test_package_override(basic_wifi, basic_esphome): assert actual == expected -def test_multiple_package_order(): +def test_multiple_package_order() -> None: """ Ensures that mutiple packages are merged in order. """ @@ -260,7 +261,7 @@ def test_multiple_package_order(): assert actual == expected -def test_package_list_merge(): +def test_package_list_merge() -> None: """ Ensures lists defined in both a package and the top-level config are merged correctly """ @@ -316,7 +317,7 @@ def test_package_list_merge(): assert actual == expected -def test_package_list_merge_by_id(): +def test_package_list_merge_by_id() -> None: """ Ensures that components with matching IDs are merged correctly. @@ -394,7 +395,7 @@ def test_package_list_merge_by_id(): assert actual == expected -def test_package_merge_by_id_with_list(): +def test_package_merge_by_id_with_list() -> None: """ Ensures that components with matching IDs are merged correctly when their configuration contains lists. @@ -433,7 +434,7 @@ def test_package_merge_by_id_with_list(): assert actual == expected -def test_package_merge_by_missing_id(): +def test_package_merge_by_missing_id() -> None: """ Ensures that a validation error is thrown when trying to extend a missing ID. """ @@ -469,7 +470,7 @@ def test_package_merge_by_missing_id(): assert error_raised -def test_package_list_remove_by_id(): +def test_package_list_remove_by_id() -> None: """ Ensures that components with matching IDs are removed correctly. @@ -520,7 +521,7 @@ def test_package_list_remove_by_id(): assert actual == expected -def test_multiple_package_list_remove_by_id(): +def test_multiple_package_list_remove_by_id() -> None: """ Ensures that components with matching IDs are removed correctly. @@ -566,7 +567,7 @@ def test_multiple_package_list_remove_by_id(): assert actual == expected -def test_package_dict_remove_by_id(basic_wifi, basic_esphome): +def test_package_dict_remove_by_id(basic_wifi, basic_esphome) -> None: """ Ensures that components with missing IDs are removed from dict. Ensures that the top-level configuration takes precedence over duplicate keys defined in a package. @@ -587,7 +588,7 @@ def test_package_dict_remove_by_id(basic_wifi, basic_esphome): assert actual == expected -def test_package_remove_by_missing_id(): +def test_package_remove_by_missing_id() -> None: """ Ensures that components with missing IDs are not merged. """ @@ -635,7 +636,7 @@ def test_package_remove_by_missing_id(): @patch("esphome.git.clone_or_update") def test_remote_packages_with_files_list( mock_clone_or_update, mock_is_file, mock_load_yaml -): +) -> None: """ Ensures that packages are loaded as mixed list of dictionary and strings """ @@ -707,7 +708,7 @@ def test_remote_packages_with_files_list( @patch("esphome.git.clone_or_update") def test_remote_packages_with_files_and_vars( mock_clone_or_update, mock_is_file, mock_load_yaml -): +) -> None: """ Ensures that packages are loaded as mixed list of dictionary and strings with vars """ @@ -796,3 +797,231 @@ def test_remote_packages_with_files_and_vars( actual = packages_pass(config) assert actual == expected + + +def test_packages_merge_substitutions() -> None: + """ + Tests that substitutions from packages in a complex package hierarchy + are extracted and merged into the top-level config. + """ + config = { + CONF_SUBSTITUTIONS: { + "a": 1, + "b": 2, + "c": 3, + }, + CONF_PACKAGES: { + "package1": { + "logger": { + "level": "DEBUG", + }, + CONF_PACKAGES: [ + { + CONF_SUBSTITUTIONS: { + "a": 10, + "e": 5, + }, + "sensor": [ + {"platform": "template", "id": "sensor1"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor2"}, + ], + }, + "package2": { + "logger": { + "level": "VERBOSE", + }, + }, + "package3": { + CONF_PACKAGES: [ + { + CONF_PACKAGES: [ + { + CONF_SUBSTITUTIONS: { + "b": 20, + "d": 4, + }, + "sensor": [ + {"platform": "template", "id": "sensor3"}, + ], + }, + ], + CONF_SUBSTITUTIONS: { + "b": 20, + "d": 6, + }, + "sensor": [ + {"platform": "template", "id": "sensor4"}, + ], + }, + ], + }, + }, + } + + expected = { + CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3}, + CONF_PACKAGES: { + "package1": { + "logger": { + "level": "DEBUG", + }, + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor1"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor2"}, + ], + }, + "package2": { + "logger": { + "level": "VERBOSE", + }, + }, + "package3": { + CONF_PACKAGES: [ + { + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor3"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor4"}, + ], + }, + ], + }, + }, + } + + actual = do_packages_pass(config) + assert actual == expected + + +def test_package_merge() -> None: + """ + Tests that all packages are merged into the top-level config. + """ + config = { + CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3}, + CONF_PACKAGES: { + "package1": { + "logger": { + "level": "DEBUG", + }, + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor1"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor2"}, + ], + }, + "package2": { + "logger": { + "level": "VERBOSE", + }, + }, + "package3": { + CONF_PACKAGES: [ + { + CONF_PACKAGES: [ + { + "sensor": [ + {"platform": "template", "id": "sensor3"}, + ], + }, + ], + "sensor": [ + {"platform": "template", "id": "sensor4"}, + ], + }, + ], + }, + }, + } + expected = { + "sensor": [ + {"platform": "template", "id": "sensor1"}, + {"platform": "template", "id": "sensor2"}, + {"platform": "template", "id": "sensor3"}, + {"platform": "template", "id": "sensor4"}, + ], + "logger": {"level": "VERBOSE"}, + CONF_SUBSTITUTIONS: {"a": 1, "e": 5, "b": 2, "d": 6, "c": 3}, + } + actual = merge_packages(config) + + assert actual == expected + + +@pytest.mark.parametrize( + "invalid_package", + [ + 6, + "some string", + ["some string"], + None, + True, + {"some_component": 8}, + {3: 2}, + {"some_component": r"${unevaluated expression}"}, + ], +) +def test_package_merge_invalid(invalid_package) -> None: + """ + Tests that trying to merge an invalid package raises an error. + """ + config = { + CONF_PACKAGES: { + "some_package": invalid_package, + }, + } + + with pytest.raises(cv.Invalid): + merge_packages(config) + + +def test_raw_config_contains_merged_esphome_from_package(tmp_path) -> None: + """Test that CORE.raw_config contains esphome section from merged package. + + This is a regression test for the bug where CORE.raw_config was set before + packages were merged, causing KeyError when components accessed + CORE.raw_config[CONF_ESPHOME] and the esphome section came from a package. + """ + # Create a config where esphome section comes from a package + test_config = OrderedDict() + test_config[CONF_PACKAGES] = { + "base": { + CONF_ESPHOME: {CONF_NAME: TEST_DEVICE_NAME}, + } + } + test_config["esp32"] = {"board": "esp32dev"} + + # Set up CORE for the test + test_yaml = tmp_path / "test.yaml" + test_yaml.write_text("# test config") + CORE.reset() + CORE.config_path = test_yaml + + # Call validate_config - this should merge packages and set CORE.raw_config + config_module.validate_config(test_config, {}) + + # Verify that CORE.raw_config contains the esphome section from the package + assert CONF_ESPHOME in CORE.raw_config, ( + "CORE.raw_config should contain esphome section after package merge" + ) + assert CORE.raw_config[CONF_ESPHOME][CONF_NAME] == TEST_DEVICE_NAME diff --git a/tests/component_tests/psram/test_psram.py b/tests/component_tests/psram/test_psram.py index 86bc29cc84..0924e66adc 100644 --- a/tests/component_tests/psram/test_psram.py +++ b/tests/component_tests/psram/test_psram.py @@ -4,7 +4,7 @@ from typing import Any import pytest -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( KEY_VARIANT, VARIANT_ESP32, VARIANT_ESP32C2, diff --git a/tests/components/api/common-base.yaml b/tests/components/api/common-base.yaml index 0416cebf9b..c766b61b13 100644 --- a/tests/components/api/common-base.yaml +++ b/tests/components/api/common-base.yaml @@ -181,6 +181,99 @@ api: else: - logger.log: "Skipped loops" - logger.log: "After combined test" + # ========================================================================== + # supports_response: status (auto-detected - api.respond without data) + # Has call_id only - reports success/error without data payload + # ========================================================================== + - action: test_respond_status + then: + - api.respond: + success: true + - logger.log: + format: "Status response sent (call_id=%d)" + args: [call_id] + + - action: test_respond_status_error + variables: + error_msg: string + then: + - api.respond: + success: false + error_message: !lambda 'return error_msg;' + + # ========================================================================== + # supports_response: optional (auto-detected - api.respond with data) + # Has call_id and return_response - client decides if it wants response + # ========================================================================== + - action: test_respond_optional + variables: + sensor_name: string + value: float + then: + - logger.log: + format: "Optional response (call_id=%d, return_response=%d)" + args: [call_id, return_response] + - api.respond: + data: !lambda |- + root["sensor"] = sensor_name; + root["value"] = value; + root["unit"] = "Β°C"; + + - action: test_respond_optional_conditional + variables: + do_succeed: bool + then: + - if: + condition: + lambda: 'return do_succeed;' + then: + - api.respond: + success: true + data: !lambda |- + root["status"] = "ok"; + else: + - api.respond: + success: false + error_message: "Operation failed" + + # ========================================================================== + # supports_response: only (explicit - always expects data response) + # Has call_id only - response is always expected with data + # ========================================================================== + - action: test_respond_only + supports_response: only + variables: + input: string + then: + - logger.log: + format: "Only response (call_id=%d)" + args: [call_id] + - api.respond: + data: !lambda |- + root["input"] = input; + root["processed"] = true; + + - action: test_respond_only_nested + supports_response: only + then: + - api.respond: + data: !lambda |- + root["config"]["wifi"] = "connected"; + root["config"]["api"] = true; + root["items"][0] = "item1"; + root["items"][1] = "item2"; + + # ========================================================================== + # supports_response: none (no api.respond action) + # No call_id or return_response - just user variables + # ========================================================================== + - action: test_no_response + variables: + message: string + then: + - logger.log: + format: "No response action: %s" + args: [message.c_str()] event: - platform: template diff --git a/tests/components/cc1101/common.yaml b/tests/components/cc1101/common.yaml new file mode 100644 index 0000000000..93f03e582e --- /dev/null +++ b/tests/components/cc1101/common.yaml @@ -0,0 +1,37 @@ +cc1101: + id: transceiver + cs_pin: ${cs_pin} + gdo0_pin: ${gdo0_pin} + frequency: 433.92MHz + if_frequency: 153kHz + filter_bandwidth: 203kHz + channel: 0 + channel_spacing: 200kHz + symbol_rate: 4800 + modulation_type: GFSK + packet_mode: true + packet_length: 8 + crc_enable: true + whitening: false + sync_mode: "16/16" + sync0: 0x91 + sync1: 0xD3 + num_preamble: 2 + on_packet: + then: + - lambda: |- + ESP_LOGD("cc1101", "packet %s rssi %.1f dBm lqi %u", format_hex(x).c_str(), rssi, lqi); + +button: + - platform: template + name: "CC1101 Button" + on_press: + then: + - cc1101.begin_tx: transceiver + - cc1101.begin_rx: transceiver + - cc1101.set_idle: transceiver + - cc1101.reset: transceiver + - cc1101.send_packet: + data: [0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef] + - cc1101.send_packet: !lambda |- + return {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}; diff --git a/tests/components/cc1101/test.esp32-idf.yaml b/tests/components/cc1101/test.esp32-idf.yaml new file mode 100644 index 0000000000..966f11bb64 --- /dev/null +++ b/tests/components/cc1101/test.esp32-idf.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO5 + gdo0_pin: GPIO4 + +packages: + spi: !include ../../test_build_components/common/spi/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/cc1101/test.esp8266.yaml b/tests/components/cc1101/test.esp8266.yaml new file mode 100644 index 0000000000..6f0f078507 --- /dev/null +++ b/tests/components/cc1101/test.esp8266.yaml @@ -0,0 +1,8 @@ +substitutions: + cs_pin: GPIO5 + gdo0_pin: GPIO4 + +packages: + spi: !include ../../test_build_components/common/spi/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/esp32/test.esp32-p4-idf.yaml b/tests/components/esp32/test.esp32-p4-idf.yaml index 1c243ef459..00a4ceec27 100644 --- a/tests/components/esp32/test.esp32-p4-idf.yaml +++ b/tests/components/esp32/test.esp32-p4-idf.yaml @@ -7,7 +7,7 @@ esp32: components: - espressif/mdns^1.8.2 - name: espressif/esp_hosted - ref: 2.6.6 + ref: 2.7.0 advanced: enable_idf_experimental_features: yes diff --git a/tests/components/espnow/common.yaml b/tests/components/espnow/common.yaml index 895ffb9d15..b724af54e0 100644 --- a/tests/components/espnow/common.yaml +++ b/tests/components/espnow/common.yaml @@ -62,7 +62,7 @@ packet_transport: sensors: - temp_sensor providers: - - name: test_provider + - name: test-provider encryption: key: "0123456789abcdef0123456789abcdef" @@ -71,6 +71,6 @@ sensor: id: temp_sensor - platform: packet_transport - provider: test_provider + provider: test-provider remote_id: temp_sensor id: remote_temp diff --git a/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml b/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml new file mode 100644 index 0000000000..09da8d90d9 --- /dev/null +++ b/tests/components/ethernet/test-lan8720-with-expander.esp32-idf.yaml @@ -0,0 +1,15 @@ +<<: !include common-lan8720.yaml + +sn74hc165: + - id: sn74hc165_hub + clock_pin: GPIO13 + data_pin: GPIO14 + load_pin: GPIO15 + sr_count: 3 + +binary_sensor: + - platform: gpio + pin: + sn74hc165: sn74hc165_hub + number: 19 + id: relay_2 diff --git a/tests/components/gree/common.yaml b/tests/components/gree/common.yaml index e706076034..1ddce781bb 100644 --- a/tests/components/gree/common.yaml +++ b/tests/components/gree/common.yaml @@ -1,5 +1,18 @@ climate: - platform: gree name: GREE - model: generic + id: my_gree_ac + model: YAN transmitter_id: xmitr + +switch: + - platform: gree + gree_id: my_gree_ac + light: + name: "AC Lights" + turbo: + name: "AC Turbo" + health: + name: "AC Health" + xfan: + name: "AC X-Fan" diff --git a/tests/components/hlw8032/common.yaml b/tests/components/hlw8032/common.yaml new file mode 100644 index 0000000000..1b4e537576 --- /dev/null +++ b/tests/components/hlw8032/common.yaml @@ -0,0 +1,17 @@ +sensor: + - platform: hlw8032 + voltage: + name: HLW8032 Voltage + id: hlw8032_voltage + current: + name: HLW8032 Current + id: hlw8032_current + power: + name: HLW8032 Power + id: hlw8032_power + apparent_power: + name: HLW8032 Apparent Power + id: hlw8032_apparent_power + power_factor: + name: HLW8032 Power Factor + id: hlw8032_power_factor diff --git a/tests/components/hlw8032/test.esp32-idf.yaml b/tests/components/hlw8032/test.esp32-idf.yaml new file mode 100644 index 0000000000..911b867708 --- /dev/null +++ b/tests/components/hlw8032/test.esp32-idf.yaml @@ -0,0 +1,4 @@ +packages: + uart_4800_even: !include ../../test_build_components/common/uart_4800_even/esp32-idf.yaml + +<<: !include common.yaml diff --git a/tests/components/hlw8032/test.esp8266-ard.yaml b/tests/components/hlw8032/test.esp8266-ard.yaml new file mode 100644 index 0000000000..9c1c11c6a1 --- /dev/null +++ b/tests/components/hlw8032/test.esp8266-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_4800_even: !include ../../test_build_components/common/uart_4800_even/esp8266-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hlw8032/test.rp2040-ard.yaml b/tests/components/hlw8032/test.rp2040-ard.yaml new file mode 100644 index 0000000000..40b6e81bb2 --- /dev/null +++ b/tests/components/hlw8032/test.rp2040-ard.yaml @@ -0,0 +1,4 @@ +packages: + uart_4800_even: !include ../../test_build_components/common/uart_4800_even/rp2040-ard.yaml + +<<: !include common.yaml diff --git a/tests/components/hmac_md5/common.yaml b/tests/components/hmac_md5/common.yaml new file mode 100644 index 0000000000..ac6d7ecbaa --- /dev/null +++ b/tests/components/hmac_md5/common.yaml @@ -0,0 +1,34 @@ +esphome: + on_boot: + - lambda: |- + // Test HMAC-MD5 functionality + #ifdef USE_MD5 + using esphome::hmac_md5::HmacMD5; + HmacMD5 hmac; + + // Test with key "key" and message "The quick brown fox jumps over the lazy dog" + const char* key = "key"; + const char* message = "The quick brown fox jumps over the lazy dog"; + + hmac.init(key, strlen(key)); + hmac.add(message, strlen(message)); + hmac.calculate(); + + char hex_output[33]; + hmac.get_hex(hex_output); + hex_output[32] = '\0'; + + ESP_LOGD("HMAC_MD5", "HMAC-MD5('%s', '%s') = %s", key, message, hex_output); + + // Expected: 80070713463e7749b90c2dc24911e275 + const char* expected = "80070713463e7749b90c2dc24911e275"; + if (strcmp(hex_output, expected) == 0) { + ESP_LOGI("HMAC_MD5", "Test PASSED"); + } else { + ESP_LOGE("HMAC_MD5", "Test FAILED. Expected %s", expected); + } + #else + ESP_LOGW("HMAC_MD5", "HMAC-MD5 not available on this platform"); + #endif + +hmac_md5: diff --git a/tests/components/hmac_md5/test.bk72xx-ard.yaml b/tests/components/hmac_md5/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_md5/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_md5/test.esp32-idf.yaml b/tests/components/hmac_md5/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_md5/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_md5/test.esp8266-ard.yaml b/tests/components/hmac_md5/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_md5/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_md5/test.host.yaml b/tests/components/hmac_md5/test.host.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_md5/test.host.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_md5/test.rp2040-ard.yaml b/tests/components/hmac_md5/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_md5/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/common.yaml b/tests/components/hmac_sha256/common.yaml new file mode 100644 index 0000000000..9bbed295fd --- /dev/null +++ b/tests/components/hmac_sha256/common.yaml @@ -0,0 +1,34 @@ +esphome: + on_boot: + - lambda: |- + // Test HMAC-SHA256 functionality + #ifdef USE_SHA256 + using esphome::hmac_sha256::HmacSHA256; + HmacSHA256 hmac; + + // Test with key "key" and message "The quick brown fox jumps over the lazy dog" + const char* key = "key"; + const char* message = "The quick brown fox jumps over the lazy dog"; + + hmac.init(key, strlen(key)); + hmac.add(message, strlen(message)); + hmac.calculate(); + + char hex_output[65]; + hmac.get_hex(hex_output); + hex_output[64] = '\0'; + + ESP_LOGD("HMAC_SHA256", "HMAC-SHA256('%s', '%s') = %s", key, message, hex_output); + + // Expected: f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8 + const char* expected = "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"; + if (strcmp(hex_output, expected) == 0) { + ESP_LOGI("HMAC_SHA256", "Test PASSED"); + } else { + ESP_LOGE("HMAC_SHA256", "Test FAILED. Expected %s", expected); + } + #else + ESP_LOGW("HMAC_SHA256", "HMAC-SHA256 not available on this platform"); + #endif + +hmac_sha256: diff --git a/tests/components/hmac_sha256/test.bk72xx-ard.yaml b/tests/components/hmac_sha256/test.bk72xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.bk72xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.esp32-ard.yaml b/tests/components/hmac_sha256/test.esp32-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.esp32-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.esp32-idf.yaml b/tests/components/hmac_sha256/test.esp32-idf.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.esp32-idf.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.esp8266-ard.yaml b/tests/components/hmac_sha256/test.esp8266-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.esp8266-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.host.yaml b/tests/components/hmac_sha256/test.host.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.host.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.rp2040-ard.yaml b/tests/components/hmac_sha256/test.rp2040-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.rp2040-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hmac_sha256/test.rtl87xx-ard.yaml b/tests/components/hmac_sha256/test.rtl87xx-ard.yaml new file mode 100644 index 0000000000..dade44d145 --- /dev/null +++ b/tests/components/hmac_sha256/test.rtl87xx-ard.yaml @@ -0,0 +1 @@ +<<: !include common.yaml diff --git a/tests/components/hub75/test.esp32-idf.yaml b/tests/components/hub75/test.esp32-idf.yaml new file mode 100644 index 0000000000..9f6bd57292 --- /dev/null +++ b/tests/components/hub75/test.esp32-idf.yaml @@ -0,0 +1,39 @@ +esp32: + board: esp32dev + framework: + type: esp-idf + +display: + - platform: hub75 + id: my_hub75 + panel_width: 64 + panel_height: 32 + double_buffer: true + brightness: 128 + r1_pin: GPIO25 + g1_pin: GPIO26 + b1_pin: GPIO27 + r2_pin: GPIO14 + g2_pin: GPIO12 + b2_pin: GPIO13 + a_pin: GPIO23 + b_pin: GPIO19 + c_pin: GPIO5 + d_pin: GPIO17 + e_pin: GPIO21 + lat_pin: GPIO4 + oe_pin: GPIO15 + clk_pin: GPIO16 + pages: + - id: page1_hub75 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: page2_hub75 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + on_page_change: + from: page1_hub75 + to: page2_hub75 + then: + lambda: |- + ESP_LOGD("display", "1 -> 2"); diff --git a/tests/components/hub75/test.esp32-s3-idf-board.yaml b/tests/components/hub75/test.esp32-s3-idf-board.yaml new file mode 100644 index 0000000000..9568ccf3aa --- /dev/null +++ b/tests/components/hub75/test.esp32-s3-idf-board.yaml @@ -0,0 +1,26 @@ +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +display: + - platform: hub75 + id: hub75_display_board + board: adafruit-matrix-portal-s3 + panel_width: 64 + panel_height: 32 + double_buffer: true + brightness: 128 + pages: + - id: page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: page2 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + on_page_change: + from: page1 + to: page2 + then: + lambda: |- + ESP_LOGD("display", "1 -> 2"); diff --git a/tests/components/hub75/test.esp32-s3-idf.yaml b/tests/components/hub75/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..db678c98a4 --- /dev/null +++ b/tests/components/hub75/test.esp32-s3-idf.yaml @@ -0,0 +1,39 @@ +esp32: + board: esp32-s3-devkitc-1 + framework: + type: esp-idf + +display: + - platform: hub75 + id: my_hub75 + panel_width: 64 + panel_height: 32 + double_buffer: true + brightness: 128 + r1_pin: GPIO42 + g1_pin: GPIO41 + b1_pin: GPIO40 + r2_pin: GPIO38 + g2_pin: GPIO39 + b2_pin: GPIO37 + a_pin: GPIO45 + b_pin: GPIO36 + c_pin: GPIO48 + d_pin: GPIO35 + e_pin: GPIO21 + lat_pin: GPIO47 + oe_pin: GPIO14 + clk_pin: GPIO2 + pages: + - id: page1 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + - id: page2 + lambda: |- + it.rectangle(0, 0, it.get_width(), it.get_height()); + on_page_change: + from: page1 + to: page2 + then: + lambda: |- + ESP_LOGD("display", "1 -> 2"); diff --git a/tests/components/micronova/common.yaml b/tests/components/micronova/common.yaml index 3cf8e36fb6..660970350a 100644 --- a/tests/components/micronova/common.yaml +++ b/tests/components/micronova/common.yaml @@ -5,7 +5,7 @@ button: - platform: micronova custom_button: name: Custom Micronova Button - memory_location: 0xA0 + memory_location: 0x20 memory_address: 0x7D memory_data: 0x0F @@ -16,6 +16,7 @@ number: step: 1 power_level: name: Micronova Power level + update_interval: 10s sensor: - platform: micronova @@ -41,3 +42,9 @@ switch: - platform: micronova stove: name: Stove on/off + +text_sensor: + - platform: micronova + stove_state: + name: Stove status + update_interval: 5s diff --git a/tests/components/micronova/test.esp32-idf.yaml b/tests/components/micronova/test.esp32-idf.yaml index 5cc3a234ca..6e5602818f 100644 --- a/tests/components/micronova/test.esp32-idf.yaml +++ b/tests/components/micronova/test.esp32-idf.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO13 packages: - uart: !include ../../test_build_components/common/uart/esp32-idf.yaml + uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml <<: !include common.yaml diff --git a/tests/components/micronova/test.esp8266-ard.yaml b/tests/components/micronova/test.esp8266-ard.yaml index ffe1e0a063..80792813ad 100644 --- a/tests/components/micronova/test.esp8266-ard.yaml +++ b/tests/components/micronova/test.esp8266-ard.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO15 packages: - uart: !include ../../test_build_components/common/uart/esp8266-ard.yaml + uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml <<: !include common.yaml diff --git a/tests/components/micronova/test.rp2040-ard.yaml b/tests/components/micronova/test.rp2040-ard.yaml index 6dc030e6b6..f069760378 100644 --- a/tests/components/micronova/test.rp2040-ard.yaml +++ b/tests/components/micronova/test.rp2040-ard.yaml @@ -2,6 +2,6 @@ substitutions: enable_rx_pin: GPIO3 packages: - uart: !include ../../test_build_components/common/uart/rp2040-ard.yaml + uart_1200_none_2stopbits: !include ../../test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml <<: !include common.yaml diff --git a/tests/components/pca9685/common.yaml b/tests/components/pca9685/common.yaml index 2e238b481c..9e2de6257a 100644 --- a/tests/components/pca9685/common.yaml +++ b/tests/components/pca9685/common.yaml @@ -2,6 +2,7 @@ pca9685: i2c_id: i2c_bus frequency: 500 address: 0x0 + phase_balancer: linear output: - platform: pca9685 diff --git a/tests/components/qr_code/common.yaml b/tests/components/qr_code/common.yaml index 5fec26c1cc..15b4e387c6 100644 --- a/tests/components/qr_code/common.yaml +++ b/tests/components/qr_code/common.yaml @@ -16,4 +16,4 @@ display: qr_code: - id: qr_code_homepage_qr - value: https://esphome.io/index.html + value: https://esphome.io/ diff --git a/tests/components/sps30/common.yaml b/tests/components/sps30/common.yaml index d40cd16b6d..a83477b764 100644 --- a/tests/components/sps30/common.yaml +++ b/tests/components/sps30/common.yaml @@ -30,3 +30,4 @@ sensor: id: workshop_PMC_10_0 address: 0x69 update_interval: 10s + idle_interval: 5min diff --git a/tests/components/text/common.yaml b/tests/components/text/common.yaml new file mode 100644 index 0000000000..26618be03a --- /dev/null +++ b/tests/components/text/common.yaml @@ -0,0 +1,25 @@ +text: + - platform: template + name: "Test Text" + id: test_text + optimistic: true + min_length: 0 + max_length: 100 + mode: text + + - platform: template + name: "Test Text with Pattern" + id: test_text_pattern + optimistic: true + min_length: 1 + max_length: 50 + pattern: "[A-Za-z0-9 ]+" + mode: text + + - platform: template + name: "Test Password" + id: test_password + optimistic: true + min_length: 8 + max_length: 32 + mode: password diff --git a/tests/components/text/test.esp32-idf.yaml b/tests/components/text/test.esp32-idf.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/text/test.esp32-idf.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/text/test.esp8266-ard.yaml b/tests/components/text/test.esp8266-ard.yaml new file mode 100644 index 0000000000..25cb37a0b4 --- /dev/null +++ b/tests/components/text/test.esp8266-ard.yaml @@ -0,0 +1,2 @@ +packages: + common: !include common.yaml diff --git a/tests/components/usb_cdc_acm/test.esp32-p4-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-p4-idf.yaml new file mode 100644 index 0000000000..4786c96bcc --- /dev/null +++ b/tests/components/usb_cdc_acm/test.esp32-p4-idf.yaml @@ -0,0 +1,5 @@ +<<: !include tinyusb_common.yaml + +usb_cdc_acm: + interfaces: + id: usb_cdc_acm1 diff --git a/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml new file mode 100644 index 0000000000..f159b38ff6 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.esp32-s2-idf.yaml @@ -0,0 +1,5 @@ +<<: !include tinyusb_common.yaml + +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 diff --git a/tests/components/usb_cdc_acm/test.esp32-s3-idf.yaml b/tests/components/usb_cdc_acm/test.esp32-s3-idf.yaml new file mode 100644 index 0000000000..6913fe21d5 --- /dev/null +++ b/tests/components/usb_cdc_acm/test.esp32-s3-idf.yaml @@ -0,0 +1,6 @@ +<<: !include tinyusb_common.yaml + +usb_cdc_acm: + interfaces: + - id: usb_cdc_acm1 + - id: usb_cdc_acm2 diff --git a/tests/components/usb_cdc_acm/tinyusb_common.yaml b/tests/components/usb_cdc_acm/tinyusb_common.yaml new file mode 100644 index 0000000000..cb3f48836a --- /dev/null +++ b/tests/components/usb_cdc_acm/tinyusb_common.yaml @@ -0,0 +1,8 @@ +tinyusb: + id: tinyusb_test + usb_lang_id: 0x0123 + usb_manufacturer_str: ESPHomeTestManufacturer + usb_product_id: 0x1234 + usb_product_str: ESPHomeTestProduct + usb_serial_str: ESPHomeTestSerialNumber + usb_vendor_id: 0x2345 diff --git a/tests/components/wifi_info/common.yaml b/tests/components/wifi_info/common.yaml index f87d381d0c..91dea6c66e 100644 --- a/tests/components/wifi_info/common.yaml +++ b/tests/components/wifi_info/common.yaml @@ -15,4 +15,6 @@ text_sensor: mac_address: name: MAC Address dns_address: - name: DNS ADdress + name: DNS Address + power_save_mode: + name: "WiFi Power Save Mode" diff --git a/tests/dashboard/test_web_server.py b/tests/dashboard/test_web_server.py index 385841b1c8..10ca6061e6 100644 --- a/tests/dashboard/test_web_server.py +++ b/tests/dashboard/test_web_server.py @@ -1567,3 +1567,90 @@ async def test_dashboard_yaml_loading_with_packages_and_secrets( # If we get here, secret resolution worked! assert "esphome" in config assert config["esphome"]["name"] == "test-download-secrets" + + +@pytest.mark.asyncio +async def test_websocket_check_origin_default_same_origin( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket uses default same-origin check when ESPHOME_TRUSTED_DOMAINS not set.""" + # Ensure ESPHOME_TRUSTED_DOMAINS is not set + env = os.environ.copy() + env.pop("ESPHOME_TRUSTED_DOMAINS", None) + with patch.dict(os.environ, env, clear=True): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + # Same origin should work (default Tornado behavior) + request = HTTPRequest( + url, headers={"Origin": f"http://127.0.0.1:{dashboard.port}"} + ) + ws = await websocket_connect(request) + try: + msg = await ws.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "initial_state" + finally: + ws.close() + + +@pytest.mark.asyncio +async def test_websocket_check_origin_trusted_domain( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket accepts connections from trusted domains.""" + with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + request = HTTPRequest(url, headers={"Origin": "https://trusted.example.com"}) + ws = await websocket_connect(request) + try: + # Should receive initial state + msg = await ws.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "initial_state" + finally: + ws.close() + + +@pytest.mark.asyncio +async def test_websocket_check_origin_untrusted_domain( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket rejects connections from untrusted domains.""" + with patch.dict(os.environ, {"ESPHOME_TRUSTED_DOMAINS": "trusted.example.com"}): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + request = HTTPRequest(url, headers={"Origin": "https://untrusted.example.com"}) + with pytest.raises(HTTPClientError) as exc_info: + await websocket_connect(request) + # Should get HTTP 403 Forbidden due to origin check failure + assert exc_info.value.code == 403 + + +@pytest.mark.asyncio +async def test_websocket_check_origin_multiple_trusted_domains( + dashboard: DashboardTestHelper, +) -> None: + """Test WebSocket accepts connections from multiple trusted domains.""" + with patch.dict( + os.environ, + {"ESPHOME_TRUSTED_DOMAINS": "first.example.com, second.example.com"}, + ): + from tornado.httpclient import HTTPRequest + + url = f"ws://127.0.0.1:{dashboard.port}/events" + # Test second domain in list (with space after comma) + request = HTTPRequest(url, headers={"Origin": "https://second.example.com"}) + ws = await websocket_connect(request) + try: + msg = await ws.read_message() + assert msg is not None + data = json.loads(msg) + assert data["event"] == "initial_state" + finally: + ws.close() diff --git a/tests/integration/README.md b/tests/integration/README.md index 2a6b6fe564..4de08777b0 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -7,7 +7,7 @@ This directory contains end-to-end integration tests for ESPHome, focusing on te - `conftest.py` - Common fixtures and utilities - `const.py` - Constants used throughout the integration tests - `types.py` - Type definitions for fixtures and functions -- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `build_key_to_entity_mapping`) +- `state_utils.py` - State handling utilities (e.g., `InitialStateHelper`, `find_entity`, `require_entity`) - `fixtures/` - YAML configuration files for tests - `test_*.py` - Individual test files @@ -53,6 +53,28 @@ The `InitialStateHelper` class solves a common problem in integration tests: whe **Future work:** Consider converting existing integration tests to use `InitialStateHelper` for more reliable state tracking and to eliminate race conditions related to initial state broadcasts. +#### Entity Lookup Helpers (`state_utils.py`) + +Two helper functions simplify finding entities in test code: + +**`find_entity(entities, object_id_substring, entity_type=None)`** +- Finds an entity by searching for a substring in its `object_id` (case-insensitive) +- Optionally filters by entity type (e.g., `BinarySensorInfo`) +- Returns `None` if not found + +**`require_entity(entities, object_id_substring, entity_type=None, description=None)`** +- Same as `find_entity` but raises `AssertionError` if not found +- Use `description` parameter for clearer error messages + +```python +from aioesphomeapi import BinarySensorInfo +from .state_utils import require_entity + +# Find entities with clear error messages +binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo) +button = require_entity(entities, "set_true", description="Set True button") +``` + ### Writing Tests The simplest way to write a test is to use the `run_compiled` and `api_client_connected` fixtures: @@ -230,7 +252,7 @@ my_service = next((s for s in services if s.name == "my_service"), None) assert my_service is not None # Execute with parameters -client.execute_service(my_service, {"param1": "value1", "param2": 42}) +await client.execute_service(my_service, {"param1": "value1", "param2": 42}) ``` ##### Multiple Entity Tracking diff --git a/tests/integration/fixtures/api_action_responses.yaml b/tests/integration/fixtures/api_action_responses.yaml new file mode 100644 index 0000000000..755623b7bb --- /dev/null +++ b/tests/integration/fixtures/api_action_responses.yaml @@ -0,0 +1,93 @@ +esphome: + name: api-action-responses-test + +host: + +logger: + level: DEBUG + +api: + actions: + # ========================================================================== + # supports_response: none (default - no api.respond action) + # No call_id or return_response - just user variables + # ========================================================================== + - action: action_no_response + variables: + message: string + then: + - logger.log: + format: "ACTION_NO_RESPONSE called with: %s" + args: [message.c_str()] + + # ========================================================================== + # supports_response: status (auto-detected - api.respond without data) + # Has call_id only - reports success/error without data payload + # ========================================================================== + - action: action_status_response + variables: + should_succeed: bool + then: + - if: + condition: + lambda: 'return should_succeed;' + then: + - api.respond: + success: true + - logger.log: + format: "ACTION_STATUS_RESPONSE success (call_id=%d)" + args: [call_id] + else: + - api.respond: + success: false + error_message: "Intentional failure for testing" + - logger.log: + format: "ACTION_STATUS_RESPONSE error (call_id=%d)" + args: [call_id] + + # ========================================================================== + # supports_response: optional (auto-detected - api.respond with data) + # Has call_id and return_response - client decides if it wants response + # ========================================================================== + - action: action_optional_response + variables: + value: int + then: + - logger.log: + format: "ACTION_OPTIONAL_RESPONSE (call_id=%d, return_response=%d, value=%d)" + args: [call_id, return_response, value] + - api.respond: + data: !lambda |- + root["input"] = value; + root["doubled"] = value * 2; + + # ========================================================================== + # supports_response: only (explicit - always expects data response) + # Has call_id only - response is always expected with data + # ========================================================================== + - action: action_only_response + supports_response: only + variables: + name: string + then: + - logger.log: + format: "ACTION_ONLY_RESPONSE (call_id=%d, name=%s)" + args: [call_id, name.c_str()] + - api.respond: + data: !lambda |- + root["greeting"] = "Hello, " + name + "!"; + root["length"] = name.length(); + + # Test action with nested JSON response + - action: action_nested_json + supports_response: only + then: + - logger.log: + format: "ACTION_NESTED_JSON (call_id=%d)" + args: [call_id] + - api.respond: + data: !lambda |- + root["config"]["wifi"]["connected"] = true; + root["config"]["api"]["port"] = 6053; + root["items"][0] = "first"; + root["items"][1] = "second"; diff --git a/tests/integration/fixtures/api_action_timeout.yaml b/tests/integration/fixtures/api_action_timeout.yaml new file mode 100644 index 0000000000..405d9d0e2b --- /dev/null +++ b/tests/integration/fixtures/api_action_timeout.yaml @@ -0,0 +1,45 @@ +esphome: + name: api-action-timeout-test + # Use a short timeout for testing (500ms instead of 30s) + platformio_options: + build_flags: + - "-DUSE_API_ACTION_CALL_TIMEOUT_MS=500" + +host: + +logger: + level: DEBUG + +api: + actions: + # Action that responds immediately - should work fine + - action: action_immediate + supports_response: only + then: + - logger.log: "ACTION_IMMEDIATE responding" + - api.respond: + data: !lambda |- + root["status"] = "immediate"; + + # Action that delays 200ms before responding - should work (within 500ms timeout) + - action: action_short_delay + supports_response: only + then: + - logger.log: "ACTION_SHORT_DELAY starting" + - delay: 200ms + - logger.log: "ACTION_SHORT_DELAY responding" + - api.respond: + data: !lambda |- + root["status"] = "short_delay"; + + # Action that delays 1s before responding - should fail (exceeds 500ms timeout) + # The api.respond will log a warning because the action call was already cleaned up + - action: action_long_delay + supports_response: only + then: + - logger.log: "ACTION_LONG_DELAY starting" + - delay: 1s + - logger.log: "ACTION_LONG_DELAY responding (after timeout)" + - api.respond: + data: !lambda |- + root["status"] = "long_delay"; diff --git a/tests/integration/fixtures/api_custom_services.yaml b/tests/integration/fixtures/api_custom_services.yaml index a597c74126..827bee93a6 100644 --- a/tests/integration/fixtures/api_custom_services.yaml +++ b/tests/integration/fixtures/api_custom_services.yaml @@ -5,6 +5,7 @@ host: # This is required for CustomAPIDevice to work api: custom_services: true + homeassistant_states: true # Also test that YAML services still work actions: - action: test_yaml_service diff --git a/tests/integration/fixtures/api_homeassistant.yaml b/tests/integration/fixtures/api_homeassistant.yaml index ce8628977a..8fe23b9a19 100644 --- a/tests/integration/fixtures/api_homeassistant.yaml +++ b/tests/integration/fixtures/api_homeassistant.yaml @@ -17,6 +17,7 @@ api: - button.press: test_all_empty_service - button.press: test_rapid_service_calls - button.press: test_read_ha_states + - button.press: test_action_response_error - number.set: id: ha_number value: 42.5 @@ -309,3 +310,24 @@ button: } else { ESP_LOGI("test", "HA Empty State has no value (expected)"); } + + # Test 9: Action response error handling (tests StringRef error message) + - platform: template + name: "Test Action Response Error" + id: test_action_response_error + on_press: + - logger.log: "Testing action response error handling" + - homeassistant.action: + action: nonexistent.action_for_error_test + data: + test_field: "test_value" + on_error: + - lambda: |- + // This tests that StringRef error message works correctly + // The error variable is std::string (converted from StringRef) + ESP_LOGI("test", "Action error received: %s", error.c_str()); + - logger.log: + format: "Action failed with error message length: %d" + args: ['error.size()'] + on_success: + - logger.log: "Action succeeded unexpectedly" diff --git a/tests/integration/fixtures/api_message_size_batching.yaml b/tests/integration/fixtures/api_message_size_batching.yaml index c730dc1aa3..0fed311e63 100644 --- a/tests/integration/fixtures/api_message_size_batching.yaml +++ b/tests/integration/fixtures/api_message_size_batching.yaml @@ -143,6 +143,7 @@ text: mode: text min_length: 0 max_length: 255 + pattern: "[A-Za-z0-9 ]+" initial_value: "Initial value" update_interval: 5.0s diff --git a/tests/integration/fixtures/binary_sensor_invalidate_state.yaml b/tests/integration/fixtures/binary_sensor_invalidate_state.yaml new file mode 100644 index 0000000000..4016cfe281 --- /dev/null +++ b/tests/integration/fixtures/binary_sensor_invalidate_state.yaml @@ -0,0 +1,39 @@ +esphome: + name: test-binary-sensor-invalidate + +host: +api: + batch_delay: 0ms # Disable batching to receive all state updates +logger: + level: DEBUG + +# Template binary sensor that we can control +binary_sensor: + - platform: template + name: "Test Binary Sensor" + id: test_binary_sensor + +# Buttons to control the binary sensor state +button: + - platform: template + name: "Set True" + id: set_true_button + on_press: + - binary_sensor.template.publish: + id: test_binary_sensor + state: true + + - platform: template + name: "Set False" + id: set_false_button + on_press: + - binary_sensor.template.publish: + id: test_binary_sensor + state: false + + - platform: template + name: "Invalidate State" + id: invalidate_button + on_press: + - binary_sensor.invalidate_state: + id: test_binary_sensor diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp index c8581b3d2f..c86ab99242 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.cpp @@ -17,6 +17,10 @@ void CustomAPIDeviceComponent::setup() { // Test array types register_service(&CustomAPIDeviceComponent::on_service_with_arrays, "custom_service_with_arrays", {"bool_array", "int_array", "float_array", "string_array"}); + + // Test Home Assistant state subscription using std::string API (custom_api_device.h) + // This tests the backward compatibility of the std::string overloads + subscribe_homeassistant_state(&CustomAPIDeviceComponent::on_ha_state_changed, std::string("sensor.custom_test")); } void CustomAPIDeviceComponent::on_test_service() { ESP_LOGI(TAG, "Custom test service called!"); } @@ -48,6 +52,12 @@ void CustomAPIDeviceComponent::on_service_with_arrays(std::vector bool_arr } } +// NOLINTNEXTLINE(performance-unnecessary-value-param) +void CustomAPIDeviceComponent::on_ha_state_changed(std::string entity_id, std::string state) { + ESP_LOGI(TAG, "Home Assistant state changed for %s: %s", entity_id.c_str(), state.c_str()); + ESP_LOGI(TAG, "This subscription uses std::string API for backward compatibility"); +} + } // namespace custom_api_device_component } // namespace esphome #endif // USE_API diff --git a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h index 92960746d9..4d519d3ed1 100644 --- a/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h +++ b/tests/integration/fixtures/external_components/custom_api_device_component/custom_api_device_component.h @@ -22,6 +22,10 @@ class CustomAPIDeviceComponent : public Component, public CustomAPIDevice { void on_service_with_arrays(std::vector bool_array, std::vector int_array, std::vector float_array, std::vector string_array); + + // Test Home Assistant state subscription with std::string API + // NOLINTNEXTLINE(performance-unnecessary-value-param) + void on_ha_state_changed(std::string entity_id, std::string state); }; } // namespace custom_api_device_component diff --git a/tests/integration/fixtures/text_command.yaml b/tests/integration/fixtures/text_command.yaml new file mode 100644 index 0000000000..cc91e2f792 --- /dev/null +++ b/tests/integration/fixtures/text_command.yaml @@ -0,0 +1,37 @@ +esphome: + name: host-text-command-test + +host: + +api: + batch_delay: 0ms + +logger: + +text: + - platform: template + name: "Test Text" + id: test_text + optimistic: true + min_length: 0 + max_length: 255 + mode: text + initial_value: "initial" + + - platform: template + name: "Test Password" + id: test_password + optimistic: true + min_length: 4 + max_length: 32 + mode: password + initial_value: "secret" + + - platform: template + name: "Test Text Long" + id: test_text_long + optimistic: true + min_length: 0 + max_length: 255 + mode: text + initial_value: "" diff --git a/tests/integration/fixtures/text_sensor_raw_state.yaml b/tests/integration/fixtures/text_sensor_raw_state.yaml index 03aece0a04..54ab2e8dcc 100644 --- a/tests/integration/fixtures/text_sensor_raw_state.yaml +++ b/tests/integration/fixtures/text_sensor_raw_state.yaml @@ -20,6 +20,42 @@ text_sensor: filters: - to_upper + # StringRef-based filters (append, prepend, substitute, map) + - platform: template + name: "Append Sensor" + id: append_sensor + filters: + - append: " suffix" + + - platform: template + name: "Prepend Sensor" + id: prepend_sensor + filters: + - prepend: "prefix " + + - platform: template + name: "Substitute Sensor" + id: substitute_sensor + filters: + - substitute: + - foo -> bar + - hello -> world + + - platform: template + name: "Map Sensor" + id: map_sensor + filters: + - map: + - ON -> Active + - OFF -> Inactive + + - platform: template + name: "Chained Sensor" + id: chained_sensor + filters: + - prepend: "[" + - append: "]" + # Button to publish values and log raw_state vs state button: - platform: template @@ -52,3 +88,94 @@ button: args: - id(with_filter_sensor).state.c_str() - id(with_filter_sensor).get_raw_state().c_str() + + - platform: template + name: "Test Append Button" + id: test_append_button + on_press: + - text_sensor.template.publish: + id: append_sensor + state: "test" + - delay: 50ms + - logger.log: + format: "APPEND: state='%s'" + args: + - id(append_sensor).state.c_str() + + - platform: template + name: "Test Prepend Button" + id: test_prepend_button + on_press: + - text_sensor.template.publish: + id: prepend_sensor + state: "test" + - delay: 50ms + - logger.log: + format: "PREPEND: state='%s'" + args: + - id(prepend_sensor).state.c_str() + + - platform: template + name: "Test Substitute Button" + id: test_substitute_button + on_press: + - text_sensor.template.publish: + id: substitute_sensor + state: "foo says hello" + - delay: 50ms + - logger.log: + format: "SUBSTITUTE: state='%s'" + args: + - id(substitute_sensor).state.c_str() + + - platform: template + name: "Test Map ON Button" + id: test_map_on_button + on_press: + - text_sensor.template.publish: + id: map_sensor + state: "ON" + - delay: 50ms + - logger.log: + format: "MAP_ON: state='%s'" + args: + - id(map_sensor).state.c_str() + + - platform: template + name: "Test Map OFF Button" + id: test_map_off_button + on_press: + - text_sensor.template.publish: + id: map_sensor + state: "OFF" + - delay: 50ms + - logger.log: + format: "MAP_OFF: state='%s'" + args: + - id(map_sensor).state.c_str() + + - platform: template + name: "Test Map Unknown Button" + id: test_map_unknown_button + on_press: + - text_sensor.template.publish: + id: map_sensor + state: "UNKNOWN" + - delay: 50ms + - logger.log: + format: "MAP_UNKNOWN: state='%s'" + args: + - id(map_sensor).state.c_str() + + - platform: template + name: "Test Chained Button" + id: test_chained_button + on_press: + - text_sensor.template.publish: + id: chained_sensor + state: "value" + - delay: 50ms + - logger.log: + format: "CHAINED: state='%s'" + args: + - id(chained_sensor).state.c_str() diff --git a/tests/integration/state_utils.py b/tests/integration/state_utils.py index 6434a41ddf..b649056f2b 100644 --- a/tests/integration/state_utils.py +++ b/tests/integration/state_utils.py @@ -4,11 +4,74 @@ from __future__ import annotations import asyncio import logging +from typing import TypeVar from aioesphomeapi import ButtonInfo, EntityInfo, EntityState _LOGGER = logging.getLogger(__name__) +T = TypeVar("T", bound=EntityInfo) + + +def find_entity( + entities: list[EntityInfo], + object_id_substring: str, + entity_type: type[T] | None = None, +) -> T | EntityInfo | None: + """Find an entity by object_id substring and optionally by type. + + Args: + entities: List of entity info objects from the API + object_id_substring: Substring to search for in object_id (case-insensitive) + entity_type: Optional entity type to filter by (e.g., BinarySensorInfo) + + Returns: + The first matching entity, or None if not found + + Example: + binary_sensor = find_entity(entities, "test_binary_sensor", BinarySensorInfo) + button = find_entity(entities, "set_true") # Any entity type + """ + substring_lower = object_id_substring.lower() + for entity in entities: + if substring_lower in entity.object_id.lower() and ( + entity_type is None or isinstance(entity, entity_type) + ): + return entity + return None + + +def require_entity( + entities: list[EntityInfo], + object_id_substring: str, + entity_type: type[T] | None = None, + description: str | None = None, +) -> T | EntityInfo: + """Find an entity or raise AssertionError if not found. + + Args: + entities: List of entity info objects from the API + object_id_substring: Substring to search for in object_id (case-insensitive) + entity_type: Optional entity type to filter by (e.g., BinarySensorInfo) + description: Human-readable description for error message + + Returns: + The first matching entity + + Raises: + AssertionError: If no matching entity is found + + Example: + binary_sensor = require_entity(entities, "test_sensor", BinarySensorInfo) + button = require_entity(entities, "set_true", description="Set True button") + """ + entity = find_entity(entities, object_id_substring, entity_type) + if entity is None: + desc = description or f"entity with '{object_id_substring}' in object_id" + type_info = f" of type {entity_type.__name__}" if entity_type else "" + raise AssertionError(f"{desc}{type_info} not found in entities") + return entity + def build_key_to_entity_mapping( entities: list[EntityInfo], entity_names: list[str] diff --git a/tests/integration/test_api_action_responses.py b/tests/integration/test_api_action_responses.py new file mode 100644 index 0000000000..d441a231aa --- /dev/null +++ b/tests/integration/test_api_action_responses.py @@ -0,0 +1,258 @@ +"""Integration test for API action responses feature. + +Tests the supports_response modes: none, status, optional, only. +""" + +from __future__ import annotations + +import asyncio +import json +import re + +from aioesphomeapi import SupportsResponseType, UserService, UserServiceArgType +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_action_responses( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test API action response modes work correctly.""" + loop = asyncio.get_running_loop() + + # Track log messages for each action type + no_response_future = loop.create_future() + status_success_future = loop.create_future() + status_error_future = loop.create_future() + optional_response_future = loop.create_future() + only_response_future = loop.create_future() + nested_json_future = loop.create_future() + + # Patterns to match in logs + no_response_pattern = re.compile(r"ACTION_NO_RESPONSE called with: test_message") + status_success_pattern = re.compile( + r"ACTION_STATUS_RESPONSE success \(call_id=\d+\)" + ) + status_error_pattern = re.compile(r"ACTION_STATUS_RESPONSE error \(call_id=\d+\)") + optional_response_pattern = re.compile( + r"ACTION_OPTIONAL_RESPONSE \(call_id=\d+, return_response=\d+, value=42\)" + ) + only_response_pattern = re.compile( + r"ACTION_ONLY_RESPONSE \(call_id=\d+, name=World\)" + ) + nested_json_pattern = re.compile(r"ACTION_NESTED_JSON \(call_id=\d+\)") + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not no_response_future.done() and no_response_pattern.search(line): + no_response_future.set_result(True) + elif not status_success_future.done() and status_success_pattern.search(line): + status_success_future.set_result(True) + elif not status_error_future.done() and status_error_pattern.search(line): + status_error_future.set_result(True) + elif not optional_response_future.done() and optional_response_pattern.search( + line + ): + optional_response_future.set_result(True) + elif not only_response_future.done() and only_response_pattern.search(line): + only_response_future.set_result(True) + elif not nested_json_future.done() and nested_json_pattern.search(line): + nested_json_future.set_result(True) + + # 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 == "api-action-responses-test" + + # List services + _, services = await client.list_entities_services() + + # Should have 5 services + assert len(services) == 5, f"Expected 5 services, found {len(services)}" + + # Find our services + action_no_response: UserService | None = None + action_status_response: UserService | None = None + action_optional_response: UserService | None = None + action_only_response: UserService | None = None + action_nested_json: UserService | None = None + + for service in services: + if service.name == "action_no_response": + action_no_response = service + elif service.name == "action_status_response": + action_status_response = service + elif service.name == "action_optional_response": + action_optional_response = service + elif service.name == "action_only_response": + action_only_response = service + elif service.name == "action_nested_json": + action_nested_json = service + + assert action_no_response is not None, "action_no_response not found" + assert action_status_response is not None, "action_status_response not found" + assert action_optional_response is not None, ( + "action_optional_response not found" + ) + assert action_only_response is not None, "action_only_response not found" + assert action_nested_json is not None, "action_nested_json not found" + + # Verify supports_response modes + assert action_no_response.supports_response is None or ( + action_no_response.supports_response == SupportsResponseType.NONE + ), ( + f"action_no_response should have supports_response=NONE, got {action_no_response.supports_response}" + ) + + assert ( + action_status_response.supports_response == SupportsResponseType.STATUS + ), ( + f"action_status_response should have supports_response=STATUS, " + f"got {action_status_response.supports_response}" + ) + + assert ( + action_optional_response.supports_response == SupportsResponseType.OPTIONAL + ), ( + f"action_optional_response should have supports_response=OPTIONAL, " + f"got {action_optional_response.supports_response}" + ) + + assert action_only_response.supports_response == SupportsResponseType.ONLY, ( + f"action_only_response should have supports_response=ONLY, " + f"got {action_only_response.supports_response}" + ) + + assert action_nested_json.supports_response == SupportsResponseType.ONLY, ( + f"action_nested_json should have supports_response=ONLY, " + f"got {action_nested_json.supports_response}" + ) + + # Verify argument types + # action_no_response: string message + assert len(action_no_response.args) == 1 + assert action_no_response.args[0].name == "message" + assert action_no_response.args[0].type == UserServiceArgType.STRING + + # action_status_response: bool should_succeed + assert len(action_status_response.args) == 1 + assert action_status_response.args[0].name == "should_succeed" + assert action_status_response.args[0].type == UserServiceArgType.BOOL + + # action_optional_response: int value + assert len(action_optional_response.args) == 1 + assert action_optional_response.args[0].name == "value" + assert action_optional_response.args[0].type == UserServiceArgType.INT + + # action_only_response: string name + assert len(action_only_response.args) == 1 + assert action_only_response.args[0].name == "name" + assert action_only_response.args[0].type == UserServiceArgType.STRING + + # action_nested_json: no args + assert len(action_nested_json.args) == 0 + + # Test action_no_response (supports_response: none) + # No response expected for this action + response = await client.execute_service( + action_no_response, {"message": "test_message"} + ) + assert response is None, "action_no_response should not return a response" + await asyncio.wait_for(no_response_future, timeout=5.0) + + # Test action_status_response with success (supports_response: status) + response = await client.execute_service( + action_status_response, + {"should_succeed": True}, + return_response=True, + ) + await asyncio.wait_for(status_success_future, timeout=5.0) + assert response is not None, "Expected response for status action" + assert response.success is True, ( + f"Expected success=True, got {response.success}" + ) + assert response.error_message == "", ( + f"Expected empty error_message, got '{response.error_message}'" + ) + + # Test action_status_response with error + response = await client.execute_service( + action_status_response, + {"should_succeed": False}, + return_response=True, + ) + await asyncio.wait_for(status_error_future, timeout=5.0) + assert response is not None, "Expected response for status action" + assert response.success is False, ( + f"Expected success=False, got {response.success}" + ) + assert "Intentional failure" in response.error_message, ( + f"Expected error message containing 'Intentional failure', " + f"got '{response.error_message}'" + ) + + # Test action_optional_response (supports_response: optional) + response = await client.execute_service( + action_optional_response, + {"value": 42}, + return_response=True, + ) + await asyncio.wait_for(optional_response_future, timeout=5.0) + assert response is not None, "Expected response for optional action" + assert response.success is True, ( + f"Expected success=True, got {response.success}" + ) + # Parse response data as JSON + response_json = json.loads(response.response_data.decode("utf-8")) + assert response_json["input"] == 42, ( + f"Expected input=42, got {response_json.get('input')}" + ) + assert response_json["doubled"] == 84, ( + f"Expected doubled=84, got {response_json.get('doubled')}" + ) + + # Test action_only_response (supports_response: only) + response = await client.execute_service( + action_only_response, + {"name": "World"}, + return_response=True, + ) + await asyncio.wait_for(only_response_future, timeout=5.0) + assert response is not None, "Expected response for only action" + assert response.success is True, ( + f"Expected success=True, got {response.success}" + ) + response_json = json.loads(response.response_data.decode("utf-8")) + assert response_json["greeting"] == "Hello, World!", ( + f"Expected greeting='Hello, World!', got {response_json.get('greeting')}" + ) + assert response_json["length"] == 5, ( + f"Expected length=5, got {response_json.get('length')}" + ) + + # Test action_nested_json + response = await client.execute_service( + action_nested_json, + {}, + return_response=True, + ) + await asyncio.wait_for(nested_json_future, timeout=5.0) + assert response is not None, "Expected response for nested json action" + assert response.success is True, ( + f"Expected success=True, got {response.success}" + ) + response_json = json.loads(response.response_data.decode("utf-8")) + # Verify nested structure + assert response_json["config"]["wifi"]["connected"] is True + assert response_json["config"]["api"]["port"] == 6053 + assert response_json["items"][0] == "first" + assert response_json["items"][1] == "second" diff --git a/tests/integration/test_api_action_timeout.py b/tests/integration/test_api_action_timeout.py new file mode 100644 index 0000000000..cec0967131 --- /dev/null +++ b/tests/integration/test_api_action_timeout.py @@ -0,0 +1,172 @@ +"""Integration test for API action call timeout functionality. + +Tests that action calls are automatically cleaned up after timeout, +and that late responses are handled gracefully. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import re + +from aioesphomeapi import UserService +import pytest + +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_api_action_timeout( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test API action call timeout behavior. + + This test uses a 500ms timeout (set via USE_API_ACTION_CALL_TIMEOUT_MS define) + to verify: + 1. Actions that respond within the timeout work correctly + 2. Actions that exceed the timeout have their calls cleaned up + 3. Late responses log a warning but don't crash + """ + loop = asyncio.get_running_loop() + + # Track log messages + immediate_future = loop.create_future() + short_delay_responding_future = loop.create_future() + long_delay_starting_future = loop.create_future() + long_delay_responding_future = loop.create_future() + timeout_warning_future = loop.create_future() + + # Patterns to match in logs + immediate_pattern = re.compile(r"ACTION_IMMEDIATE responding") + short_delay_responding_pattern = re.compile(r"ACTION_SHORT_DELAY responding") + long_delay_starting_pattern = re.compile(r"ACTION_LONG_DELAY starting") + long_delay_responding_pattern = re.compile( + r"ACTION_LONG_DELAY responding \(after timeout\)" + ) + # This warning is logged when api.respond is called after the action call timed out + timeout_warning_pattern = re.compile( + r"Cannot send response: no active call found for action_call_id" + ) + + def check_output(line: str) -> None: + """Check log output for expected messages.""" + if not immediate_future.done() and immediate_pattern.search(line): + immediate_future.set_result(True) + elif ( + not short_delay_responding_future.done() + and short_delay_responding_pattern.search(line) + ): + short_delay_responding_future.set_result(True) + elif ( + not long_delay_starting_future.done() + and long_delay_starting_pattern.search(line) + ): + long_delay_starting_future.set_result(True) + elif ( + not long_delay_responding_future.done() + and long_delay_responding_pattern.search(line) + ): + long_delay_responding_future.set_result(True) + elif not timeout_warning_future.done() and timeout_warning_pattern.search(line): + timeout_warning_future.set_result(True) + + # 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 == "api-action-timeout-test" + + # List services + _, services = await client.list_entities_services() + + # Should have 3 services + assert len(services) == 3, f"Expected 3 services, found {len(services)}" + + # Find our services + action_immediate: UserService | None = None + action_short_delay: UserService | None = None + action_long_delay: UserService | None = None + + for service in services: + if service.name == "action_immediate": + action_immediate = service + elif service.name == "action_short_delay": + action_short_delay = service + elif service.name == "action_long_delay": + action_long_delay = service + + assert action_immediate is not None, "action_immediate not found" + assert action_short_delay is not None, "action_short_delay not found" + assert action_long_delay is not None, "action_long_delay not found" + + # Test 1: Immediate response should work + response = await client.execute_service( + action_immediate, + {}, + return_response=True, + ) + await asyncio.wait_for(immediate_future, timeout=1.0) + assert response is not None, "Expected response for immediate action" + assert response.success is True + + # Test 2: Short delay (200ms) should work within the 500ms timeout + response = await client.execute_service( + action_short_delay, + {}, + return_response=True, + ) + await asyncio.wait_for(short_delay_responding_future, timeout=1.0) + assert response is not None, "Expected response for short delay action" + assert response.success is True + + # Test 3: Long delay (1s) should exceed the 500ms timeout + # The server-side timeout will clean up the action call after 500ms + # The client will timeout waiting for the response + # When the action finally tries to respond after 1s, it will log a warning + + # Start the long delay action (don't await it fully - it will timeout) + long_delay_task = asyncio.create_task( + client.execute_service( + action_long_delay, + {}, + return_response=True, + timeout=2.0, # Give client enough time to see the late response attempt + ) + ) + + # Wait for the action to start + await asyncio.wait_for(long_delay_starting_future, timeout=1.0) + + # Wait for the action to try to respond (after 1s delay) + await asyncio.wait_for(long_delay_responding_future, timeout=2.0) + + # Wait for the warning log about no active call + await asyncio.wait_for(timeout_warning_future, timeout=1.0) + + # The client task should complete (either with None response or timeout) + # Client timing out is acceptable - the server-side timeout already cleaned up the call + with contextlib.suppress(TimeoutError): + await asyncio.wait_for(long_delay_task, timeout=1.0) + + # Verify the system is still functional after the timeout + # Call the immediate action again to prove cleanup worked + immediate_future_2 = loop.create_future() + + def check_output_2(line: str) -> None: + if not immediate_future_2.done() and immediate_pattern.search(line): + immediate_future_2.set_result(True) + + response = await client.execute_service( + action_immediate, + {}, + return_response=True, + ) + assert response is not None, "System should still work after timeout" + assert response.success is True diff --git a/tests/integration/test_api_conditional_memory.py b/tests/integration/test_api_conditional_memory.py index cfa32c431d..349b572859 100644 --- a/tests/integration/test_api_conditional_memory.py +++ b/tests/integration/test_api_conditional_memory.py @@ -88,13 +88,13 @@ async def test_api_conditional_memory( assert arg_types["arg_float"] == UserServiceArgType.FLOAT # Call simple service - client.execute_service(simple_service, {}) + await client.execute_service(simple_service, {}) # Wait for service log await asyncio.wait_for(service_simple_future, timeout=5.0) # Call service with arguments - client.execute_service( + await client.execute_service( service_with_args, { "arg_string": "test_string", diff --git a/tests/integration/test_api_custom_services.py b/tests/integration/test_api_custom_services.py index 967c504112..acf69bf092 100644 --- a/tests/integration/test_api_custom_services.py +++ b/tests/integration/test_api_custom_services.py @@ -38,6 +38,7 @@ async def test_api_custom_services( custom_service_future = loop.create_future() custom_args_future = loop.create_future() custom_arrays_future = loop.create_future() + ha_state_future = loop.create_future() # Patterns to match in logs yaml_service_pattern = re.compile(r"YAML service called") @@ -50,6 +51,9 @@ async def test_api_custom_services( custom_arrays_pattern = re.compile( r"Array service called with 2 bools, 3 ints, 2 floats, 2 strings" ) + ha_state_pattern = re.compile( + r"This subscription uses std::string API for backward compatibility" + ) def check_output(line: str) -> None: """Check log output for expected messages.""" @@ -65,6 +69,8 @@ async def test_api_custom_services( custom_args_future.set_result(True) elif not custom_arrays_future.done() and custom_arrays_pattern.search(line): custom_arrays_future.set_result(True) + elif not ha_state_future.done() and ha_state_pattern.search(line): + ha_state_future.set_result(True) # Run with log monitoring async with ( @@ -114,7 +120,7 @@ async def test_api_custom_services( assert custom_arrays_service is not None, "custom_service_with_arrays not found" # Test YAML service - client.execute_service(yaml_service, {}) + await client.execute_service(yaml_service, {}) await asyncio.wait_for(yaml_service_future, timeout=5.0) # Verify YAML service with args arguments @@ -124,7 +130,7 @@ async def test_api_custom_services( assert yaml_args_types["my_string"] == UserServiceArgType.STRING # Test YAML service with arguments - client.execute_service( + await client.execute_service( yaml_args_service, { "my_int": 123, @@ -144,7 +150,7 @@ async def test_api_custom_services( assert yaml_many_args_types["arg4"] == UserServiceArgType.STRING # Test YAML service with many arguments - client.execute_service( + await client.execute_service( yaml_many_args_service, { "arg1": 42, @@ -156,7 +162,7 @@ async def test_api_custom_services( await asyncio.wait_for(yaml_many_args_future, timeout=5.0) # Test simple CustomAPIDevice service - client.execute_service(custom_service, {}) + await client.execute_service(custom_service, {}) await asyncio.wait_for(custom_service_future, timeout=5.0) # Verify custom_args_service arguments @@ -168,7 +174,7 @@ async def test_api_custom_services( assert arg_types["arg_float"] == UserServiceArgType.FLOAT # Test CustomAPIDevice service with arguments - client.execute_service( + await client.execute_service( custom_args_service, { "arg_string": "test_string", @@ -188,7 +194,7 @@ async def test_api_custom_services( assert array_arg_types["string_array"] == UserServiceArgType.STRING_ARRAY # Test CustomAPIDevice service with arrays - client.execute_service( + await client.execute_service( custom_arrays_service, { "bool_array": [True, False], @@ -198,3 +204,8 @@ async def test_api_custom_services( }, ) await asyncio.wait_for(custom_arrays_future, timeout=5.0) + + # Test Home Assistant state subscription (std::string API backward compatibility) + # This verifies that custom_api_device.h can still use std::string overloads + client.send_home_assistant_state("sensor.custom_test", "", "42.5") + await asyncio.wait_for(ha_state_future, timeout=5.0) diff --git a/tests/integration/test_api_homeassistant.py b/tests/integration/test_api_homeassistant.py index f69838396d..1343691f5f 100644 --- a/tests/integration/test_api_homeassistant.py +++ b/tests/integration/test_api_homeassistant.py @@ -81,8 +81,15 @@ async def test_api_homeassistant( "input_number.set_value": loop.create_future(), # ha_number_service_call "switch.turn_on": loop.create_future(), # ha_switch_on_service_call "switch.turn_off": loop.create_future(), # ha_switch_off_service_call + "nonexistent.action_for_error_test": loop.create_future(), # error_test_call } + # Future for error message test + action_error_received_future = loop.create_future() + + # Store client reference for use in callback + client_ref: list = [] # Use list to allow modification in nested function + def on_service_call(service_call: HomeassistantServiceCall) -> None: """Capture HomeAssistant service calls.""" ha_service_calls.append(service_call) @@ -93,6 +100,17 @@ async def test_api_homeassistant( if not future.done(): future.set_result(service_call) + # Immediately respond to the error test call so the test can proceed + # This needs to happen synchronously so ESPHome receives the response + # before logging "=== All tests completed ===" + if service_call.service == "nonexistent.action_for_error_test" and client_ref: + test_error_message = "Test error: action not found" + client_ref[0].send_homeassistant_action_response( + call_id=service_call.call_id, + success=False, + error_message=test_error_message, + ) + def check_output(line: str) -> None: """Check log output for expected messages.""" log_lines.append(line) @@ -131,7 +149,12 @@ async def test_api_homeassistant( if match: ha_number_future.set_result(match.group(1)) - elif not tests_complete_future.done() and tests_complete_pattern.search(line): + # Check for action error message (tests StringRef -> std::string conversion) + # Use separate if (not elif) since this can come after tests_complete + if not action_error_received_future.done() and "Action error received:" in line: + action_error_received_future.set_result(line) + + if not tests_complete_future.done() and tests_complete_pattern.search(line): tests_complete_future.set_result(True) # Run with log monitoring @@ -144,6 +167,9 @@ async def test_api_homeassistant( assert device_info is not None assert device_info.name == "test-ha-api" + # Store client reference for use in service call callback + client_ref.append(client) + # Subscribe to HomeAssistant service calls client.subscribe_service_calls(on_service_call) @@ -163,7 +189,7 @@ async def test_api_homeassistant( assert trigger_service is not None, "trigger_all_tests service not found" # Execute all tests - client.execute_service(trigger_service, {}) + await client.execute_service(trigger_service, {}) # Wait for all tests to complete with appropriate timeouts try: @@ -292,6 +318,17 @@ async def test_api_homeassistant( assert switch_off_call.service == "switch.turn_off" assert switch_off_call.data["entity_id"] == "switch.test_switch" + # 9. Action response error test (tests StringRef error message) + # The error response is sent automatically in on_service_call callback + # Wait for the error to be logged (proves StringRef -> std::string works) + error_log_line = await asyncio.wait_for( + action_error_received_future, timeout=2.0 + ) + test_error_message = "Test error: action not found" + assert test_error_message in error_log_line, ( + f"Expected error message '{test_error_message}' not found in: {error_log_line}" + ) + except TimeoutError as e: # Show recent log lines for debugging recent_logs = "\n".join(log_lines[-20:]) diff --git a/tests/integration/test_api_message_size_batching.py b/tests/integration/test_api_message_size_batching.py index f7859eb902..5b123318c4 100644 --- a/tests/integration/test_api_message_size_batching.py +++ b/tests/integration/test_api_message_size_batching.py @@ -141,6 +141,9 @@ async def test_api_message_size_batching( assert text_input.max_length == 255, ( f"Expected max_length 255, got {text_input.max_length}" ) + assert text_input.pattern == "[A-Za-z0-9 ]+", ( + f"Expected pattern '[A-Za-z0-9 ]+', got '{text_input.pattern}'" + ) # Verify total entity count - messages of various sizes were batched successfully # We have: 3 selects + 3 text sensors + 1 text input + 1 number = 8 total diff --git a/tests/integration/test_api_string_lambda.py b/tests/integration/test_api_string_lambda.py index f4ef77bad8..ece8b192a2 100644 --- a/tests/integration/test_api_string_lambda.py +++ b/tests/integration/test_api_string_lambda.py @@ -75,10 +75,12 @@ async def test_api_string_lambda( assert char_ptr_service is not None, "test_char_ptr_lambda service not found" # Execute all four services to test different lambda return types - client.execute_service(string_service, {"input_string": "STRING_FROM_LAMBDA"}) - client.execute_service(int_service, {"input_number": 42}) - client.execute_service(float_service, {"input_float": 3.14}) - client.execute_service( + await client.execute_service( + string_service, {"input_string": "STRING_FROM_LAMBDA"} + ) + await client.execute_service(int_service, {"input_number": 42}) + await client.execute_service(float_service, {"input_float": 3.14}) + await client.execute_service( char_ptr_service, {"input_number": 123, "input_string": "test_string"} ) diff --git a/tests/integration/test_automation_wait_actions.py b/tests/integration/test_automation_wait_actions.py index adcb8ba487..f4db247231 100644 --- a/tests/integration/test_automation_wait_actions.py +++ b/tests/integration/test_automation_wait_actions.py @@ -71,7 +71,7 @@ async def test_automation_wait_actions( # Test 1: wait_until in automation - trigger 5 times rapidly test_service = next((s for s in services if s.name == "test_wait_until"), None) assert test_service is not None, "test_wait_until service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test1_complete, timeout=3.0) # Verify Test 1: All 5 triggers should complete @@ -82,7 +82,7 @@ async def test_automation_wait_actions( # Test 2: script.wait in automation - trigger 5 times rapidly test_service = next((s for s in services if s.name == "test_script_wait"), None) assert test_service is not None, "test_script_wait service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test2_complete, timeout=3.0) # Verify Test 2: All 5 triggers should complete @@ -95,7 +95,7 @@ async def test_automation_wait_actions( (s for s in services if s.name == "test_wait_timeout"), None ) assert test_service is not None, "test_wait_timeout service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test3_complete, timeout=3.0) # Verify Test 3: All 5 triggers should timeout and complete diff --git a/tests/integration/test_automations.py b/tests/integration/test_automations.py index 83268c1eea..ffd7f5c587 100644 --- a/tests/integration/test_automations.py +++ b/tests/integration/test_automations.py @@ -67,7 +67,7 @@ async def test_delay_action_cancellation( assert test_service is not None, "start_delay_then_restart service not found" # Execute the test sequence - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) # Wait for the second script to start await asyncio.wait_for(second_script_started, timeout=5.0) @@ -138,7 +138,7 @@ async def test_parallel_script_delays( assert test_service is not None, "test_parallel_delays service not found" # Execute the test - this will start 3 parallel scripts with 1 second delays - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) # Wait for all scripts to complete (should take ~1 second, not 3) await asyncio.wait_for(all_scripts_completed, timeout=2.0) diff --git a/tests/integration/test_binary_sensor_invalidate_state.py b/tests/integration/test_binary_sensor_invalidate_state.py new file mode 100644 index 0000000000..ee9e57319c --- /dev/null +++ b/tests/integration/test_binary_sensor_invalidate_state.py @@ -0,0 +1,138 @@ +"""Integration test for binary_sensor.invalidate_state() functionality. + +This tests the fix in PR #12296 where invalidate_state() was not properly +reporting the 'unknown' state to the API. The binary sensor should report +missing_state=True when invalidated. + +Regression test for: https://github.com/esphome/esphome/issues/12252 +""" + +from __future__ import annotations + +import asyncio + +from aioesphomeapi import BinarySensorInfo, BinarySensorState, EntityState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_binary_sensor_invalidate_state( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test that binary_sensor.invalidate_state() reports unknown to the API. + + This verifies that: + 1. Binary sensor starts with missing_state=True (no initial state) + 2. Publishing true sets missing_state=False and state=True + 3. Publishing false sets missing_state=False and state=False + 4. Invalidating state sets missing_state=True (unknown state) + """ + loop = asyncio.get_running_loop() + + # Track state changes + states_received: list[BinarySensorState] = [] + state_future: asyncio.Future[BinarySensorState] = loop.create_future() + + def on_state(state: EntityState) -> None: + """Track binary sensor state changes.""" + if isinstance(state, BinarySensorState): + states_received.append(state) + if not state_future.done(): + state_future.set_result(state) + + async with ( + run_compiled(yaml_config), + 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-binary-sensor-invalidate" + + # Get entities + entities, _ = await client.list_entities_services() + + # Find our binary sensor and buttons using helper + binary_sensor = require_entity(entities, "test_binary_sensor", BinarySensorInfo) + set_true_button = require_entity( + entities, "set_true", description="Set True button" + ) + set_false_button = require_entity( + entities, "set_false", description="Set False button" + ) + invalidate_button = require_entity( + entities, "invalidate", description="Invalidate button" + ) + + # Set up initial state helper to handle the initial state broadcast + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for initial states + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Check initial state - should be missing (unknown) + initial_state = initial_state_helper.initial_states.get(binary_sensor.key) + assert initial_state is not None, "No initial state received for binary sensor" + assert isinstance(initial_state, BinarySensorState) + assert initial_state.missing_state is True, ( + f"Initial state should have missing_state=True, got {initial_state}" + ) + + # Test 1: Set state to true + states_received.clear() + state_future = loop.create_future() + client.button_command(set_true_button.key) + + try: + state = await asyncio.wait_for(state_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for state=true") + + assert state.missing_state is False, ( + f"After setting true, missing_state should be False, got {state}" + ) + assert state.state is True, f"Expected state=True, got {state}" + + # Test 2: Set state to false + states_received.clear() + state_future = loop.create_future() + client.button_command(set_false_button.key) + + try: + state = await asyncio.wait_for(state_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for state=false") + + assert state.missing_state is False, ( + f"After setting false, missing_state should be False, got {state}" + ) + assert state.state is False, f"Expected state=False, got {state}" + + # Test 3: Invalidate state (set to unknown) + # This is the critical test for the bug fix + states_received.clear() + state_future = loop.create_future() + client.button_command(invalidate_button.key) + + try: + state = await asyncio.wait_for(state_future, timeout=5.0) + except TimeoutError: + pytest.fail( + "Timeout waiting for invalidated state - " + "binary_sensor.invalidate_state() may not be reporting to the API. " + "See issue #12252." + ) + + assert state.missing_state is True, ( + f"After invalidate_state(), missing_state should be True (unknown), " + f"got {state}. This is the regression from issue #12252." + ) diff --git a/tests/integration/test_continuation_actions.py b/tests/integration/test_continuation_actions.py index 1069ee7581..e6020c711a 100644 --- a/tests/integration/test_continuation_actions.py +++ b/tests/integration/test_continuation_actions.py @@ -142,7 +142,7 @@ async def test_continuation_actions( # Test 1: IfAction with then branch test_service = next((s for s in services if s.name == "test_if_action"), None) assert test_service is not None, "test_if_action service not found" - client.execute_service(test_service, {"condition": True, "value": 42}) + await client.execute_service(test_service, {"condition": True, "value": 42}) await asyncio.wait_for(test1_complete, timeout=2.0) assert test_results["if_then"], "IfAction then branch not executed" assert test_results["if_complete"], "IfAction did not complete" @@ -150,7 +150,7 @@ async def test_continuation_actions( # Test 1b: IfAction with else branch test1_complete = loop.create_future() test_results["if_complete"] = False - client.execute_service(test_service, {"condition": False, "value": 99}) + await client.execute_service(test_service, {"condition": False, "value": 99}) await asyncio.wait_for(test1_complete, timeout=2.0) assert test_results["if_else"], "IfAction else branch not executed" assert test_results["if_complete"], "IfAction did not complete" @@ -160,14 +160,14 @@ async def test_continuation_actions( assert test_service is not None, "test_nested_if service not found" # Both true - client.execute_service(test_service, {"outer": True, "inner": True}) + await client.execute_service(test_service, {"outer": True, "inner": True}) await asyncio.wait_for(test2_complete, timeout=2.0) assert test_results["nested_both_true"], "Nested both true not executed" # Outer true, inner false test2_complete = loop.create_future() test_results["nested_complete"] = False - client.execute_service(test_service, {"outer": True, "inner": False}) + await client.execute_service(test_service, {"outer": True, "inner": False}) await asyncio.wait_for(test2_complete, timeout=2.0) assert test_results["nested_outer_true_inner_false"], ( "Nested outer true inner false not executed" @@ -176,7 +176,7 @@ async def test_continuation_actions( # Outer false test2_complete = loop.create_future() test_results["nested_complete"] = False - client.execute_service(test_service, {"outer": False, "inner": True}) + await client.execute_service(test_service, {"outer": False, "inner": True}) await asyncio.wait_for(test2_complete, timeout=2.0) assert test_results["nested_outer_false"], "Nested outer false not executed" @@ -185,7 +185,7 @@ async def test_continuation_actions( (s for s in services if s.name == "test_while_action"), None ) assert test_service is not None, "test_while_action service not found" - client.execute_service(test_service, {"max_count": 3}) + await client.execute_service(test_service, {"max_count": 3}) await asyncio.wait_for(test3_complete, timeout=2.0) assert test_results["while_iterations"] == 3, ( f"WhileAction expected 3 iterations, got {test_results['while_iterations']}" @@ -197,7 +197,7 @@ async def test_continuation_actions( (s for s in services if s.name == "test_repeat_action"), None ) assert test_service is not None, "test_repeat_action service not found" - client.execute_service(test_service, {"count": 5}) + await client.execute_service(test_service, {"count": 5}) await asyncio.wait_for(test4_complete, timeout=2.0) assert test_results["repeat_iterations"] == 5, ( f"RepeatAction expected 5 iterations, got {test_results['repeat_iterations']}" @@ -207,7 +207,7 @@ async def test_continuation_actions( # Test 5: Combined (if + repeat + while) test_service = next((s for s in services if s.name == "test_combined"), None) assert test_service is not None, "test_combined service not found" - client.execute_service(test_service, {"do_loop": True, "loop_count": 2}) + await client.execute_service(test_service, {"do_loop": True, "loop_count": 2}) await asyncio.wait_for(test5_complete, timeout=2.0) # Should execute: repeat 2 times, each iteration does while from iteration down to 0 # iteration 0: while 0 times = 0 @@ -221,7 +221,7 @@ async def test_continuation_actions( # Test 6: Rapid triggers (tests memory efficiency of ContinuationAction) test_service = next((s for s in services if s.name == "test_rapid_if"), None) assert test_service is not None, "test_rapid_if service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test6_complete, timeout=2.0) # Values 1, 2 should hit else (<=2), values 3, 4, 5 should hit then (>2) assert test_results["rapid_else"] == 2, ( diff --git a/tests/integration/test_scheduler_bulk_cleanup.py b/tests/integration/test_scheduler_bulk_cleanup.py index b52a4a3496..973f59b838 100644 --- a/tests/integration/test_scheduler_bulk_cleanup.py +++ b/tests/integration/test_scheduler_bulk_cleanup.py @@ -98,7 +98,7 @@ async def test_scheduler_bulk_cleanup( ) # Execute the test - client.execute_service(trigger_bulk_cleanup_service, {}) + await client.execute_service(trigger_bulk_cleanup_service, {}) # Wait for test completion try: diff --git a/tests/integration/test_scheduler_defer_cancel.py b/tests/integration/test_scheduler_defer_cancel.py index 34c46bab82..bf34de9677 100644 --- a/tests/integration/test_scheduler_defer_cancel.py +++ b/tests/integration/test_scheduler_defer_cancel.py @@ -81,7 +81,7 @@ async def test_scheduler_defer_cancel( client.subscribe_states(on_state) # Execute the test - client.execute_service(test_defer_cancel_service, {}) + await client.execute_service(test_defer_cancel_service, {}) # Wait for test completion try: diff --git a/tests/integration/test_scheduler_defer_cancel_regular.py b/tests/integration/test_scheduler_defer_cancel_regular.py index c93d814fbe..4c37062844 100644 --- a/tests/integration/test_scheduler_defer_cancel_regular.py +++ b/tests/integration/test_scheduler_defer_cancel_regular.py @@ -59,7 +59,7 @@ async def test_scheduler_defer_cancels_regular( assert test_service is not None, "test_defer_cancels_regular service not found" # Execute the test - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) # Wait for test completion try: diff --git a/tests/integration/test_scheduler_defer_fifo_simple.py b/tests/integration/test_scheduler_defer_fifo_simple.py index 3502302368..4c5c2b56de 100644 --- a/tests/integration/test_scheduler_defer_fifo_simple.py +++ b/tests/integration/test_scheduler_defer_fifo_simple.py @@ -84,7 +84,7 @@ async def test_scheduler_defer_fifo_simple( client.subscribe_states(on_state) # Test 1: Test set_timeout(0) - client.execute_service(test_set_timeout_service, {}) + await client.execute_service(test_set_timeout_service, {}) # Wait for first test completion try: @@ -102,7 +102,7 @@ async def test_scheduler_defer_fifo_simple( test_result_future = loop.create_future() # Test 2: Test defer() - client.execute_service(test_defer_service, {}) + await client.execute_service(test_defer_service, {}) # Wait for second test completion try: diff --git a/tests/integration/test_scheduler_defer_stress.py b/tests/integration/test_scheduler_defer_stress.py index 6f4d997307..345ba9434c 100644 --- a/tests/integration/test_scheduler_defer_stress.py +++ b/tests/integration/test_scheduler_defer_stress.py @@ -92,7 +92,7 @@ async def test_scheduler_defer_stress( assert run_stress_test_service is not None, "run_stress_test service not found" # Call the run_stress_test service to start the test - client.execute_service(run_stress_test_service, {}) + await client.execute_service(run_stress_test_service, {}) # Wait for all defers to execute (should be quick) try: diff --git a/tests/integration/test_scheduler_heap_stress.py b/tests/integration/test_scheduler_heap_stress.py index 2d55b8ae89..cceadd0661 100644 --- a/tests/integration/test_scheduler_heap_stress.py +++ b/tests/integration/test_scheduler_heap_stress.py @@ -99,7 +99,7 @@ async def test_scheduler_heap_stress( ) # Call the run_heap_stress_test service to start the test - client.execute_service(run_stress_test_service, {}) + await client.execute_service(run_stress_test_service, {}) # Wait for all callbacks to execute (should be quick, but give more time for scheduling) try: diff --git a/tests/integration/test_scheduler_null_name.py b/tests/integration/test_scheduler_null_name.py index 75864ea2d2..9eeb648d59 100644 --- a/tests/integration/test_scheduler_null_name.py +++ b/tests/integration/test_scheduler_null_name.py @@ -48,7 +48,7 @@ async def test_scheduler_null_name( assert test_null_name_service is not None, "test_null_name service not found" # Execute the test - client.execute_service(test_null_name_service, {}) + await client.execute_service(test_null_name_service, {}) # Wait for test completion try: diff --git a/tests/integration/test_scheduler_pool.py b/tests/integration/test_scheduler_pool.py index b5f9f12631..021917cc25 100644 --- a/tests/integration/test_scheduler_pool.py +++ b/tests/integration/test_scheduler_pool.py @@ -120,42 +120,42 @@ async def test_scheduler_pool( try: # Phase 1: Component lifecycle - client.execute_service(phase_services[1], {}) + await client.execute_service(phase_services[1], {}) await asyncio.wait_for(phase_futures[1], timeout=1.0) await asyncio.sleep(0.05) # Let timeouts complete # Phase 2: Sensor polling - client.execute_service(phase_services[2], {}) + await client.execute_service(phase_services[2], {}) await asyncio.wait_for(phase_futures[2], timeout=1.0) await asyncio.sleep(0.1) # Let intervals run a bit # Phase 3: Communication patterns - client.execute_service(phase_services[3], {}) + await client.execute_service(phase_services[3], {}) await asyncio.wait_for(phase_futures[3], timeout=1.0) await asyncio.sleep(0.1) # Let heartbeat run # Phase 4: Defer patterns - client.execute_service(phase_services[4], {}) + await client.execute_service(phase_services[4], {}) await asyncio.wait_for(phase_futures[4], timeout=1.0) await asyncio.sleep(0.2) # Let everything settle and recycle # Phase 5: Pool reuse verification - client.execute_service(phase_services[5], {}) + await client.execute_service(phase_services[5], {}) await asyncio.wait_for(phase_futures[5], timeout=1.0) await asyncio.sleep(0.1) # Let Phase 5 timeouts complete and recycle # Phase 6: Full pool reuse verification - client.execute_service(phase_services[6], {}) + await client.execute_service(phase_services[6], {}) await asyncio.wait_for(phase_futures[6], timeout=1.0) await asyncio.sleep(0.1) # Let Phase 6 timeouts complete # Phase 7: Same-named defer optimization - client.execute_service(phase_services[7], {}) + await client.execute_service(phase_services[7], {}) await asyncio.wait_for(phase_futures[7], timeout=1.0) await asyncio.sleep(0.05) # Let the single defer execute # Complete test - client.execute_service(complete_service, {}) + await client.execute_service(complete_service, {}) await asyncio.wait_for(test_complete_future, timeout=0.5) except TimeoutError as e: diff --git a/tests/integration/test_scheduler_rapid_cancellation.py b/tests/integration/test_scheduler_rapid_cancellation.py index 1b7da32aaa..1b67e7fc33 100644 --- a/tests/integration/test_scheduler_rapid_cancellation.py +++ b/tests/integration/test_scheduler_rapid_cancellation.py @@ -108,7 +108,7 @@ async def test_scheduler_rapid_cancellation( ) # Call the service to start the test - client.execute_service(run_test_service, {}) + await client.execute_service(run_test_service, {}) # Wait for test to complete with timeout try: diff --git a/tests/integration/test_scheduler_recursive_timeout.py b/tests/integration/test_scheduler_recursive_timeout.py index d98d2ac5ee..7d7131f8f6 100644 --- a/tests/integration/test_scheduler_recursive_timeout.py +++ b/tests/integration/test_scheduler_recursive_timeout.py @@ -79,7 +79,7 @@ async def test_scheduler_recursive_timeout( ) # Call the service to start the test - client.execute_service(run_test_service, {}) + await client.execute_service(run_test_service, {}) # Wait for test to complete try: diff --git a/tests/integration/test_scheduler_removed_item_race.py b/tests/integration/test_scheduler_removed_item_race.py index 3e72bacc0d..5c78f829a4 100644 --- a/tests/integration/test_scheduler_removed_item_race.py +++ b/tests/integration/test_scheduler_removed_item_race.py @@ -81,7 +81,7 @@ async def test_scheduler_removed_item_race( assert run_test_service is not None, "run_test service not found" # Execute the test - client.execute_service(run_test_service, {}) + await client.execute_service(run_test_service, {}) # Wait for test completion try: diff --git a/tests/integration/test_scheduler_simultaneous_callbacks.py b/tests/integration/test_scheduler_simultaneous_callbacks.py index 82fd0fc01e..66b2862eef 100644 --- a/tests/integration/test_scheduler_simultaneous_callbacks.py +++ b/tests/integration/test_scheduler_simultaneous_callbacks.py @@ -98,7 +98,7 @@ async def test_scheduler_simultaneous_callbacks( ) # Call the service to start the test - client.execute_service(run_test_service, {}) + await client.execute_service(run_test_service, {}) # Wait for test to complete try: diff --git a/tests/integration/test_scheduler_string_lifetime.py b/tests/integration/test_scheduler_string_lifetime.py index 7ec5a54373..bfa581129b 100644 --- a/tests/integration/test_scheduler_string_lifetime.py +++ b/tests/integration/test_scheduler_string_lifetime.py @@ -134,27 +134,27 @@ async def test_scheduler_string_lifetime( # Run tests sequentially, waiting for each to complete try: # Test 1 - client.execute_service(test_services["test1"], {}) + await client.execute_service(test_services["test1"], {}) await asyncio.wait_for(test1_complete.wait(), timeout=5.0) # Test 2 - client.execute_service(test_services["test2"], {}) + await client.execute_service(test_services["test2"], {}) await asyncio.wait_for(test2_complete.wait(), timeout=5.0) # Test 3 - client.execute_service(test_services["test3"], {}) + await client.execute_service(test_services["test3"], {}) await asyncio.wait_for(test3_complete.wait(), timeout=5.0) # Test 4 - client.execute_service(test_services["test4"], {}) + await client.execute_service(test_services["test4"], {}) await asyncio.wait_for(test4_complete.wait(), timeout=5.0) # Test 5 - client.execute_service(test_services["test5"], {}) + await client.execute_service(test_services["test5"], {}) await asyncio.wait_for(test5_complete.wait(), timeout=5.0) # Final check - client.execute_service(test_services["final"], {}) + await client.execute_service(test_services["final"], {}) await asyncio.wait_for(all_tests_complete.wait(), timeout=5.0) except TimeoutError: diff --git a/tests/integration/test_scheduler_string_name_stress.py b/tests/integration/test_scheduler_string_name_stress.py index 4c52913e63..56b8998c56 100644 --- a/tests/integration/test_scheduler_string_name_stress.py +++ b/tests/integration/test_scheduler_string_name_stress.py @@ -92,7 +92,7 @@ async def test_scheduler_string_name_stress( ) # Call the service to start the test - client.execute_service(run_stress_test_service, {}) + await client.execute_service(run_stress_test_service, {}) # Wait for test to complete or crash try: diff --git a/tests/integration/test_script_delay_params.py b/tests/integration/test_script_delay_params.py index 1b5d70863b..37c72f0f7d 100644 --- a/tests/integration/test_script_delay_params.py +++ b/tests/integration/test_script_delay_params.py @@ -90,7 +90,7 @@ async def test_script_delay_with_params( assert test_service is not None, "test_repeat_with_delay service not found" # Execute the test - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) # Wait for test to complete (10 iterations * ~100ms each + margin) try: diff --git a/tests/integration/test_script_queued.py b/tests/integration/test_script_queued.py index ce1c25b649..c86c289719 100644 --- a/tests/integration/test_script_queued.py +++ b/tests/integration/test_script_queued.py @@ -136,7 +136,7 @@ async def test_script_queued( # Test 1: Queue depth limit test_service = next((s for s in services if s.name == "test_queue_depth"), None) assert test_service is not None, "test_queue_depth service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test1_complete, timeout=2.0) await asyncio.sleep(0.1) # Give time for rejections @@ -151,7 +151,7 @@ async def test_script_queued( # Test 2: Ring buffer order test_service = next((s for s in services if s.name == "test_ring_buffer"), None) assert test_service is not None, "test_ring_buffer service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test2_complete, timeout=2.0) # Verify Test 2 @@ -165,7 +165,7 @@ async def test_script_queued( # Test 3: Stop clears queue test_service = next((s for s in services if s.name == "test_stop_clears"), None) assert test_service is not None, "test_stop_clears service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test3_complete, timeout=2.0) # Verify Test 3 @@ -179,7 +179,7 @@ async def test_script_queued( # Test 4: Rejection enforcement (max_runs=3) test_service = next((s for s in services if s.name == "test_rejection"), None) assert test_service is not None, "test_rejection service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test4_complete, timeout=2.0) await asyncio.sleep(0.1) # Give time for rejections @@ -194,7 +194,7 @@ async def test_script_queued( # Test 5: No parameters test_service = next((s for s in services if s.name == "test_no_params"), None) assert test_service is not None, "test_no_params service not found" - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) await asyncio.wait_for(test5_complete, timeout=2.0) # Verify Test 5 diff --git a/tests/integration/test_text_command.py b/tests/integration/test_text_command.py new file mode 100644 index 0000000000..82fe981578 --- /dev/null +++ b/tests/integration/test_text_command.py @@ -0,0 +1,126 @@ +"""Integration test for text command zero-copy optimization. + +Tests that TextCommandRequest correctly handles the pointer_to_buffer +optimization for the state field, ensuring text values are properly +transmitted via the API. +""" + +from __future__ import annotations + +import asyncio +from typing import Any + +from aioesphomeapi import TextInfo, TextState +import pytest + +from .state_utils import InitialStateHelper, require_entity +from .types import APIClientConnectedFactory, RunCompiledFunction + + +@pytest.mark.asyncio +async def test_text_command( + yaml_config: str, + run_compiled: RunCompiledFunction, + api_client_connected: APIClientConnectedFactory, +) -> None: + """Test text command with various string values including edge cases.""" + loop = asyncio.get_running_loop() + async with run_compiled(yaml_config), api_client_connected() as client: + # Verify we can get device info + device_info = await client.device_info() + assert device_info is not None + assert device_info.name == "host-text-command-test" + + # Get list of entities + entities, _ = await client.list_entities_services() + + # Find our text entities using require_entity + test_text = require_entity(entities, "test_text", TextInfo, "Test Text entity") + test_password = require_entity( + entities, "test_password", TextInfo, "Test Password entity" + ) + test_text_long = require_entity( + entities, "test_text_long", TextInfo, "Test Text Long entity" + ) + + # Track state changes + states: dict[int, Any] = {} + state_futures: dict[int, asyncio.Future[Any]] = {} + + def on_state(state: Any) -> None: + states[state.key] = state + if state.key in state_futures and not state_futures[state.key].done(): + state_futures[state.key].set_result(state) + + # Set up InitialStateHelper to swallow initial state broadcasts + initial_state_helper = InitialStateHelper(entities) + client.subscribe_states(initial_state_helper.on_state_wrapper(on_state)) + + # Wait for all initial states to be received + try: + await initial_state_helper.wait_for_initial_states() + except TimeoutError: + pytest.fail("Timeout waiting for initial states") + + # Verify initial states were received + assert test_text.key in initial_state_helper.initial_states + initial_text_state = initial_state_helper.initial_states[test_text.key] + assert isinstance(initial_text_state, TextState) + assert initial_text_state.state == "initial" + + async def wait_for_state_change(key: int, timeout: float = 2.0) -> Any: + """Wait for a state change for the given entity key.""" + state_futures[key] = loop.create_future() + try: + return await asyncio.wait_for(state_futures[key], timeout) + finally: + state_futures.pop(key, None) + + # Test 1: Simple text value + client.text_command(key=test_text.key, state="hello world") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello world" + + # Test 2: Empty string (edge case for zero-copy) + client.text_command(key=test_text.key, state="") + state = await wait_for_state_change(test_text.key) + assert state.state == "" + + # Test 3: Single character + client.text_command(key=test_text.key, state="x") + state = await wait_for_state_change(test_text.key) + assert state.state == "x" + + # Test 4: String with special characters + client.text_command(key=test_text.key, state="hello\tworld\n!") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello\tworld\n!" + + # Test 5: Unicode characters + client.text_command(key=test_text.key, state="hello δΈ–η•Œ 🌍") + state = await wait_for_state_change(test_text.key) + assert state.state == "hello δΈ–η•Œ 🌍" + + # Test 6: Long string (tests buffer handling) + long_text = "a" * 200 + client.text_command(key=test_text_long.key, state=long_text) + state = await wait_for_state_change(test_text_long.key) + assert state.state == long_text + assert len(state.state) == 200 + + # Test 7: Password field (same mechanism, different mode) + client.text_command(key=test_password.key, state="newpassword123") + state = await wait_for_state_change(test_password.key) + assert state.state == "newpassword123" + + # Test 8: String with null bytes embedded (edge case) + # Note: protobuf strings should handle this but it's good to verify + client.text_command(key=test_text.key, state="before\x00after") + state = await wait_for_state_change(test_text.key) + assert state.state == "before\x00after" + + # Test 9: Rapid successive commands (tests buffer reuse) + for i in range(5): + client.text_command(key=test_text.key, state=f"rapid_{i}") + state = await wait_for_state_change(test_text.key) + assert state.state == f"rapid_{i}" diff --git a/tests/integration/test_text_sensor_raw_state.py b/tests/integration/test_text_sensor_raw_state.py index a53ec8c963..482ebbe9c2 100644 --- a/tests/integration/test_text_sensor_raw_state.py +++ b/tests/integration/test_text_sensor_raw_state.py @@ -1,8 +1,10 @@ -"""Integration test for TextSensor get_raw_state() functionality. +"""Integration test for TextSensor get_raw_state() and StringRef-based filters. -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. +This tests: +1. 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. +2. StringRef-based filters (append, prepend, substitute, map) which store + static string data in flash instead of heap-allocating std::string. """ from __future__ import annotations @@ -21,16 +23,25 @@ async def test_text_sensor_raw_state( run_compiled: RunCompiledFunction, api_client_connected: APIClientConnectedFactory, ) -> None: - """Test that get_raw_state() works correctly with and without filters. + """Test text sensor filters and raw_state behavior. - Without filters: get_raw_state() should return the same value as state - With filters: get_raw_state() should return the original (unfiltered) value + Tests: + 1. get_raw_state() without filters returns same as state + 2. get_raw_state() with filters returns original (unfiltered) value + 3. StringRef-based filters: append, prepend, substitute, map, chained """ 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() + append_future: asyncio.Future[str] = loop.create_future() + prepend_future: asyncio.Future[str] = loop.create_future() + substitute_future: asyncio.Future[str] = loop.create_future() + map_on_future: asyncio.Future[str] = loop.create_future() + map_off_future: asyncio.Future[str] = loop.create_future() + map_unknown_future: asyncio.Future[str] = loop.create_future() + chained_future: asyncio.Future[str] = loop.create_future() # Patterns to match log output # NO_FILTER: state='hello world' raw_state='hello world' @@ -39,18 +50,47 @@ async def test_text_sensor_raw_state( with_filter_pattern = re.compile( r"WITH_FILTER: state='([^']*)' raw_state='([^']*)'" ) + # StringRef-based filter patterns + append_pattern = re.compile(r"APPEND: state='([^']*)'") + prepend_pattern = re.compile(r"PREPEND: state='([^']*)'") + substitute_pattern = re.compile(r"SUBSTITUTE: state='([^']*)'") + map_on_pattern = re.compile(r"MAP_ON: state='([^']*)'") + map_off_pattern = re.compile(r"MAP_OFF: state='([^']*)'") + map_unknown_pattern = re.compile(r"MAP_UNKNOWN: state='([^']*)'") + chained_pattern = re.compile(r"CHAINED: 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 no_filter_future.done() and (match := no_filter_pattern.search(line)): + 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))) + if not with_filter_future.done() and ( + match := with_filter_pattern.search(line) + ): + with_filter_future.set_result((match.group(1), match.group(2))) + + if not append_future.done() and (match := append_pattern.search(line)): + append_future.set_result(match.group(1)) + + if not prepend_future.done() and (match := prepend_pattern.search(line)): + prepend_future.set_result(match.group(1)) + + if not substitute_future.done() and (match := substitute_pattern.search(line)): + substitute_future.set_result(match.group(1)) + + if not map_on_future.done() and (match := map_on_pattern.search(line)): + map_on_future.set_result(match.group(1)) + + if not map_off_future.done() and (match := map_off_pattern.search(line)): + map_off_future.set_result(match.group(1)) + + if not map_unknown_future.done() and ( + match := map_unknown_pattern.search(line) + ): + map_unknown_future.set_result(match.group(1)) + + if not chained_future.done() and (match := chained_pattern.search(line)): + chained_future.set_result(match.group(1)) async with ( run_compiled(yaml_config, line_callback=check_output), @@ -112,3 +152,123 @@ async def test_text_sensor_raw_state( f"With filters, state and raw_state should differ. " f"state='{state}', raw_state='{raw_state}'" ) + + # Test 3: Append filter (StringRef-based) + # "test" + " suffix" = "test suffix" + append_button = next( + (e for e in entities if "test_append_button" in e.object_id.lower()), + None, + ) + assert append_button is not None, "Test Append Button not found" + client.button_command(append_button.key) + + try: + state = await asyncio.wait_for(append_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for APPEND log message") + + assert state == "test suffix", ( + f"Append failed: expected 'test suffix', got '{state}'" + ) + + # Test 4: Prepend filter (StringRef-based) + # "prefix " + "test" = "prefix test" + prepend_button = next( + (e for e in entities if "test_prepend_button" in e.object_id.lower()), + None, + ) + assert prepend_button is not None, "Test Prepend Button not found" + client.button_command(prepend_button.key) + + try: + state = await asyncio.wait_for(prepend_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for PREPEND log message") + + assert state == "prefix test", ( + f"Prepend failed: expected 'prefix test', got '{state}'" + ) + + # Test 5: Substitute filter (StringRef-based) + # "foo says hello" with foo->bar, hello->world = "bar says world" + substitute_button = next( + (e for e in entities if "test_substitute_button" in e.object_id.lower()), + None, + ) + assert substitute_button is not None, "Test Substitute Button not found" + client.button_command(substitute_button.key) + + try: + state = await asyncio.wait_for(substitute_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for SUBSTITUTE log message") + + assert state == "bar says world", ( + f"Substitute failed: expected 'bar says world', got '{state}'" + ) + + # Test 6: Map filter - "ON" -> "Active" + map_on_button = next( + (e for e in entities if "test_map_on_button" in e.object_id.lower()), + None, + ) + assert map_on_button is not None, "Test Map ON Button not found" + client.button_command(map_on_button.key) + + try: + state = await asyncio.wait_for(map_on_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for MAP_ON log message") + + assert state == "Active", f"Map ON failed: expected 'Active', got '{state}'" + + # Test 7: Map filter - "OFF" -> "Inactive" + map_off_button = next( + (e for e in entities if "test_map_off_button" in e.object_id.lower()), + None, + ) + assert map_off_button is not None, "Test Map OFF Button not found" + client.button_command(map_off_button.key) + + try: + state = await asyncio.wait_for(map_off_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for MAP_OFF log message") + + assert state == "Inactive", ( + f"Map OFF failed: expected 'Inactive', got '{state}'" + ) + + # Test 8: Map filter - passthrough for unknown values + # "UNKNOWN" -> "UNKNOWN" (no match, passes through unchanged) + map_unknown_button = next( + (e for e in entities if "test_map_unknown_button" in e.object_id.lower()), + None, + ) + assert map_unknown_button is not None, "Test Map Unknown Button not found" + client.button_command(map_unknown_button.key) + + try: + state = await asyncio.wait_for(map_unknown_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for MAP_UNKNOWN log message") + + assert state == "UNKNOWN", ( + f"Map passthrough failed: expected 'UNKNOWN', got '{state}'" + ) + + # Test 9: Chained filters (prepend "[" + append "]") + # "[" + "value" + "]" = "[value]" + chained_button = next( + (e for e in entities if "test_chained_button" in e.object_id.lower()), + None, + ) + assert chained_button is not None, "Test Chained Button not found" + client.button_command(chained_button.key) + + try: + state = await asyncio.wait_for(chained_future, timeout=5.0) + except TimeoutError: + pytest.fail("Timeout waiting for CHAINED log message") + + assert state == "[value]", f"Chained failed: expected '[value]', got '{state}'" diff --git a/tests/integration/test_wait_until_mid_loop_timing.py b/tests/integration/test_wait_until_mid_loop_timing.py index 01cad747ae..b5dd1a0028 100644 --- a/tests/integration/test_wait_until_mid_loop_timing.py +++ b/tests/integration/test_wait_until_mid_loop_timing.py @@ -86,7 +86,7 @@ async def test_wait_until_mid_loop_timing( assert test_service is not None, "test_mid_loop_timeout service not found" # Execute the test - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) # Wait for test to complete (100ms delay + 200ms timeout + margins = ~500ms) await asyncio.wait_for(test_complete, timeout=5.0) diff --git a/tests/integration/test_wait_until_on_boot.py b/tests/integration/test_wait_until_on_boot.py index b42c530c54..da21a43200 100644 --- a/tests/integration/test_wait_until_on_boot.py +++ b/tests/integration/test_wait_until_on_boot.py @@ -74,7 +74,7 @@ async def test_wait_until_on_boot( ) assert set_flag_service is not None, "set_test_flag service not found" - client.execute_service(set_flag_service, {}) + await 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 diff --git a/tests/integration/test_wait_until_ordering.py b/tests/integration/test_wait_until_ordering.py index 7c39913e5a..96b3a8aed0 100644 --- a/tests/integration/test_wait_until_ordering.py +++ b/tests/integration/test_wait_until_ordering.py @@ -71,7 +71,7 @@ async def test_wait_until_fifo_ordering( assert test_service is not None, "test_wait_until_fifo service not found" # Execute the test - client.execute_service(test_service, {}) + await client.execute_service(test_service, {}) # Wait for test to complete try: diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-ard.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-ard.yaml new file mode 100644 index 0000000000..41ded5a763 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-ard.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP32 Arduino tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-ard.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-ard.yaml new file mode 100644 index 0000000000..1eb5d6d5f9 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-ard.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP32-C3 Arduino tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO20 + rx_pin: GPIO21 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-idf.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-idf.yaml new file mode 100644 index 0000000000..5181995a40 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-c3-idf.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP32-C3 IDF tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO20 + rx_pin: GPIO21 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml new file mode 100644 index 0000000000..122f05aced --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/esp32-idf.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP32 IDF tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO17 + rx_pin: GPIO16 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml new file mode 100644 index 0000000000..3bffabf82d --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/esp8266-ard.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for ESP8266 Arduino tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO4 + rx_pin: GPIO5 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 diff --git a/tests/test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml b/tests/test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml new file mode 100644 index 0000000000..fb94939090 --- /dev/null +++ b/tests/test_build_components/common/uart_1200_none_2stopbits/rp2040-ard.yaml @@ -0,0 +1,13 @@ +# Common UART configuration for RP2040 Arduino tests - 1200 baud NONE parity 2 stop bits + +substitutions: + tx_pin: GPIO0 + rx_pin: GPIO1 + +uart: + - id: uart_bus + tx_pin: ${tx_pin} + rx_pin: ${rx_pin} + baud_rate: 1200 + parity: NONE + stop_bits: 2 diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml index 6f3bae1ac4..9ed9b99c49 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.approved.yaml @@ -8,6 +8,8 @@ substitutions: position: x: 79 y: 82 + a: 15 + b: 20 esphome: name: test @@ -34,3 +36,5 @@ test_list: - '{{{"AA"}}}' - '"HELLO"' - '{ 79, 82 }' + - a: 15 should be 15, overridden from command line + b: 20 should stay as 20, not overridden diff --git a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml index 306119b753..64701c03dd 100644 --- a/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml +++ b/tests/unit_tests/fixtures/substitutions/00-simple_var.input.yaml @@ -11,6 +11,13 @@ substitutions: position: x: 79 y: 82 + a: 10 + b: 20 + +# The following key is only used by the test framework +# to simulate command line substitutions +command_line_substitutions: + a: 15 test_list: - "$var1" @@ -35,3 +42,5 @@ test_list: - ${ '{{{"AA"}}}' } - ${ '"HELLO"' } - '{ ${position.x}, ${position.y} }' + - a: ${a} should be 15, overridden from command line + b: ${b} should stay as 20, not overridden diff --git a/tests/unit_tests/fixtures/substitutions/06-package_merging.approved.yaml b/tests/unit_tests/fixtures/substitutions/06-package_merging.approved.yaml new file mode 100644 index 0000000000..3fbf5660d5 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-package_merging.approved.yaml @@ -0,0 +1,43 @@ +fancy_component: &id001 + - id: component9 + value: 9 +some_component: + - id: component1 + value: 1 + - id: component2 + value: 2 + - id: component3 + value: 3 + - id: component4 + value: 4 + - id: component5 + value: 79 + power: 200 + - id: component6 + value: 6 + - id: component7 + value: 7 +switch: &id002 + - platform: gpio + id: switch1 + pin: 12 + - platform: gpio + id: switch2 + pin: 13 +display: + - platform: ili9xxx + dimensions: + width: 100 + height: 480 +substitutions: + extended_component: component5 + package_options: + alternative_package: + alternative_component: + - id: component8 + value: 8 + fancy_package: + fancy_component: *id001 + pin: 12 + some_switches: *id002 + package_selection: fancy_package diff --git a/tests/unit_tests/fixtures/substitutions/06-package_merging.input.yaml b/tests/unit_tests/fixtures/substitutions/06-package_merging.input.yaml new file mode 100644 index 0000000000..d937a89306 --- /dev/null +++ b/tests/unit_tests/fixtures/substitutions/06-package_merging.input.yaml @@ -0,0 +1,61 @@ +substitutions: + package_options: + alternative_package: + alternative_component: + - id: component8 + value: 8 + fancy_package: + fancy_component: + - id: component9 + value: 9 + + pin: 12 + some_switches: + - platform: gpio + id: switch1 + pin: ${pin} + - platform: gpio + id: switch2 + pin: ${pin+1} + + package_selection: fancy_package + +packages: + - ${ package_options[package_selection] } + - some_component: + - id: component1 + value: 1 + - some_component: + - id: component2 + value: 2 + - switch: ${ some_switches } + - packages: + package_with_defaults: !include + file: display.yaml + vars: + native_width: 100 + high_dpi: false + my_package: + packages: + - packages: + special_package: + substitutions: + extended_component: component5 + some_component: + - id: component3 + value: 3 + some_component: + - id: component4 + value: 4 + - id: !extend ${ extended_component } + power: 200 + value: 79 + some_component: + - id: component5 + value: 5 + +some_component: + - id: component6 + value: 6 + - id: component7 + value: 7 diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py index 104cdc2b7a..c9d7b7486e 100644 --- a/tests/unit_tests/test_config_validation.py +++ b/tests/unit_tests/test_config_validation.py @@ -6,7 +6,7 @@ import pytest import voluptuous as vol from esphome import config_validation -from esphome.components.esp32.const import ( +from esphome.components.esp32 import ( VARIANT_ESP32, VARIANT_ESP32C2, VARIANT_ESP32C3, @@ -221,7 +221,7 @@ def hex_int__valid(value): ], ) def test_split_default(framework, platform, variant, full, idf, arduino, simple): - from esphome.components.esp32.const import KEY_ESP32 + from esphome.components.esp32 import KEY_ESP32 from esphome.const import ( KEY_CORE, KEY_TARGET_FRAMEWORK, @@ -251,15 +251,6 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "host": "24", } - idf_mappings = { - "esp32_idf": "4", - "esp32_s2_idf": "7", - "esp32_s3_idf": "10", - "esp32_c3_idf": "13", - "esp32_c6_idf": "16", - "esp32_h2_idf": "19", - } - arduino_mappings = { "esp32_arduino": "3", "esp32_s2_arduino": "6", @@ -269,6 +260,15 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) "esp32_h2_arduino": "18", } + idf_mappings = { + "esp32_idf": "4", + "esp32_s2_idf": "7", + "esp32_s3_idf": "10", + "esp32_c3_idf": "13", + "esp32_c6_idf": "16", + "esp32_h2_idf": "19", + } + schema = config_validation.Schema( { config_validation.SplitDefault( @@ -293,8 +293,8 @@ def test_split_default(framework, platform, variant, full, idf, arduino, simple) @pytest.mark.parametrize( "framework, platform, message", [ - ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), ("arduino", PLATFORM_ESP32, "ESP32 using arduino framework"), + ("esp-idf", PLATFORM_ESP32, "ESP32 using esp-idf framework"), ("arduino", PLATFORM_ESP8266, "ESP8266 using arduino framework"), ("arduino", PLATFORM_RP2040, "RP2040 using arduino framework"), ("arduino", PLATFORM_BK72XX, "BK72XX using arduino framework"), diff --git a/tests/unit_tests/test_main.py b/tests/unit_tests/test_main.py index 670d6c16fc..bd14395037 100644 --- a/tests/unit_tests/test_main.py +++ b/tests/unit_tests/test_main.py @@ -35,7 +35,7 @@ from esphome.__main__ import ( upload_program, upload_using_esptool, ) -from esphome.components.esp32.const import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 +from esphome.components.esp32 import KEY_ESP32, KEY_VARIANT, VARIANT_ESP32 from esphome.const import ( CONF_API, CONF_BROKER, diff --git a/tests/unit_tests/test_substitutions.py b/tests/unit_tests/test_substitutions.py index c5e6618ea6..1d8cb7631d 100644 --- a/tests/unit_tests/test_substitutions.py +++ b/tests/unit_tests/test_substitutions.py @@ -2,13 +2,16 @@ import glob import logging from pathlib import Path from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +import pytest from esphome import config as config_module, yaml_util from esphome.components import substitutions +from esphome.components.packages import do_packages_pass, merge_packages from esphome.config import resolve_extend_remove from esphome.config_helpers import merge_config -from esphome.const import CONF_PACKAGES, CONF_SUBSTITUTIONS +from esphome.const import CONF_SUBSTITUTIONS from esphome.core import CORE from esphome.util import OrderedDict @@ -71,6 +74,8 @@ def verify_database(value: Any, path: str = "") -> str | None: return None if isinstance(value, dict): for k, v in value.items(): + if path == "" and k == CONF_SUBSTITUTIONS: + return None # ignore substitutions key at top level since it is merged. key_result = verify_database(k, f"{path}/{k}") if key_result is not None: return key_result @@ -91,13 +96,22 @@ REMOTES = { ("https://github.com/esphome/repo2", "main"): "remotes/repo2/main", } +# Collect all input YAML files for test_substitutions_fixtures parametrized tests: +HERE = Path(__file__).parent +BASE_DIR = HERE / "fixtures" / "substitutions" +SOURCES = sorted(glob.glob(str(BASE_DIR / "*.input.yaml"))) +assert SOURCES, f"test_substitutions_fixtures: No input YAML files found in {BASE_DIR}" + +@pytest.mark.parametrize( + "source_path", + [Path(p) for p in SOURCES], + ids=lambda p: p.name, +) @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 test_substitutions_fixtures( + mock_clone_or_update: MagicMock, source_path: Path +) -> None: def fake_clone_or_update( *, url: str, @@ -116,72 +130,63 @@ def test_substitutions_fixtures(mock_clone_or_update, fixture_path): raise RuntimeError( f"Cannot find test repository for {url} @ {ref}. Check the REMOTES mapping in test_substitutions.py" ) - return base_dir / path, None + 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) - try: - expected_path = source_path.with_suffix("").with_suffix(".approved.yaml") - test_case = source_path.with_suffix("").stem + expected_path = source_path.with_suffix("").with_suffix(".approved.yaml") + test_case = source_path.with_suffix("").stem - # Load using ESPHome's YAML loader - config = yaml_util.load_yaml(source_path) + # Load using ESPHome's YAML loader + config = yaml_util.load_yaml(source_path) - if CONF_PACKAGES in config: - from esphome.components.packages import do_packages_pass + command_line_substitutions = config.pop("command_line_substitutions", None) - config = do_packages_pass(config) + config = do_packages_pass(config) - substitutions.do_substitution_pass(config, None) + substitutions.do_substitution_pass(config, command_line_substitutions) - resolve_extend_remove(config) - verify_database_result = verify_database(config) - if verify_database_result is not None: - raise AssertionError(verify_database_result) + config = merge_packages(config) - # Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE - if expected_path.is_file(): - expected = yaml_util.load_yaml(expected_path) - elif DEV_MODE: - expected = {} - else: - assert expected_path.is_file(), ( - f"Expected file missing: {expected_path}" - ) + resolve_extend_remove(config) + verify_database_result = verify_database(config) + if verify_database_result is not None: + raise AssertionError(verify_database_result) - # Sort dicts only (not lists) for comparison - got_sorted = sort_dicts(config) - expected_sorted = sort_dicts(expected) + # Also load expected using ESPHome's loader, or use {} if missing and DEV_MODE + if expected_path.is_file(): + expected = yaml_util.load_yaml(expected_path) + elif DEV_MODE: + expected = {} + else: + assert expected_path.is_file(), f"Expected file missing: {expected_path}" - if got_sorted != expected_sorted: - diff = "\n".join(dict_diff(got_sorted, expected_sorted)) - msg = ( - f"Substitution result mismatch for {source_path.name}\n" - f"Diff:\n{diff}\n\n" - f"Got: {got_sorted}\n" - f"Expected: {expected_sorted}" - ) - # Write out the received file when test fails - if DEV_MODE: - received_path = source_path.with_name(f"{test_case}.received.yaml") - write_yaml(received_path, config) - print(msg) - failures.append(msg) - else: - raise AssertionError(msg) - except Exception as err: - _LOGGER.error("Error in test file %s", source_path) - raise err + # Sort dicts only (not lists) for comparison + got_sorted = sort_dicts(config) + expected_sorted = sort_dicts(expected) - if DEV_MODE and failures: - print(f"\n{len(failures)} substitution test case(s) failed.") + if got_sorted != expected_sorted: + diff = "\n".join(dict_diff(got_sorted, expected_sorted)) + msg = ( + f"Substitution result mismatch for {source_path.name}\n" + f"Diff:\n{diff}\n\n" + f"Got: {got_sorted}\n" + f"Expected: {expected_sorted}" + ) + # Write out the received file when test fails + if DEV_MODE: + received_path = source_path.with_name(f"{test_case}.received.yaml") + write_yaml(received_path, config) + msg += f"\nWrote received file to {received_path}." + raise AssertionError(msg) if DEV_MODE: _LOGGER.error("Tests passed, but Dev mode is enabled.") - assert not DEV_MODE # make sure DEV_MODE is disabled after you are finished. + assert ( + not DEV_MODE # make sure DEV_MODE is disabled after you are finished. + ), ( + "Test passed but DEV_MODE must be disabled when running tests. Please set DEV_MODE=False." + ) def test_substitutions_with_command_line_maintains_ordered_dict() -> None: